mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-28 05:14:25 +08:00
changed.对启动器重构的尝试
This commit is contained in:
@@ -36,7 +36,7 @@ internal static class Commands
|
||||
{
|
||||
var appRoot = ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var updateEngine = new UpdateEngineFacade(deploymentLocator);
|
||||
var updateEngine = UpdateEngineFactory.Create(deploymentLocator);
|
||||
var pluginInstaller = new PluginInstallerService();
|
||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||
|
||||
@@ -63,7 +63,7 @@ internal static class Commands
|
||||
|
||||
private static async Task<LauncherResult> ExecuteCoreAsync(
|
||||
CommandContext context,
|
||||
UpdateEngineFacade updateEngine,
|
||||
IUpdateEngine updateEngine,
|
||||
PluginInstallerService pluginInstaller,
|
||||
PluginUpgradeQueueService pluginUpgrades)
|
||||
{
|
||||
@@ -84,7 +84,7 @@ internal static class Commands
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineFacade updateEngine)
|
||||
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, IUpdateEngine updateEngine)
|
||||
{
|
||||
return context.SubCommand.ToLowerInvariant() switch
|
||||
{
|
||||
@@ -102,7 +102,7 @@ internal static class Commands
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineFacade updateEngine)
|
||||
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, IUpdateEngine updateEngine)
|
||||
{
|
||||
return await updateEngine.DownloadAsync(
|
||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||
|
||||
78
LanMountainDesktop.Launcher/Shell/ApplyUpdateGuiFlow.cs
Normal file
78
LanMountainDesktop.Launcher/Shell/ApplyUpdateGuiFlow.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Infrastructure;
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
|
||||
{
|
||||
@@ -37,7 +37,7 @@ internal static class ApplyUpdateEntryHandler
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
UpdateWindow window) =>
|
||||
LauncherCompositionRoot.RunApplyUpdateWithWindowAsync(desktop, context, window);
|
||||
ApplyUpdateGuiFlow.RunAsync(desktop, context, window);
|
||||
}
|
||||
|
||||
internal static class AirAppBrokerEntryHandler
|
||||
|
||||
@@ -4,10 +4,20 @@ using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
internal static class LaunchUiPresenter
|
||||
{
|
||||
public static async Task HideSplashAsync(SplashWindow splashWindow)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(splashWindow.Hide);
|
||||
}
|
||||
|
||||
public static async Task ShowSplashAsync(SplashWindow splashWindow)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(splashWindow.Show);
|
||||
}
|
||||
|
||||
public static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
|
||||
{
|
||||
try
|
||||
@@ -1,6 +1,6 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Infrastructure;
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 启动器背景图片服务
|
||||
@@ -1,17 +1,10 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Resources;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher GUI 入口装配:创建编排器并驱动启动流程。
|
||||
/// Launcher GUI composition root. It only wires services and dispatches to entry coordinators.
|
||||
/// </summary>
|
||||
internal static class LauncherCompositionRoot
|
||||
{
|
||||
@@ -19,593 +12,15 @@ internal static class LauncherCompositionRoot
|
||||
CommandContext context,
|
||||
string appRoot,
|
||||
StartupAttemptRegistry startupAttemptRegistry,
|
||||
LauncherCoordinatorIpcServer coordinatorServer) =>
|
||||
LauncherServiceRegistration.CreateOrchestrator(context, startupAttemptRegistry, coordinatorServer);
|
||||
LauncherCoordinatorIpcServer coordinatorServer)
|
||||
{
|
||||
_ = appRoot;
|
||||
return LauncherServiceRegistration.CreateOrchestrator(context, startupAttemptRegistry, coordinatorServer);
|
||||
}
|
||||
|
||||
public static async Task RunOrchestratorWithSplashAsync(
|
||||
public static Task RunOrchestratorWithSplashAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
SplashWindow splashWindow)
|
||||
{
|
||||
LauncherResult result;
|
||||
SplashWindow? currentSplashWindow = splashWindow;
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var dataLocationResolver = new DataLocationResolver(appRoot);
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
successPolicy,
|
||||
coordinatorPipeName,
|
||||
out var reservedAttempt,
|
||||
out var activeCoordinatorAttempt))
|
||||
{
|
||||
result = await AttachToExistingCoordinatorAsync(
|
||||
context,
|
||||
currentSplashWindow,
|
||||
activeCoordinatorAttempt).ConfigureAwait(false);
|
||||
|
||||
Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
return;
|
||||
}
|
||||
|
||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
||||
new LauncherAirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null,
|
||||
() => dataLocationResolver.ResolveDataRoot())));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||
coordinatorPipeName,
|
||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||
HandleCoordinatorRequestAsync,
|
||||
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
|
||||
coordinatorServer.Start();
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.Info(
|
||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||
|
||||
var orchestrator = CreateOrchestrator(
|
||||
context,
|
||||
appRoot,
|
||||
startupAttemptRegistry,
|
||||
coordinatorServer);
|
||||
|
||||
result = await orchestrator.RunAsync(currentSplashWindow).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Coordinator threw an unhandled exception.", ex);
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = $"Launcher failed: {ex.Message}",
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
if (result.Success ||
|
||||
result.Code == "host_not_found" ||
|
||||
(!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false);
|
||||
if (failureAction == ErrorWindowResult.Exit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (failureAction == ErrorWindowResult.ActivateExisting &&
|
||||
await TryActivateExistingInstanceAsync().ConfigureAwait(false))
|
||||
{
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_requested",
|
||||
Message = "Launcher activated the existing desktop instance.",
|
||||
Details = result.Details
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
currentSplashWindow = CreateSplashWindow();
|
||||
currentSplashWindow.Show();
|
||||
}
|
||||
|
||||
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
if (result.Success)
|
||||
{
|
||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
public static async Task RunApplyUpdateWithWindowAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
UpdateWindow window)
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var updateEngine = new UpdateEngineFacade(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);
|
||||
}
|
||||
|
||||
private static SplashWindow CreateSplashWindow()
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
|
||||
return window;
|
||||
}
|
||||
|
||||
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
|
||||
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid)
|
||||
{
|
||||
if (result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
||||
int.TryParse(hostPidText, out var hostPid))
|
||||
{
|
||||
return hostPid;
|
||||
}
|
||||
|
||||
if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) &&
|
||||
int.TryParse(existingHostPidText, out var existingHostPid))
|
||||
{
|
||||
return existingHostPid;
|
||||
}
|
||||
|
||||
return fallbackHostPid;
|
||||
}
|
||||
|
||||
private static async Task WaitForManagedProcessesToExitAsync(
|
||||
int hostPid,
|
||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
||||
{
|
||||
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
|
||||
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||
CommandContext context,
|
||||
SplashWindow? splashWindow,
|
||||
StartupAttemptRecord? activeCoordinatorAttempt)
|
||||
{
|
||||
var reporter = splashWindow as ISplashStageReporter;
|
||||
reporter?.Report("activation", Strings.Preview_ActivationConnecting);
|
||||
|
||||
if (activeCoordinatorAttempt is not null &&
|
||||
!string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName))
|
||||
{
|
||||
var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase)
|
||||
? LauncherCoordinatorCommands.Attach
|
||||
: LauncherCoordinatorCommands.ActivateDesktop;
|
||||
var request = new LauncherCoordinatorRequest
|
||||
{
|
||||
Command = command,
|
||||
LaunchSource = context.LaunchSource,
|
||||
SuccessPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context)
|
||||
};
|
||||
|
||||
var response = await new LauncherCoordinatorIpcClient()
|
||||
.SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
reporter?.Report("activation", response.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = response.Accepted ||
|
||||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
|
||||
Message = success && !response.Accepted
|
||||
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
|
||||
: response.Message,
|
||||
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
reporter?.Report("activation", activation.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = activation.Accepted
|
||||
? "existing_host_activated"
|
||||
: success
|
||||
? "existing_host_startup_pending"
|
||||
: "existing_host_activation_failed",
|
||||
Message = success && !activation.Accepted
|
||||
? "Existing desktop process is still starting; Launcher attached without starting another process."
|
||||
: activation.Message,
|
||||
Details = BuildCoordinatorResultDetails(null, activation)
|
||||
};
|
||||
}
|
||||
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "launcher_coordinator_unavailable",
|
||||
Message = "Another Launcher is coordinating startup, but it did not respond in time.",
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty,
|
||||
["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty,
|
||||
["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<LauncherCoordinatorResponse> HandleCoordinatorRequestAsync(
|
||||
LauncherCoordinatorRequest request,
|
||||
LauncherCoordinatorStatus status)
|
||||
{
|
||||
if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
|
||||
{
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = activation.Accepted,
|
||||
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
||||
Message = activation.Message,
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt)
|
||||
{
|
||||
return new LauncherCoordinatorStatus
|
||||
{
|
||||
AttemptId = attempt.AttemptId,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
HostPid = attempt.HostPid,
|
||||
HostProcessAlive = TryGetLiveProcess(attempt.HostPid),
|
||||
LaunchSource = attempt.LaunchSource,
|
||||
SuccessPolicy = attempt.SuccessPolicy,
|
||||
LastObservedStage = attempt.LastObservedStage,
|
||||
LastObservedMessage = attempt.LastObservedMessage,
|
||||
PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected,
|
||||
State = attempt.State.ToString(),
|
||||
SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting,
|
||||
Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed,
|
||||
Succeeded = attempt.State == StartupAttemptState.Succeeded,
|
||||
UpdatedAtUtc = attempt.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsRecoverableActivationFailure(
|
||||
PublicShellActivationResult? activation,
|
||||
LauncherCoordinatorStatus? status)
|
||||
{
|
||||
if (activation is { Accepted: true })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status is { Completed: false, HostProcessAlive: true })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var shellStatus = activation?.Status;
|
||||
if (shellStatus is null || !shellStatus.PublicIpcReady)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shellStatus.MainWindowOpened ||
|
||||
!shellStatus.DesktopVisible ||
|
||||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildCoordinatorResultDetails(
|
||||
LauncherCoordinatorStatus? status,
|
||||
PublicShellActivationResult? activation)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty,
|
||||
["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty,
|
||||
["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty,
|
||||
["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(),
|
||||
["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty,
|
||||
["startupState"] = status?.State ?? string.Empty,
|
||||
["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty,
|
||||
["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty,
|
||||
["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty,
|
||||
["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow)
|
||||
{
|
||||
if (splashWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await splashWindow.DismissAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
|
||||
{
|
||||
var resultPath = context.GetOption("result");
|
||||
if (string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
|
||||
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) &&
|
||||
bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) &&
|
||||
hostProcessAliveValue;
|
||||
var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
||||
int.TryParse(hostPidText, out var parsedPid)
|
||||
? parsedPid
|
||||
: (int?)null;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
if (hostProcessAlive)
|
||||
{
|
||||
errorWindow.ConfigureForRunningHostFailure(hostPid);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorWindow.ConfigureForGenericFailure(allowRetry: true);
|
||||
}
|
||||
|
||||
errorWindow.SetErrorMessage(
|
||||
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
|
||||
errorWindow.Show();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to show launcher failure window.", ex);
|
||||
}
|
||||
});
|
||||
|
||||
if (errorWindow is null)
|
||||
{
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failure window closed unexpectedly.", ex);
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> TryActivateExistingInstanceAsync()
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
return activation?.Accepted == true;
|
||||
}
|
||||
|
||||
private static async Task<PublicShellActivationResult?> TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ipcClient = new LanMountainDesktopIpcClient();
|
||||
var connectTask = ipcClient.ConnectAsync();
|
||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != connectTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await connectTask.ConfigureAwait(false);
|
||||
if (!ipcClient.IsConnected)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||
var activationTask = shellProxy.ActivateMainWindowWithStatusAsync();
|
||||
completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != activationTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await activationTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetLiveProcess(int processId)
|
||||
{
|
||||
if (processId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
SplashWindow splashWindow) =>
|
||||
LauncherGuiCoordinator.RunAsync(desktop, context, splashWindow);
|
||||
}
|
||||
|
||||
533
LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs
Normal file
533
LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs
Normal file
@@ -0,0 +1,533 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Resources;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
internal static class LauncherGuiCoordinator
|
||||
{
|
||||
public static async Task RunAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
SplashWindow splashWindow)
|
||||
{
|
||||
LauncherResult result;
|
||||
SplashWindow? currentSplashWindow = splashWindow;
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var dataLocationResolver = new DataLocationResolver(appRoot);
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
successPolicy,
|
||||
coordinatorPipeName,
|
||||
out var reservedAttempt,
|
||||
out var activeCoordinatorAttempt))
|
||||
{
|
||||
result = await AttachToExistingCoordinatorAsync(
|
||||
context,
|
||||
currentSplashWindow,
|
||||
activeCoordinatorAttempt).ConfigureAwait(false);
|
||||
|
||||
Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
return;
|
||||
}
|
||||
|
||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
||||
new LauncherAirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null,
|
||||
() => dataLocationResolver.ResolveDataRoot())));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||
coordinatorPipeName,
|
||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||
HandleCoordinatorRequestAsync,
|
||||
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
|
||||
coordinatorServer.Start();
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.Info(
|
||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||
|
||||
var orchestrator = LauncherCompositionRoot.CreateOrchestrator(
|
||||
context,
|
||||
appRoot,
|
||||
startupAttemptRegistry,
|
||||
coordinatorServer);
|
||||
|
||||
result = await orchestrator.RunAsync(currentSplashWindow).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Coordinator threw an unhandled exception.", ex);
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = $"Launcher failed: {ex.Message}",
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
if (result.Success ||
|
||||
result.Code == "host_not_found" ||
|
||||
(!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false);
|
||||
if (failureAction == ErrorWindowResult.Exit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (failureAction == ErrorWindowResult.ActivateExisting &&
|
||||
await TryActivateExistingInstanceAsync().ConfigureAwait(false))
|
||||
{
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_requested",
|
||||
Message = "Launcher activated the existing desktop instance.",
|
||||
Details = result.Details
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
currentSplashWindow = CreateSplashWindow();
|
||||
currentSplashWindow.Show();
|
||||
}
|
||||
|
||||
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
if (result.Success)
|
||||
{
|
||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static SplashWindow CreateSplashWindow()
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
|
||||
return window;
|
||||
}
|
||||
|
||||
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
|
||||
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid)
|
||||
{
|
||||
if (result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
||||
int.TryParse(hostPidText, out var hostPid))
|
||||
{
|
||||
return hostPid;
|
||||
}
|
||||
|
||||
if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) &&
|
||||
int.TryParse(existingHostPidText, out var existingHostPid))
|
||||
{
|
||||
return existingHostPid;
|
||||
}
|
||||
|
||||
return fallbackHostPid;
|
||||
}
|
||||
|
||||
private static async Task WaitForManagedProcessesToExitAsync(
|
||||
int hostPid,
|
||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
||||
{
|
||||
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
|
||||
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||
CommandContext context,
|
||||
SplashWindow? splashWindow,
|
||||
StartupAttemptRecord? activeCoordinatorAttempt)
|
||||
{
|
||||
var reporter = splashWindow as ISplashStageReporter;
|
||||
reporter?.Report("activation", Strings.Preview_ActivationConnecting);
|
||||
|
||||
if (activeCoordinatorAttempt is not null &&
|
||||
!string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName))
|
||||
{
|
||||
var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase)
|
||||
? LauncherCoordinatorCommands.Attach
|
||||
: LauncherCoordinatorCommands.ActivateDesktop;
|
||||
var request = new LauncherCoordinatorRequest
|
||||
{
|
||||
Command = command,
|
||||
LaunchSource = context.LaunchSource,
|
||||
SuccessPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context)
|
||||
};
|
||||
|
||||
var response = await new LauncherCoordinatorIpcClient()
|
||||
.SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
reporter?.Report("activation", response.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = response.Accepted ||
|
||||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
|
||||
Message = success && !response.Accepted
|
||||
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
|
||||
: response.Message,
|
||||
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
reporter?.Report("activation", activation.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = activation.Accepted
|
||||
? "existing_host_activated"
|
||||
: success
|
||||
? "existing_host_startup_pending"
|
||||
: "existing_host_activation_failed",
|
||||
Message = success && !activation.Accepted
|
||||
? "Existing desktop process is still starting; Launcher attached without starting another process."
|
||||
: activation.Message,
|
||||
Details = BuildCoordinatorResultDetails(null, activation)
|
||||
};
|
||||
}
|
||||
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "launcher_coordinator_unavailable",
|
||||
Message = "Another Launcher is coordinating startup, but it did not respond in time.",
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty,
|
||||
["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty,
|
||||
["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<LauncherCoordinatorResponse> HandleCoordinatorRequestAsync(
|
||||
LauncherCoordinatorRequest request,
|
||||
LauncherCoordinatorStatus status)
|
||||
{
|
||||
if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
|
||||
{
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = activation.Accepted,
|
||||
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
||||
Message = activation.Message,
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt)
|
||||
{
|
||||
return new LauncherCoordinatorStatus
|
||||
{
|
||||
AttemptId = attempt.AttemptId,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
HostPid = attempt.HostPid,
|
||||
HostProcessAlive = TryGetLiveProcess(attempt.HostPid),
|
||||
LaunchSource = attempt.LaunchSource,
|
||||
SuccessPolicy = attempt.SuccessPolicy,
|
||||
LastObservedStage = attempt.LastObservedStage,
|
||||
LastObservedMessage = attempt.LastObservedMessage,
|
||||
PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected,
|
||||
State = attempt.State.ToString(),
|
||||
SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting,
|
||||
Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed,
|
||||
Succeeded = attempt.State == StartupAttemptState.Succeeded,
|
||||
UpdatedAtUtc = attempt.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsRecoverableActivationFailure(
|
||||
PublicShellActivationResult? activation,
|
||||
LauncherCoordinatorStatus? status)
|
||||
{
|
||||
if (activation is { Accepted: true })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status is { Completed: false, HostProcessAlive: true })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var shellStatus = activation?.Status;
|
||||
if (shellStatus is null || !shellStatus.PublicIpcReady)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shellStatus.MainWindowOpened ||
|
||||
!shellStatus.DesktopVisible ||
|
||||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildCoordinatorResultDetails(
|
||||
LauncherCoordinatorStatus? status,
|
||||
PublicShellActivationResult? activation)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty,
|
||||
["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty,
|
||||
["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty,
|
||||
["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(),
|
||||
["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty,
|
||||
["startupState"] = status?.State ?? string.Empty,
|
||||
["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty,
|
||||
["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty,
|
||||
["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty,
|
||||
["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow)
|
||||
{
|
||||
if (splashWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await splashWindow.DismissAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
|
||||
{
|
||||
var resultPath = context.GetOption("result");
|
||||
if (string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
|
||||
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) &&
|
||||
bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) &&
|
||||
hostProcessAliveValue;
|
||||
var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
||||
int.TryParse(hostPidText, out var parsedPid)
|
||||
? parsedPid
|
||||
: (int?)null;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
if (hostProcessAlive)
|
||||
{
|
||||
errorWindow.ConfigureForRunningHostFailure(hostPid);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorWindow.ConfigureForGenericFailure(allowRetry: true);
|
||||
}
|
||||
|
||||
errorWindow.SetErrorMessage(
|
||||
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
|
||||
errorWindow.Show();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to show launcher failure window.", ex);
|
||||
}
|
||||
});
|
||||
|
||||
if (errorWindow is null)
|
||||
{
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failure window closed unexpectedly.", ex);
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> TryActivateExistingInstanceAsync()
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
return activation?.Accepted == true;
|
||||
}
|
||||
|
||||
private static async Task<PublicShellActivationResult?> TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ipcClient = new LanMountainDesktopIpcClient();
|
||||
var connectTask = ipcClient.ConnectAsync();
|
||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != connectTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await connectTask.ConfigureAwait(false);
|
||||
if (!ipcClient.IsConnected)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||
var activationTask = shellProxy.ActivateMainWindowWithStatusAsync();
|
||||
completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != activationTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await activationTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetLiveProcess(int processId)
|
||||
{
|
||||
if (processId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ internal static class LauncherServiceRegistration
|
||||
services.AddSingleton(new DeploymentLocator(appRoot));
|
||||
services.AddSingleton(sp => new OobeStateService(appRoot));
|
||||
services.AddSingleton(sp => new DataLocationResolver(appRoot));
|
||||
services.AddSingleton<IUpdateEngine>(sp => new UpdateEngineFacade(sp.GetRequiredService<DeploymentLocator>()));
|
||||
services.AddSingleton(sp => UpdateEngineFactory.Create(sp.GetRequiredService<DeploymentLocator>()));
|
||||
services.AddSingleton<HostLaunchService>();
|
||||
services.AddSingleton<StartupAttemptRegistry>();
|
||||
services.AddSingleton<ILaunchPhase, CleanupDeploymentsPhase>();
|
||||
|
||||
@@ -2,7 +2,7 @@ using Avalonia;
|
||||
using Avalonia.Styling;
|
||||
using FluentAvalonia.Styling;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Infrastructure;
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 主题服务,管理启动器的主题设置
|
||||
@@ -1,3 +1,4 @@
|
||||
using LanMountainDesktop.Launcher.Shell;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Shell;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LanMountainDesktop.Launcher.Shell;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Shell;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal sealed class MonitorStartupPhase : ILaunchPhase
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
@@ -10,13 +10,13 @@ internal sealed class OobeGatePhase : ILaunchPhase
|
||||
{
|
||||
if (context.OobeDecision.ShouldShowOobe)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => context.SplashWindow.Hide());
|
||||
await LaunchUiPresenter.HideSplashAsync(context.SplashWindow).ConfigureAwait(false);
|
||||
foreach (var step in context.OobeSteps)
|
||||
{
|
||||
await step.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => context.SplashWindow.Show());
|
||||
await LaunchUiPresenter.ShowSplashAsync(context.SplashWindow).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
|
||||
|
||||
73
LanMountainDesktop.Launcher/Update/DeploymentActivator.cs
Normal file
73
LanMountainDesktop.Launcher/Update/DeploymentActivator.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
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);
|
||||
@@ -0,0 +1,51 @@
|
||||
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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
49
LanMountainDesktop.Launcher/Update/InstallCheckpointStore.cs
Normal file
49
LanMountainDesktop.Launcher/Update/InstallCheckpointStore.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
287
LanMountainDesktop.Launcher/Update/LegacyUpdateApplier.cs
Normal file
287
LanMountainDesktop.Launcher/Update/LegacyUpdateApplier.cs
Normal file
@@ -0,0 +1,287 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
116
LanMountainDesktop.Launcher/Update/PendingUpdateDetector.cs
Normal file
116
LanMountainDesktop.Launcher/Update/PendingUpdateDetector.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
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 pdcFileMapText = File.ReadAllText(paths.PlondsFileMapPath);
|
||||
var pdcFileMap = JsonSerializer.Deserialize(pdcFileMapText, AppJsonContext.Default.PlondsFileMap);
|
||||
if (pdcFileMap is null)
|
||||
{
|
||||
return UpdateEngineResults.Failed("update.check", "invalid_manifest", "plonds-filemap.json is invalid.");
|
||||
}
|
||||
|
||||
var pdcVerified = signatureVerifier.Verify(
|
||||
paths.PlondsFileMapPath,
|
||||
paths.PlondsSignaturePath,
|
||||
UpdateEnginePaths.PlondsSignatureFileName);
|
||||
if (!pdcVerified.Success)
|
||||
{
|
||||
return UpdateEngineResults.Failed("update.check", "signature_failed", pdcVerified.Message);
|
||||
}
|
||||
|
||||
var pdcMetadata = 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(pdcFileMap, pdcMetadata)
|
||||
};
|
||||
}
|
||||
|
||||
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."
|
||||
};
|
||||
}
|
||||
}
|
||||
416
LanMountainDesktop.Launcher/Update/PlondsManifestParser.cs
Normal file
416
LanMountainDesktop.Launcher/Update/PlondsManifestParser.cs
Normal file
@@ -0,0 +1,416 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
97
LanMountainDesktop.Launcher/Update/PlondsPayloadResolver.cs
Normal file
97
LanMountainDesktop.Launcher/Update/PlondsPayloadResolver.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
374
LanMountainDesktop.Launcher/Update/PlondsUpdateApplier.cs
Normal file
374
LanMountainDesktop.Launcher/Update/PlondsUpdateApplier.cs
Normal file
@@ -0,0 +1,374 @@
|
||||
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 pdcMetadata = 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, pdcMetadata);
|
||||
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, pdcMetadata);
|
||||
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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
48
LanMountainDesktop.Launcher/Update/RollbackStrategy.cs
Normal file
48
LanMountainDesktop.Launcher/Update/RollbackStrategy.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal static class UpdateEngineFactory
|
||||
{
|
||||
public static IUpdateEngine Create(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null) =>
|
||||
new UpdateEngineFacade(deploymentLocator, progressReporter);
|
||||
}
|
||||
68
LanMountainDesktop.Launcher/Update/UpdateEnginePaths.cs
Normal file
68
LanMountainDesktop.Launcher/Update/UpdateEnginePaths.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
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");
|
||||
}
|
||||
18
LanMountainDesktop.Launcher/Update/UpdateEngineResults.cs
Normal file
18
LanMountainDesktop.Launcher/Update/UpdateEngineResults.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
84
LanMountainDesktop.Launcher/Update/UpdateHash.cs
Normal file
84
LanMountainDesktop.Launcher/Update/UpdateHash.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
20
LanMountainDesktop.Launcher/Update/UpdatePathGuard.cs
Normal file
20
LanMountainDesktop.Launcher/Update/UpdatePathGuard.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
34
LanMountainDesktop.Launcher/Update/UpdateSnapshotStore.cs
Normal file
34
LanMountainDesktop.Launcher/Update/UpdateSnapshotStore.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Resources;
|
||||
using LanMountainDesktop.Launcher.Infrastructure;
|
||||
using LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Resources;
|
||||
using LanMountainDesktop.Launcher.Infrastructure;
|
||||
using LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user