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