mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
Compare commits
3 Commits
04b95020bd
...
v0.8.7.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8403b89a15 | ||
|
|
0ea98c08bf | ||
|
|
54d97e312d |
8
.github/workflows/plonds-uploader.yml
vendored
8
.github/workflows/plonds-uploader.yml
vendored
@@ -22,6 +22,9 @@ env:
|
||||
PLONDS_S3_PREFIX: lanmountain/update/plonds
|
||||
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: '5'
|
||||
PLONDS_S3_MULTIPART_CONCURRENCY: '8'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -119,7 +122,10 @@ jobs:
|
||||
--s3-secret-key "$S3_SECRET_KEY" \
|
||||
--s3-public-base-url "$PUBLIC_BASE" \
|
||||
--s3-public-base-key-prefix "$PLONDS_S3_PUBLIC_BASE_KEY_PREFIX" \
|
||||
--directory-upload-concurrency "$PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY"
|
||||
--directory-upload-concurrency "$PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY" \
|
||||
--multipart-threshold-mb "$PLONDS_S3_MULTIPART_THRESHOLD_MB" \
|
||||
--multipart-part-size-mb "$PLONDS_S3_MULTIPART_PART_SIZE_MB" \
|
||||
--multipart-concurrency "$PLONDS_S3_MULTIPART_CONCURRENCY"
|
||||
|
||||
jq -e '.downloads.github.changedZipUrl and .downloads.github.filesZipUrl and .downloads.s3.changedFolderUrl and .downloads.s3.filesFolderUrl' plonds-assets/PLONDS.json >/dev/null
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ Publisher 上传到 S3 的版本目录:
|
||||
- `PLONDS.json` 的 downloads 字段同时包含 GitHub 与 S3 的增量包、完整包位置。
|
||||
- Publisher 必须先完成版本目录内的 `changed.zip`、`Files.zip`、解压目录和版本 `PLONDS.json` 上传,再更新 `<prefix>/PLONDS.json` latest 指针。
|
||||
- Publisher 的 S3 目录上传必须支持重跑续传;同 key 且大小一致的对象可以跳过,避免失败后从头上传完整包目录。
|
||||
- Publisher 上传大对象时应使用 S3 multipart upload,以避免 `changed.zip` / `Files.zip` 在低吞吐链路上被单次 PUT 长时间阻塞。
|
||||
|
||||
## 7. 建议代码结构
|
||||
|
||||
|
||||
1125
CODE_WIKI.md
1125
CODE_WIKI.md
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
@@ -88,7 +88,7 @@ public partial class DateWidget : UserControl, IDesktopComponentWidget, ITimeZon
|
||||
private FontWeight _weekdayFontWeight = FontWeight.SemiBold;
|
||||
private double _calendarDayFontSize = 18;
|
||||
private FontWeight _calendarDayFontWeight = FontWeight.SemiBold;
|
||||
private double _calendarTodayDotSize = 32;
|
||||
private double _calendarTodayDotSize = 38;
|
||||
private int _lunarItemCount = 3;
|
||||
private int _calendarVisibleRows = 6;
|
||||
private bool? _isNightModeApplied;
|
||||
@@ -254,7 +254,7 @@ public partial class DateWidget : UserControl, IDesktopComponentWidget, ITimeZon
|
||||
// 4x2 widget has less vertical space than 2x2. Compress only on 6-row months.
|
||||
var rowDensity = _calendarVisibleRows >= 6 ? 0.84 : 1.0;
|
||||
var dayFontSize = Math.Clamp(_calendarDayFontSize * rowDensity, 8, 24);
|
||||
var todayDotSize = Math.Clamp(_calendarTodayDotSize * rowDensity, 13.5, 32);
|
||||
var todayDotSize = Math.Clamp(_calendarTodayDotSize * rowDensity, 16, 46);
|
||||
|
||||
for (var day = 1; day <= daysInMonth; day++)
|
||||
{
|
||||
@@ -363,7 +363,7 @@ public partial class DateWidget : UserControl, IDesktopComponentWidget, ITimeZon
|
||||
|
||||
_calendarDayFontSize = Math.Clamp(15.4 * scale * densityBoost, 8, 22);
|
||||
_calendarDayFontWeight = ToVariableWeight(Lerp(540, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
|
||||
_calendarTodayDotSize = Math.Clamp(_calendarDayFontSize * 1.30, 13.5, 31);
|
||||
_calendarTodayDotSize = Math.Clamp(_calendarDayFontSize * 1.85, 16, 42);
|
||||
|
||||
var rightDensity = scale <= 0.72 ? 0.90 : scale <= 0.90 ? 0.95 : scale >= 1.38 ? 1.03 : 1.0;
|
||||
LunarDateTextBlock.FontSize = Math.Clamp(30 * scale * rightDensity, 14, 44);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
@@ -29,7 +30,10 @@ public sealed class PlondsS3Client : IDisposable
|
||||
PublicBaseUrl = Require(options.PublicBaseUrl, nameof(options.PublicBaseUrl)).TrimEnd('/'),
|
||||
PublicBaseKeyPrefix = NormalizeOptionalKeyPrefix(options.PublicBaseKeyPrefix),
|
||||
RequestTimeout = options.RequestTimeout <= TimeSpan.Zero ? TimeSpan.FromMinutes(30) : options.RequestTimeout,
|
||||
MaxUploadAttempts = Math.Max(1, options.MaxUploadAttempts)
|
||||
MaxUploadAttempts = Math.Max(1, options.MaxUploadAttempts),
|
||||
MultipartThresholdBytes = Math.Max(5L * 1024 * 1024, options.MultipartThresholdBytes),
|
||||
MultipartPartSizeBytes = Math.Max(5L * 1024 * 1024, options.MultipartPartSizeBytes),
|
||||
MultipartConcurrency = Math.Max(1, options.MultipartConcurrency)
|
||||
};
|
||||
|
||||
ownsHttpClient = httpClient is null;
|
||||
@@ -53,6 +57,19 @@ public sealed class PlondsS3Client : IDisposable
|
||||
var payloadHash = PayloadUtilities.ComputeSha256(sourcePath);
|
||||
var contentLength = new FileInfo(sourcePath).Length;
|
||||
|
||||
if (contentLength >= options.MultipartThresholdBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
await UploadFileMultipartAsync(sourcePath, key, upload.ContentType, contentLength, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Console.Error.WriteLine($"S3 multipart upload failed for {key}; falling back to single PUT. {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
for (var attempt = 1; attempt <= options.MaxUploadAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
@@ -69,6 +86,216 @@ public sealed class PlondsS3Client : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UploadFileMultipartAsync(
|
||||
string sourcePath,
|
||||
string key,
|
||||
string? contentType,
|
||||
long contentLength,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uploadId = await CreateMultipartUploadAsync(key, contentType, cancellationToken).ConfigureAwait(false);
|
||||
var partCount = checked((int)((contentLength + options.MultipartPartSizeBytes - 1) / options.MultipartPartSizeBytes));
|
||||
var parts = new PlondsS3UploadedPart[partCount];
|
||||
|
||||
Console.WriteLine($"Uploading S3 object {key} ({FormatBytes(contentLength)}) using multipart upload {uploadId}: {partCount} parts, part size {FormatBytes(options.MultipartPartSizeBytes)}, concurrency {options.MultipartConcurrency}.");
|
||||
|
||||
try
|
||||
{
|
||||
var completed = 0;
|
||||
await Parallel.ForEachAsync(
|
||||
Enumerable.Range(1, partCount),
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = options.MultipartConcurrency,
|
||||
CancellationToken = cancellationToken
|
||||
},
|
||||
async (partNumber, token) =>
|
||||
{
|
||||
var offset = (long)(partNumber - 1) * options.MultipartPartSizeBytes;
|
||||
var length = Math.Min(options.MultipartPartSizeBytes, contentLength - offset);
|
||||
parts[partNumber - 1] = await UploadMultipartPartWithRetriesAsync(
|
||||
sourcePath,
|
||||
key,
|
||||
uploadId,
|
||||
partNumber,
|
||||
offset,
|
||||
length,
|
||||
token).ConfigureAwait(false);
|
||||
|
||||
var done = Interlocked.Increment(ref completed);
|
||||
Console.WriteLine($"S3 multipart progress {key}: {done}/{partCount} parts uploaded.");
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
await CompleteMultipartUploadAsync(key, uploadId, parts, cancellationToken).ConfigureAwait(false);
|
||||
Console.WriteLine($"Uploaded S3 object {key} using multipart upload.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
await AbortMultipartUploadBestEffortAsync(key, uploadId, CancellationToken.None).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> CreateMultipartUploadAsync(string key, string? contentType, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestUri = BuildObjectUri(key, "uploads=");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
|
||||
if (!string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Content-Type", contentType);
|
||||
}
|
||||
|
||||
SignRequest(request, key, EmptyPayloadHash, DateTimeOffset.UtcNow);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"S3 create multipart upload failed for {key}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(body, 512)}");
|
||||
}
|
||||
|
||||
var uploadId = XDocument.Parse(body).Descendants().FirstOrDefault(element => element.Name.LocalName == "UploadId")?.Value;
|
||||
return string.IsNullOrWhiteSpace(uploadId)
|
||||
? throw new InvalidOperationException($"S3 create multipart upload response did not include UploadId for {key}.")
|
||||
: uploadId;
|
||||
}
|
||||
|
||||
private async Task<PlondsS3UploadedPart> UploadMultipartPartWithRetriesAsync(
|
||||
string sourcePath,
|
||||
string key,
|
||||
string uploadId,
|
||||
int partNumber,
|
||||
long offset,
|
||||
long length,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 1; attempt <= options.MaxUploadAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await UploadMultipartPartOnceAsync(
|
||||
sourcePath,
|
||||
key,
|
||||
uploadId,
|
||||
partNumber,
|
||||
offset,
|
||||
length,
|
||||
attempt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < options.MaxUploadAttempts && IsRetriable(ex))
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, attempt)));
|
||||
Console.Error.WriteLine($"S3 multipart retry {attempt + 1}/{options.MaxUploadAttempts} for {key} part {partNumber} after {delay.TotalSeconds:0}s: {ex.Message}");
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"S3 multipart upload failed for {key} part {partNumber}.");
|
||||
}
|
||||
|
||||
private async Task<PlondsS3UploadedPart> UploadMultipartPartOnceAsync(
|
||||
string sourcePath,
|
||||
string key,
|
||||
string uploadId,
|
||||
int partNumber,
|
||||
long offset,
|
||||
long length,
|
||||
int attempt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestUri = BuildObjectUri(key, $"partNumber={partNumber}&uploadId={Uri.EscapeDataString(uploadId)}");
|
||||
var bytes = new byte[length];
|
||||
await using (var fileStream = File.OpenRead(sourcePath))
|
||||
{
|
||||
fileStream.Seek(offset, SeekOrigin.Begin);
|
||||
var totalRead = 0;
|
||||
while (totalRead < bytes.Length)
|
||||
{
|
||||
var read = await fileStream.ReadAsync(bytes.AsMemory(totalRead), cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new EndOfStreamException($"Unexpected end of file while reading {sourcePath} for part {partNumber}.");
|
||||
}
|
||||
|
||||
totalRead += read;
|
||||
}
|
||||
}
|
||||
|
||||
var payloadHash = Sha256Hex(bytes);
|
||||
Console.WriteLine($"Uploading S3 multipart part {partNumber} for {key} ({FormatBytes(length)}), attempt {attempt}/{options.MaxUploadAttempts}.");
|
||||
|
||||
using var content = new ByteArrayContent(bytes);
|
||||
content.Headers.ContentLength = length;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
SignRequest(request, key, payloadHash, DateTimeOffset.UtcNow);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"S3 multipart upload failed for {key} part {partNumber}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(body, 512)}");
|
||||
}
|
||||
|
||||
var etag = response.Headers.ETag?.Tag;
|
||||
if (string.IsNullOrWhiteSpace(etag))
|
||||
{
|
||||
throw new InvalidOperationException($"S3 multipart upload did not return ETag for {key} part {partNumber}.");
|
||||
}
|
||||
|
||||
return new PlondsS3UploadedPart(partNumber, etag);
|
||||
}
|
||||
|
||||
private async Task CompleteMultipartUploadAsync(
|
||||
string key,
|
||||
string uploadId,
|
||||
IReadOnlyList<PlondsS3UploadedPart> parts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var body = BuildCompleteMultipartUploadBody(parts);
|
||||
var bodyBytes = Encoding.UTF8.GetBytes(body);
|
||||
var payloadHash = Sha256Hex(bodyBytes);
|
||||
var requestUri = BuildObjectUri(key, $"uploadId={Uri.EscapeDataString(uploadId)}");
|
||||
|
||||
using var content = new ByteArrayContent(bodyBytes);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("application/xml");
|
||||
content.Headers.ContentLength = bodyBytes.Length;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
SignRequest(request, key, payloadHash, DateTimeOffset.UtcNow);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"S3 complete multipart upload failed for {key}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(responseBody, 512)}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AbortMultipartUploadBestEffortAsync(string key, string uploadId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestUri = BuildObjectUri(key, $"uploadId={Uri.EscapeDataString(uploadId)}");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, requestUri);
|
||||
SignRequest(request, key, EmptyPayloadHash, DateTimeOffset.UtcNow);
|
||||
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.Error.WriteLine($"S3 abort multipart upload failed for {key}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"S3 abort multipart upload failed for {key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UploadFileIfChangedAsync(PlondsS3ObjectUpload upload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(upload);
|
||||
@@ -204,6 +431,7 @@ public sealed class PlondsS3Client : IDisposable
|
||||
var dateStamp = now.UtcDateTime.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
var credentialScope = $"{dateStamp}/{options.Region}/{ServiceName}/aws4_request";
|
||||
var canonicalUri = BuildCanonicalUri(key);
|
||||
var canonicalQueryString = BuildCanonicalQueryString(request.RequestUri);
|
||||
var host = request.RequestUri?.IsDefaultPort == true
|
||||
? request.RequestUri.Host
|
||||
: request.RequestUri?.Authority;
|
||||
@@ -227,7 +455,7 @@ public sealed class PlondsS3Client : IDisposable
|
||||
[
|
||||
request.Method.Method,
|
||||
canonicalUri,
|
||||
string.Empty,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders.ToString(),
|
||||
signedHeaders,
|
||||
payloadHash
|
||||
@@ -247,13 +475,14 @@ public sealed class PlondsS3Client : IDisposable
|
||||
request.Headers.TryAddWithoutValidation("Authorization", authorization);
|
||||
}
|
||||
|
||||
private Uri BuildObjectUri(string key)
|
||||
private Uri BuildObjectUri(string key, string? query = null)
|
||||
{
|
||||
var bucketPrefix = Uri.EscapeDataString(options.Bucket).Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
|
||||
var path = $"{options.Endpoint.AbsolutePath.TrimEnd('/')}/{bucketPrefix}/{BuildCanonicalKey(key)}";
|
||||
var builder = new UriBuilder(options.Endpoint)
|
||||
{
|
||||
Path = path
|
||||
Path = path,
|
||||
Query = query ?? string.Empty
|
||||
};
|
||||
|
||||
return builder.Uri;
|
||||
@@ -272,6 +501,27 @@ public sealed class PlondsS3Client : IDisposable
|
||||
.Select(Uri.EscapeDataString));
|
||||
}
|
||||
|
||||
private static string BuildCanonicalQueryString(Uri? uri)
|
||||
{
|
||||
if (uri is null || string.IsNullOrEmpty(uri.Query))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Join("&", uri.Query.TrimStart('?')
|
||||
.Split('&', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(parameter =>
|
||||
{
|
||||
var parts = parameter.Split('=', 2);
|
||||
var name = Uri.UnescapeDataString(parts[0]);
|
||||
var value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
|
||||
return new KeyValuePair<string, string>(name, value);
|
||||
})
|
||||
.OrderBy(parameter => parameter.Key, StringComparer.Ordinal)
|
||||
.ThenBy(parameter => parameter.Value, StringComparer.Ordinal)
|
||||
.Select(parameter => $"{Uri.EscapeDataString(parameter.Key)}={Uri.EscapeDataString(parameter.Value)}"));
|
||||
}
|
||||
|
||||
private static string NormalizeKey(string value)
|
||||
{
|
||||
var normalized = value.Replace('\\', '/').Trim('/');
|
||||
@@ -320,6 +570,23 @@ public sealed class PlondsS3Client : IDisposable
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Sha256Hex(byte[] value)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string BuildCompleteMultipartUploadBody(IEnumerable<PlondsS3UploadedPart> parts)
|
||||
{
|
||||
var document = new XDocument(
|
||||
new XElement("CompleteMultipartUpload",
|
||||
parts.OrderBy(part => part.PartNumber)
|
||||
.Select(part => new XElement("Part",
|
||||
new XElement("PartNumber", part.PartNumber.ToString(CultureInfo.InvariantCulture)),
|
||||
new XElement("ETag", part.ETag)))));
|
||||
|
||||
return document.ToString(SaveOptions.DisableFormatting);
|
||||
}
|
||||
|
||||
private static byte[] HmacSha256(byte[] key, string data)
|
||||
{
|
||||
return HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data));
|
||||
@@ -371,4 +638,6 @@ public sealed class PlondsS3Client : IDisposable
|
||||
|
||||
return $"{value:0.##} {units[unit]}";
|
||||
}
|
||||
|
||||
private sealed record PlondsS3UploadedPart(int PartNumber, string ETag);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,10 @@ public sealed record PlondsS3ClientOptions(
|
||||
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
public int MaxUploadAttempts { get; init; } = 3;
|
||||
|
||||
public long MultipartThresholdBytes { get; init; } = 8L * 1024 * 1024;
|
||||
|
||||
public long MultipartPartSizeBytes { get; init; } = 8L * 1024 * 1024;
|
||||
|
||||
public int MultipartConcurrency { get; init; } = 4;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,12 @@ internal static class PlondsCli
|
||||
AccessKey: Require(options, "s3-access-key"),
|
||||
SecretKey: Require(options, "s3-secret-key"),
|
||||
PublicBaseUrl: Require(options, "s3-public-base-url"),
|
||||
PublicBaseKeyPrefix: Get(options, "s3-public-base-key-prefix", string.Empty) ?? string.Empty))
|
||||
PublicBaseKeyPrefix: Get(options, "s3-public-base-key-prefix", string.Empty) ?? string.Empty)
|
||||
{
|
||||
MultipartThresholdBytes = GetLong(options, "multipart-threshold-mb", 8) * 1024 * 1024,
|
||||
MultipartPartSizeBytes = GetLong(options, "multipart-part-size-mb", 8) * 1024 * 1024,
|
||||
MultipartConcurrency = GetInt(options, "multipart-concurrency", 4)
|
||||
})
|
||||
{
|
||||
DirectoryUploadConcurrency = GetInt(options, "directory-upload-concurrency", 4)
|
||||
}).ConfigureAwait(false);
|
||||
@@ -216,6 +221,9 @@ internal static class PlondsCli
|
||||
Console.WriteLine(" [--s3-public-base-key-prefix <prefix>] Key prefix already represented by public URL");
|
||||
Console.WriteLine(" [--s3-prefix <prefix>] Object key prefix (default: lanmountain/update/plonds)");
|
||||
Console.WriteLine(" [--directory-upload-concurrency <n>] Parallel file uploads for expanded directories (default: 4)");
|
||||
Console.WriteLine(" [--multipart-threshold-mb <n>] Use multipart upload for files at or above this size (default: 8)");
|
||||
Console.WriteLine(" [--multipart-part-size-mb <n>] Multipart upload part size in MiB (default: 8)");
|
||||
Console.WriteLine(" [--multipart-concurrency <n>] Parallel multipart part uploads (default: 4)");
|
||||
Console.WriteLine(" [--work-dir <dir>] Temporary publish work directory");
|
||||
}
|
||||
|
||||
@@ -230,4 +238,16 @@ internal static class PlondsCli
|
||||
? parsed
|
||||
: throw new InvalidOperationException($"Option --{key} must be a positive integer.");
|
||||
}
|
||||
|
||||
private static long GetLong(IReadOnlyDictionary<string, string> options, string key, long defaultValue)
|
||||
{
|
||||
if (!options.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return long.TryParse(value, out var parsed) && parsed > 0
|
||||
? parsed
|
||||
: throw new InvalidOperationException($"Option --{key} must be a positive integer.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user