chabged.进一步清理启动器内的更新逻辑

This commit is contained in:
lincube
2026-05-29 22:16:40 +08:00
parent a1cc0ee2bf
commit d004088601
48 changed files with 1348 additions and 2034 deletions

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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>());

View File

@@ -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; }

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -1,9 +0,0 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Update;
public interface IUpdateProgressReporter
{
void ReportProgress(InstallProgressReport report);
void ReportComplete(InstallCompleteReport report);
}

View File

@@ -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);
}
}

View File

@@ -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) { }
}

View File

@@ -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."
};
}
}

View File

@@ -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
{
}
}
}

View File

@@ -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);
}

View File

@@ -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
};
}
}

View File

@@ -6,7 +6,7 @@ using LanMountainDesktop.Launcher.Resources;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 更新进度窗口 - 用于 apply-update 命令模式显示更新/插件升级进度
/// 更新进度窗口 - 用于预览模式显示更新/插件升级进度
/// </summary>
public partial class UpdateWindow : Window
{

View File

@@ -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" }
};

View 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;

View 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]));
}
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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()

View File

@@ -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
{
}
}
}

View File

@@ -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));
}
}
}

View 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];
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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()

View 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; }
}

View File

@@ -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");
}

View File

@@ -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"),

View File

@@ -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));
}
}
}

View File

@@ -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
{

View File

@@ -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",

View 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;

View 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
};
}
}

View File

@@ -1,6 +1,6 @@
using System.Security.Cryptography;
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal static class UpdateHash
{

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Update;
namespace LanMountainDesktop.Services.Update;
internal static class UpdatePathGuard
{

View 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();
}
}

View File

@@ -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)
{

View File

@@ -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);
}
}

View File

@@ -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. **代码审计**: 建议进行定期安全审计
---
*报告生成工具: 自动安全审计系统*
*审计方法: 静态代码分析 + 架构审查*
*报告生成工具:自动化安全审计*
*审计方法:静态代码分析 + 攻击面评估*

View 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*

View 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*