feat.PLONDS客户端补全

This commit is contained in:
lincube
2026-06-02 13:16:13 +08:00
parent 54d97e312d
commit 0ea98c08bf
10 changed files with 521 additions and 30 deletions

View File

@@ -23,8 +23,8 @@ env:
PLONDS_S3_PUBLIC_BASE_KEY_PREFIX: lanmountain/update
PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY: '4'
PLONDS_S3_MULTIPART_THRESHOLD_MB: '8'
PLONDS_S3_MULTIPART_PART_SIZE_MB: '8'
PLONDS_S3_MULTIPART_CONCURRENCY: '4'
PLONDS_S3_MULTIPART_PART_SIZE_MB: '5'
PLONDS_S3_MULTIPART_CONCURRENCY: '8'
jobs:
publish:

View File

@@ -360,7 +360,7 @@ jobs:
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
$payloadRoot = Join-Path $PWD "publish/windows-$arch"
if (-not (Test-Path $payloadRoot)) {
Write-Error "Payload root not found: $payloadRoot"
exit 1
@@ -374,7 +374,7 @@ jobs:
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
if ($relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
return
}

View File

@@ -91,6 +91,25 @@ public sealed class PlondsClientServiceTests : IDisposable
Assert.Contains("full package fallback also failed", result.ErrorMessage);
}
[Fact]
public async Task DownloadPlanner_WhenManifestRequiresCleanInstall_DoesNotPreparePlondsPackage()
{
var downloader = new FakeDownloader(deltaFails: false, fullFails: false);
var planner = new PlondsDownloadPlanner(downloader);
var result = await planner.PrepareAsync(
new PlondsManifestCandidate(
new("s3", "s3", "https://s3.test/PLONDS.json"),
CreateManifest("1.2.3", requiresCleanInstall: true)),
CancellationToken.None);
Assert.False(result.Success);
Assert.True(result.RequiresUiHandling);
Assert.Contains("clean install", result.ErrorMessage);
Assert.Equal(0, downloader.DeltaCalls);
Assert.Equal(0, downloader.FullCalls);
}
[Fact]
public async Task PlondsService_ReadsBuiltInSources_RegistersManifestSources_AndPreparesHighestVersion()
{
@@ -411,18 +430,62 @@ public sealed class PlondsClientServiceTests : IDisposable
Assert.True(File.Exists(Path.Combine(currentDeployment, ".destroy")));
}
[Fact]
public async Task PreparedPackageInstaller_InstallsFullPackageFromCompleteRootLayout()
{
var launcherRoot = Path.Combine(_tempRoot, "launcher-full");
var currentDeployment = Path.Combine(launcherRoot, "app-1.0.0-0");
Directory.CreateDirectory(currentDeployment);
File.WriteAllText(Path.Combine(currentDeployment, ".current"), string.Empty);
File.WriteAllText(Path.Combine(currentDeployment, "LanMountainDesktop.exe"), "old-exe");
File.WriteAllText(Path.Combine(currentDeployment, "app.dll"), "old");
var filesRoot = Path.Combine(_tempRoot, "Files-root");
var fullAppDirectory = Path.Combine(filesRoot, "app-1.2.0");
Directory.CreateDirectory(fullAppDirectory);
File.WriteAllText(Path.Combine(filesRoot, "LanMountainDesktop.Launcher.exe"), "launcher");
File.WriteAllText(Path.Combine(filesRoot, "LanMountainDesktop.AirAppRuntime.exe"), "runtime");
File.WriteAllText(Path.Combine(fullAppDirectory, ".current"), string.Empty);
File.WriteAllText(Path.Combine(fullAppDirectory, "LanMountainDesktop.exe"), "new-exe");
File.WriteAllText(Path.Combine(fullAppDirectory, "app.dll"), "new");
var package = new PlondsPreparedPackage(
new Version(1, 2, 0),
PlondsPackageMode.Full,
Path.Combine(_tempRoot, "PLONDS.json"),
null,
null,
Path.Combine(_tempRoot, "Files.zip"),
filesRoot);
var result = await new PlondsPreparedPackageInstaller().InstallAsync(
package,
launcherRoot,
progress: null,
CancellationToken.None);
Assert.True(result.Success);
var target = Assert.Single(Directory.GetDirectories(launcherRoot, "app-1.2.0-*"));
Assert.Equal("new-exe", File.ReadAllText(Path.Combine(target, "LanMountainDesktop.exe")));
Assert.Equal("new", File.ReadAllText(Path.Combine(target, "app.dll")));
Assert.False(File.Exists(Path.Combine(target, "LanMountainDesktop.Launcher.exe")));
Assert.True(File.Exists(Path.Combine(target, ".current")));
Assert.True(File.Exists(Path.Combine(currentDeployment, ".destroy")));
}
private static PlondsClientManifest CreateManifest(
string version,
IReadOnlyList<PlondsSourceDescriptor>? sources = null,
PlondsClientDownloads? downloads = null,
IReadOnlyDictionary<string, string>? checksums = null)
IReadOnlyDictionary<string, string>? checksums = null,
bool requiresCleanInstall = false)
{
return new PlondsClientManifest(
FormatVersion: "2.0",
CurrentVersion: version,
PreviousVersion: "1.0.0",
IsFullUpdate: false,
RequiresCleanInstall: false,
RequiresCleanInstall: requiresCleanInstall,
Channel: "stable",
Platform: "windows-x64",
UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"),

View File

@@ -140,6 +140,43 @@ public sealed class UpdateSettingsInterfaceTests
Assert.False(orchestratorCreated);
}
[Fact]
public async Task UpdateSettingsService_WhenPlondsManifestRequiresCleanInstall_ReportsFullInstaller()
{
var settings = new FakeSettingsService
{
Snapshot =
{
UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds
}
};
var plonds = new FakePlondsService
{
LatestResult = PlondsLatestResult.Available(
new Version(1, 0, 0),
new Version(9, 9, 9),
[new PlondsManifestCandidate(
new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100),
CreatePlondsManifest("9.9.9", requiresCleanInstall: true))])
};
var orchestratorCreated = false;
var service = new UpdateSettingsService(
settings,
orchestratorFactory: () =>
{
orchestratorCreated = true;
throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS check.");
},
plondsService: plonds);
var report = await service.CheckAsync(CancellationToken.None);
Assert.True(report.IsUpdateAvailable);
Assert.Equal(UpdatePayloadKind.FullInstaller, report.PayloadKind);
Assert.Equal("9.9.9", report.LatestVersion);
Assert.False(orchestratorCreated);
}
[Fact]
public async Task UpdateSettingsService_WhenGitHubSelected_UsesOrchestrator()
{
@@ -226,14 +263,14 @@ public sealed class UpdateSettingsInterfaceTests
new UpdateStateStore(new FakeSettingsFacade(new FakeUpdateSettingsService { State = state })));
}
private static PlondsClientManifest CreatePlondsManifest(string version)
private static PlondsClientManifest CreatePlondsManifest(string version, bool requiresCleanInstall = false)
{
return new PlondsClientManifest(
FormatVersion: "2.0",
CurrentVersion: version,
PreviousVersion: "1.0.0",
IsFullUpdate: false,
RequiresCleanInstall: false,
RequiresCleanInstall: requiresCleanInstall,
Channel: "stable",
Platform: "windows-x64",
UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"),

View File

@@ -8,6 +8,12 @@ internal sealed class PlondsDownloadPlanner(IPlondsPackageDownloader downloader)
{
ArgumentNullException.ThrowIfNull(candidate);
if (candidate.Manifest.RequiresCleanInstall)
{
return PlondsPrepareResult.FailedForUi(
"PLONDS manifest requires a clean install. Use the Host Update installer flow instead.");
}
try
{
var deltaPackage = await downloader

View File

@@ -55,12 +55,13 @@ internal sealed class PlondsPreparedPackageInstaller
return new PlondsInstallResult(false, "PLONDS full package directory is missing.", "staging_incomplete");
}
var sourceAppDirectory = ResolveFullPackageAppDirectory(package.FilesDirectory, package.Version);
var currentDeployment = FindCurrentDeploymentDirectory(launcherRoot);
var targetDeployment = BuildNextDeploymentDirectory(launcherRoot, package.Version.ToString());
progress?.Report(new InstallProgressReport(InstallStage.CreateTarget, "Creating target deployment...", 15, null, 0, 0));
PrepareTargetDirectory(targetDeployment);
CopyDirectory(package.FilesDirectory, targetDeployment, cancellationToken);
CopyDirectory(sourceAppDirectory, targetDeployment, cancellationToken, skipMarkers: true);
progress?.Report(new InstallProgressReport(InstallStage.ActivateDeployment, "Activating deployment...", 85, null, 0, 0));
ActivateDeployment(currentDeployment, targetDeployment);
@@ -288,6 +289,40 @@ internal sealed class PlondsPreparedPackageInstaller
.FirstOrDefault();
}
private static string ResolveFullPackageAppDirectory(string filesDirectory, Version version)
{
var resolvedRoot = Path.GetFullPath(filesDirectory);
if (File.Exists(Path.Combine(resolvedRoot, "LanMountainDesktop.exe")))
{
return resolvedRoot;
}
var exactAppDirectory = Path.Combine(resolvedRoot, $"app-{version}");
if (Directory.Exists(exactAppDirectory) &&
File.Exists(Path.Combine(exactAppDirectory, "LanMountainDesktop.exe")))
{
return exactAppDirectory;
}
var appDirectory = Directory.GetDirectories(resolvedRoot, "app-*", SearchOption.TopDirectoryOnly)
.Where(path => File.Exists(Path.Combine(path, "LanMountainDesktop.exe")))
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.Version)
.Select(item => item.Path)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(appDirectory))
{
return appDirectory;
}
throw new DirectoryNotFoundException("PLONDS full package does not contain an app deployment directory.");
}
private static string BuildNextDeploymentDirectory(string launcherRoot, string targetVersion)
{
Directory.CreateDirectory(launcherRoot);

View File

@@ -791,8 +791,11 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly IPlondsService _plondsService;
private readonly PlondsPreparedPackageInstaller _plondsInstaller = new();
private readonly UpdateInstallGateway _plondsUpdateInstallGateway = new();
private readonly Lazy<UpdateOrchestrator> _orchestrator;
private PlondsLatestResult? _pendingPlondsLatest;
private PlondsManifestCandidate? _pendingPlondsCleanInstallCandidate;
private UpdateManifest? _pendingPlondsInstallerManifest;
private PlondsPreparedPackage? _pendingPlondsPackage;
private UpdatePhase _plondsPhase = UpdatePhase.Idle;
private bool _orchestratorEventsSubscribed;
@@ -957,6 +960,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
if (IsPlondsSelected())
{
_pendingPlondsLatest = null;
_pendingPlondsCleanInstallCandidate = null;
_pendingPlondsInstallerManifest = null;
_pendingPlondsPackage = null;
TransitionPlonds(UpdatePhase.Idle);
return Task.CompletedTask;
@@ -979,7 +984,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
{
if (IsPlondsSelected())
{
return false;
return TryApplyPlondsOnExit();
}
return GetOrchestrator().TryApplyOnExit();
@@ -1087,6 +1092,9 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
var latest = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
_pendingPlondsLatest = latest.Success && latest.IsUpdateAvailable ? latest : null;
_pendingPlondsCleanInstallCandidate = _pendingPlondsLatest?.Candidates
.FirstOrDefault(candidate => candidate.Manifest.RequiresCleanInstall);
_pendingPlondsInstallerManifest = null;
_pendingPlondsPackage = null;
TransitionPlonds(UpdatePhase.Checked);
SaveLastChecked();
@@ -1096,11 +1104,17 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage);
}
var payloadKind = latest.IsUpdateAvailable
? _pendingPlondsCleanInstallCandidate is not null
? UpdatePayloadKind.FullInstaller
: UpdatePayloadKind.DeltaPlonds
: (UpdatePayloadKind?)null;
return new UpdateCheckReport(
latest.IsUpdateAvailable,
latest.LatestVersion?.ToString(),
currentVersionText,
latest.IsUpdateAvailable ? UpdatePayloadKind.DeltaPlonds : null,
payloadKind,
latest.Candidates.FirstOrDefault()?.Source.Id,
Get().UpdateChannel,
DateTimeOffset.UtcNow,
@@ -1111,7 +1125,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
private async Task<LanMountainDesktop.Services.Update.DownloadResult> DownloadPlondsAsync(CancellationToken cancellationToken)
{
if (_plondsPhase is not UpdatePhase.Checked)
if (_plondsPhase is not (UpdatePhase.Checked or UpdatePhase.PausedDownloading))
{
return new LanMountainDesktop.Services.Update.DownloadResult(false, null, $"Cannot download in phase {_plondsPhase}.", false);
}
@@ -1121,8 +1135,13 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return new LanMountainDesktop.Services.Update.DownloadResult(false, null, "No PLONDS update is pending.", false);
}
TransitionPlonds(UpdatePhase.Downloading);
var currentVersion = _pendingPlondsLatest.CurrentVersion;
if (_pendingPlondsCleanInstallCandidate is not null)
{
return await DownloadPlondsCleanInstallAsync(_pendingPlondsCleanInstallCandidate, cancellationToken).ConfigureAwait(false);
}
TransitionPlonds(UpdatePhase.Downloading);
var result = await _plondsService.FindAndPrepareLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
if (!result.Success || result.Package is null)
{
@@ -1136,6 +1155,95 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return new LanMountainDesktop.Services.Update.DownloadResult(true, result.Package.ManifestPath, null, true);
}
private async Task<LanMountainDesktop.Services.Update.DownloadResult> DownloadPlondsCleanInstallAsync(
PlondsManifestCandidate candidate,
CancellationToken cancellationToken)
{
TransitionPlonds(UpdatePhase.Downloading);
var manifest = await ResolveGitHubInstallerManifestForPlondsAsync(candidate.Manifest, cancellationToken).ConfigureAwait(false);
if (manifest is null)
{
TransitionPlonds(UpdatePhase.Failed);
return new LanMountainDesktop.Services.Update.DownloadResult(
false,
null,
$"PLONDS {candidate.Manifest.CurrentVersion} requires clean install, but no matching GitHub installer release was found.",
false);
}
var mirror = manifest.InstallerMirrors?
.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Url));
if (mirror is null || string.IsNullOrWhiteSpace(mirror.Url))
{
TransitionPlonds(UpdatePhase.Failed);
return new LanMountainDesktop.Services.Update.DownloadResult(
false,
null,
$"PLONDS {candidate.Manifest.CurrentVersion} requires clean install, but GitHub release has no usable installer asset.",
false);
}
var fileName = string.IsNullOrWhiteSpace(mirror.Name)
? $"{manifest.DistributionId}-{manifest.ToVersion}-installer.exe"
: mirror.Name!;
var asset = new GitHubReleaseAsset(fileName, mirror.Url!, mirror.Size, mirror.Sha256);
var destinationPath = CreateInstallerDestinationPath(manifest, fileName);
var maxThreads = UpdateSettingsValues.NormalizeDownloadThreads(Get().UpdateDownloadThreads);
var progress = new Progress<double>(fraction =>
{
var downloadReport = new DownloadProgressReport(
fileName,
0,
Math.Max(0, mirror.Size),
0,
fraction >= 1 ? 1 : 0,
1,
Math.Clamp(fraction, 0, 1));
_progressChanged?.Invoke(new UpdateProgressReport(
UpdatePhase.Downloading,
$"Downloading {fileName}",
Math.Clamp(fraction, 0, 1),
downloadReport,
null));
});
var result = await _githubReleaseUpdateService.DownloadAssetAsync(
asset,
destinationPath,
UpdateSettingsValues.DownloadSourceGitHub,
maxThreads,
progress,
cancellationToken)
.ConfigureAwait(false);
if (!result.Success || string.IsNullOrWhiteSpace(result.FilePath))
{
TransitionPlonds(UpdatePhase.Failed);
return new LanMountainDesktop.Services.Update.DownloadResult(
false,
result.FilePath,
result.ErrorMessage ?? "Failed to download GitHub installer for PLONDS clean install.",
result.HashVerified);
}
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
DeploymentLockService.WriteLock(launcherRoot, new DeploymentLock(
SchemaVersion: 1,
Kind: "full",
TargetVersion: manifest.ToVersion,
PayloadPath: result.FilePath,
PayloadSha256: result.ExpectedHash,
CreatedAtUtc: DateTimeOffset.UtcNow));
_pendingPlondsInstallerManifest = manifest;
_pendingPlondsPackage = null;
TransitionPlonds(UpdatePhase.Downloaded);
SavePendingPlondsInstaller(manifest, result.FilePath, result.ExpectedHash);
return new LanMountainDesktop.Services.Update.DownloadResult(true, result.FilePath, null, result.HashVerified);
}
private Task PausePlondsAsync()
{
if (_plondsPhase.CanPause())
@@ -1160,6 +1268,11 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return new InstallResult(false, $"Cannot install in phase {_plondsPhase}.", false, "invalid_phase");
}
if (_pendingPlondsInstallerManifest is not null)
{
return await InstallPlondsCleanInstallAsync(cancellationToken).ConfigureAwait(false);
}
if (_pendingPlondsPackage is null)
{
return new InstallResult(false, "No PLONDS package has been prepared.", false, "staging_incomplete");
@@ -1188,6 +1301,37 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return new InstallResult(true, null, false);
}
private async Task<InstallResult> InstallPlondsCleanInstallAsync(CancellationToken cancellationToken)
{
TransitionPlonds(UpdatePhase.Installing);
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
var progress = new Progress<InstallProgressReport>(report =>
{
_progressChanged?.Invoke(new UpdateProgressReport(
UpdatePhase.Installing,
report.Message,
report.ProgressPercent / 100.0,
null,
report));
});
var install = await _plondsUpdateInstallGateway.InstallAsync(
UpdatePayloadKind.FullInstaller,
launcherRoot,
progress,
cancellationToken)
.ConfigureAwait(false);
if (!install.Success)
{
TransitionPlonds(UpdatePhase.Failed);
return install;
}
TransitionPlonds(UpdatePhase.Installed);
return install;
}
private async Task AutoCheckPlondsIfEnabledAsync(CancellationToken cancellationToken)
{
var settings = Get();
@@ -1269,6 +1413,160 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
});
}
private void SavePendingPlondsInstaller(UpdateManifest manifest, string installerPath, string? sha256)
{
var state = Get();
Save(state with
{
PendingUpdateInstallerPath = installerPath,
PendingUpdateVersion = manifest.ToVersion,
PendingUpdatePublishedAtUtcMs = manifest.PublishedAt.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = string.IsNullOrWhiteSpace(sha256) ? null : sha256.Trim().ToLowerInvariant(),
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
}
private bool TryApplyPlondsOnExit()
{
var settings = Get();
if (!string.Equals(
UpdateSettingsValues.NormalizeMode(settings.UpdateMode),
UpdateSettingsValues.ModeSilentOnExit,
StringComparison.OrdinalIgnoreCase))
{
return false;
}
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
try
{
if (_pendingPlondsPackage is not null)
{
AppLogger.Info("UpdateWorkflow", "PLONDS package pending. Applying from Host on exit.");
var result = _plondsInstaller.InstallAsync(
_pendingPlondsPackage,
launcherRoot,
progress: null,
CancellationToken.None)
.GetAwaiter()
.GetResult();
return result.Success;
}
var deploymentLock = DeploymentLockService.ReadLock(launcherRoot);
if (!string.Equals(deploymentLock?.Kind, "full", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.IsNullOrWhiteSpace(settings.PendingUpdateInstallerPath) ||
!File.Exists(settings.PendingUpdateInstallerPath))
{
return false;
}
AppLogger.Info("UpdateWorkflow", "PLONDS clean-install installer pending. Launching from Host Update on exit.");
var install = _plondsUpdateInstallGateway.InstallAsync(
UpdatePayloadKind.FullInstaller,
launcherRoot,
progress: null,
CancellationToken.None)
.GetAwaiter()
.GetResult();
return install.Success;
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", "Failed to apply pending PLONDS update on exit.", ex);
return false;
}
}
private async Task<UpdateManifest?> ResolveGitHubInstallerManifestForPlondsAsync(
PlondsClientManifest plondsManifest,
CancellationToken cancellationToken)
{
foreach (var tag in BuildReleaseTagCandidates(plondsManifest.CurrentVersion))
{
try
{
var release = await _githubReleaseUpdateService
.GetReleaseByTagAsync(tag, cancellationToken)
.ConfigureAwait(false);
if (release is null)
{
continue;
}
return UpdateManifestMapper.FromFullInstaller(release, Get().UpdateChannel, ResolveCurrentPlatform());
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", $"Failed to resolve GitHub installer release '{tag}' for PLONDS clean install: {ex.Message}");
}
}
return null;
}
private static IEnumerable<string> BuildReleaseTagCandidates(string? version)
{
if (string.IsNullOrWhiteSpace(version))
{
yield break;
}
var trimmed = version.Trim();
if (trimmed.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
yield return trimmed;
yield return trimmed[1..];
yield break;
}
yield return $"v{trimmed}";
yield return trimmed;
}
private static string CreateInstallerDestinationPath(UpdateManifest manifest, string fileName)
{
var safeFileName = string.Join(
"_",
fileName.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries)).Trim();
if (string.IsNullOrWhiteSpace(safeFileName))
{
safeFileName = $"{manifest.DistributionId}-{manifest.ToVersion}-installer.exe";
}
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Updates",
safeFileName);
}
private static string ResolveCurrentPlatform()
{
var os = OperatingSystem.IsWindows()
? "windows"
: OperatingSystem.IsLinux()
? "linux"
: OperatingSystem.IsMacOS()
? "macos"
: "unknown";
var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
{
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
System.Runtime.InteropServices.Architecture.X86 => "x86",
_ => "x64"
};
return $"{os}-{arch}";
}
private static bool TryParseVersion(string? value, out Version version)
{
version = new Version(0, 0, 0);

View File

@@ -31,7 +31,7 @@ internal sealed class UpdateInstallGateway
0,
0));
if (!VerifyDeploymentLock(payloadKind, launcherRoot, out var lockErrorCode, out var lockError))
if (!VerifyDeploymentLock(payloadKind, launcherRoot, out var deploymentLock, out var lockErrorCode, out var lockError))
{
return new InstallResult(false, lockError, false, lockErrorCode);
}
@@ -59,7 +59,7 @@ internal sealed class UpdateInstallGateway
return new InstallResult(true, null, false);
}
var installerPath = FindPendingInstaller(launcherRoot, payloadKind, ct);
var installerPath = FindPendingInstaller(launcherRoot, deploymentLock!, ct);
if (installerPath is null)
{
return new InstallResult(false, "No pending installer found.", false, "staging_incomplete");
@@ -92,19 +92,25 @@ internal sealed class UpdateInstallGateway
}
}
private static bool VerifyDeploymentLock(UpdatePayloadKind payloadKind, string launcherRoot, out string? errorCode, out string? error)
private static bool VerifyDeploymentLock(
UpdatePayloadKind payloadKind,
string launcherRoot,
out DeploymentLock? deploymentLock,
out string? errorCode,
out string? error)
{
deploymentLock = null;
errorCode = null;
error = null;
var deploymentLock = DeploymentLockService.ReadLock(launcherRoot);
if (deploymentLock is null)
var currentLock = DeploymentLockService.ReadLock(launcherRoot);
if (currentLock is null)
{
errorCode = "lock_conflict";
error = "Deployment lock is missing. Please redownload the update.";
return false;
}
if (deploymentLock.SchemaVersion != 1)
if (currentLock.SchemaVersion != 1)
{
errorCode = "lock_conflict";
error = "Deployment lock schema is unsupported. Please redownload the update.";
@@ -112,22 +118,23 @@ internal sealed class UpdateInstallGateway
}
var expectedKind = payloadKind is UpdatePayloadKind.DeltaPlonds ? "delta" : "full";
if (!string.Equals(deploymentLock.Kind, expectedKind, StringComparison.OrdinalIgnoreCase))
if (!string.Equals(currentLock.Kind, expectedKind, StringComparison.OrdinalIgnoreCase))
{
errorCode = "lock_conflict";
error = "Deployment lock payload type mismatch. Please redownload the update.";
return false;
}
if (string.IsNullOrWhiteSpace(deploymentLock.PayloadPath) ||
!File.Exists(deploymentLock.PayloadPath) &&
!Directory.Exists(deploymentLock.PayloadPath))
if (string.IsNullOrWhiteSpace(currentLock.PayloadPath) ||
!File.Exists(currentLock.PayloadPath) &&
!Directory.Exists(currentLock.PayloadPath))
{
errorCode = "staging_incomplete";
error = "Deployment lock payload path is missing. Please redownload the update.";
return false;
}
deploymentLock = currentLock;
return true;
}
@@ -240,10 +247,17 @@ internal sealed class UpdateInstallGateway
}
}
private static string? FindPendingInstaller(string launcherRoot, UpdatePayloadKind payloadKind, CancellationToken ct)
private static string? FindPendingInstaller(string launcherRoot, DeploymentLock deploymentLock, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (!string.IsNullOrWhiteSpace(deploymentLock.PayloadPath) &&
File.Exists(deploymentLock.PayloadPath) &&
Path.GetExtension(deploymentLock.PayloadPath).Equals(".exe", StringComparison.OrdinalIgnoreCase))
{
return deploymentLock.PayloadPath;
}
var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot);
if (!Directory.Exists(incomingDir))
{

View File

@@ -55,6 +55,7 @@ public sealed class PlondsCommitDeltaBuilder
Directory.CreateDirectory(outputRoot);
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
var currentAppRoot = PlondsDeltaBuilder.ResolvePayloadAppRoot(currentExtractRoot, options.CurrentVersion);
var changedSourceFiles = GetChangedSourceFiles(options.BaselineTag, options.CurrentTag, sourceDirs);
@@ -76,7 +77,7 @@ public sealed class PlondsCommitDeltaBuilder
}
var artifactFiles = MapSourceToArtifacts(changedSourceFiles, sourceDirs);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
var currentManifest = PayloadUtilities.ScanDirectory(currentAppRoot);
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
@@ -97,7 +98,7 @@ public sealed class PlondsCommitDeltaBuilder
changedFilesMap[normalizedPath] = new PlondsChangedFileEntry(normalizedPath, fileHash, fingerprint.Size, hashAlgorithm);
}
var changedZipPath = CreateChangedZipFromList(currentExtractRoot, artifactFiles, outputRoot, options.Platform);
var changedZipPath = CreateChangedZipFromList(currentAppRoot, artifactFiles, outputRoot, options.Platform);
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
var launcherInChanges = artifactFiles.Any(f =>

View File

@@ -47,17 +47,26 @@ public sealed class PlondsDeltaBuilder
PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot);
}
var currentAppRoot = ResolvePayloadAppRoot(currentExtractRoot, options.CurrentVersion);
var baselineAppRoot = isFullUpdate
? null
: ResolvePayloadAppRoot(baselineExtractRoot, options.BaselineVersion);
var previousManifest = isFullUpdate
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
: PayloadUtilities.ScanDirectory(baselineAppRoot);
var currentManifest = PayloadUtilities.ScanDirectory(currentAppRoot);
var filesMap = BuildFilesMap(previousManifest, currentManifest, hashAlgorithm);
var changedFilesMap = BuildChangedFilesMap(filesMap, hashAlgorithm);
var changedZipPath = CreateChangedZip(currentExtractRoot, filesMap, outputRoot, options.Platform);
var changedZipPath = CreateChangedZip(currentAppRoot, filesMap, outputRoot, options.Platform);
var launcherChanged = DetectLauncherChange(previousManifest, currentManifest, options.LauncherRelativePath);
var previousRootManifest = isFullUpdate
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
var currentRootManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
var launcherChanged = DetectLauncherChange(previousRootManifest, currentRootManifest, options.LauncherRelativePath);
var requiresCleanInstall = launcherChanged && !isFullUpdate;
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
@@ -216,6 +225,34 @@ public sealed class PlondsDeltaBuilder
return !string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase);
}
internal static string ResolvePayloadAppRoot(string extractRoot, string? version)
{
var resolvedRoot = Path.GetFullPath(extractRoot);
if (File.Exists(Path.Combine(resolvedRoot, "LanMountainDesktop.exe")))
{
return resolvedRoot;
}
if (!string.IsNullOrWhiteSpace(version))
{
var versionedAppRoot = Path.Combine(resolvedRoot, $"app-{version.Trim().TrimStart('v', 'V')}");
if (Directory.Exists(versionedAppRoot) &&
File.Exists(Path.Combine(versionedAppRoot, "LanMountainDesktop.exe")))
{
return versionedAppRoot;
}
}
var appRoots = Directory.Exists(resolvedRoot)
? Directory.GetDirectories(resolvedRoot, "app-*", SearchOption.TopDirectoryOnly)
.Where(path => File.Exists(Path.Combine(path, "LanMountainDesktop.exe")))
.OrderByDescending(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
.ToArray()
: [];
return appRoots.FirstOrDefault() ?? resolvedRoot;
}
internal static string ComputeHash(string filePath, string hashAlgorithm)
{
using var stream = File.OpenRead(filePath);