diff --git a/.github/workflows/plonds-uploader.yml b/.github/workflows/plonds-uploader.yml index d81eb85..ebc527f 100644 --- a/.github/workflows/plonds-uploader.yml +++ b/.github/workflows/plonds-uploader.yml @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4a406a..128ca12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 } diff --git a/LanMountainDesktop.Tests/PlondsClientServiceTests.cs b/LanMountainDesktop.Tests/PlondsClientServiceTests.cs index 1ada3ad..55d9fea 100644 --- a/LanMountainDesktop.Tests/PlondsClientServiceTests.cs +++ b/LanMountainDesktop.Tests/PlondsClientServiceTests.cs @@ -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? sources = null, PlondsClientDownloads? downloads = null, - IReadOnlyDictionary? checksums = null) + IReadOnlyDictionary? 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"), diff --git a/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs b/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs index 3b2338f..ac49def 100644 --- a/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs +++ b/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs @@ -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"), diff --git a/LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs b/LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs index 9b6473a..dfc8fb4 100644 --- a/LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs +++ b/LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs @@ -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 diff --git a/LanMountainDesktop/Services/Plonds/PlondsPreparedPackageInstaller.cs b/LanMountainDesktop/Services/Plonds/PlondsPreparedPackageInstaller.cs index 7f966e7..77d4fea 100644 --- a/LanMountainDesktop/Services/Plonds/PlondsPreparedPackageInstaller.cs +++ b/LanMountainDesktop/Services/Plonds/PlondsPreparedPackageInstaller.cs @@ -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); diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index db547e5..a4766e6 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -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 _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 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 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(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 InstallPlondsCleanInstallAsync(CancellationToken cancellationToken) + { + TransitionPlonds(UpdatePhase.Installing); + var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory); + var progress = new Progress(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 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 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); diff --git a/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs b/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs index bf2eb85..d2269ea 100644 --- a/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs +++ b/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs @@ -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)) { diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuilder.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuilder.cs index 0b69511..0293809 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuilder.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsCommitDeltaBuilder.cs @@ -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(StringComparer.OrdinalIgnoreCase); var changedFilesMap = new Dictionary(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 => diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs index 9504a9e..9d1dfca 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs @@ -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(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(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);