mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
feat.PLONDS客户端补全
This commit is contained in:
4
.github/workflows/plonds-uploader.yml
vendored
4
.github/workflows/plonds-uploader.yml
vendored
@@ -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:
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user