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

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