Compare commits

..

5 Commits

Author SHA1 Message Date
lincube
8403b89a15 fiz.4×2日历组件日期显示修复 2026-06-02 14:28:33 +08:00
lincube
0ea98c08bf feat.PLONDS客户端补全 2026-06-02 13:16:13 +08:00
lincube
54d97e312d fix.plonds-s3-multipart-upload 2026-06-02 10:09:06 +08:00
lincube
04b95020bd fix.plonds-s3-resumable-publish 2026-06-02 09:27:08 +08:00
lincube
cf08269e15 fix.plonds-s3-upload-timeout 2026-06-02 08:51:53 +08:00
19 changed files with 1980 additions and 268 deletions

View File

@@ -21,11 +21,16 @@ env:
DOTNET_VERSION: '10.0.x'
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:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: write
actions: read
@@ -116,7 +121,11 @@ jobs:
--s3-access-key "$S3_ACCESS_KEY" \
--s3-secret-key "$S3_SECRET_KEY" \
--s3-public-base-url "$PUBLIC_BASE" \
--s3-public-base-key-prefix "$PLONDS_S3_PUBLIC_BASE_KEY_PREFIX"
--s3-public-base-key-prefix "$PLONDS_S3_PUBLIC_BASE_KEY_PREFIX" \
--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

View File

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

View File

@@ -116,6 +116,9 @@ Publisher 上传到 S3 的版本目录:
- `<prefix>/PLONDS.json` 是 S3 的固定 latest manifest 地址,和 GitHub Release latest manifest 一起作为客户端内置初始 source。
- GitHub Release 仍可保留平台原始文件名,例如 `files-windows-x64.zip`
- `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. 建议代码结构

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,4 +8,7 @@ public sealed record PlondsPublishOptions(
string FilesZipPath,
string WorkDir,
string S3KeyPrefix,
PlondsS3ClientOptions S3);
PlondsS3ClientOptions S3)
{
public int DirectoryUploadConcurrency { get; init; } = 4;
}

View File

@@ -62,11 +62,12 @@ public sealed class PlondsPublisher
using var s3 = new PlondsS3Client(options.S3);
var changedFileCount = await UploadDirectoryAsync(s3, changedExtractRoot, changedFolderKey, cancellationToken).ConfigureAwait(false);
var filesFileCount = await UploadDirectoryAsync(s3, filesExtractRoot, filesFolderKey, cancellationToken).ConfigureAwait(false);
await UploadArtifactAsync(s3, changedZipPath, changedZipKey, "application/zip", cancellationToken).ConfigureAwait(false);
await UploadArtifactAsync(s3, filesZipPath, filesZipKey, "application/zip", cancellationToken).ConfigureAwait(false);
await s3.UploadFileAsync(new PlondsS3ObjectUpload(changedZipPath, changedZipKey, "application/zip"), cancellationToken).ConfigureAwait(false);
await s3.UploadFileAsync(new PlondsS3ObjectUpload(filesZipPath, filesZipKey, "application/zip"), cancellationToken).ConfigureAwait(false);
var directoryConcurrency = Math.Max(1, options.DirectoryUploadConcurrency);
var changedFileCount = await UploadDirectoryAsync(s3, changedExtractRoot, changedFolderKey, directoryConcurrency, cancellationToken).ConfigureAwait(false);
var filesFileCount = await UploadDirectoryAsync(s3, filesExtractRoot, filesFolderKey, directoryConcurrency, cancellationToken).ConfigureAwait(false);
var updatedChecksums = new Dictionary<string, string>(manifest.Checksums, StringComparer.OrdinalIgnoreCase)
{
@@ -130,18 +131,79 @@ public sealed class PlondsPublisher
PlondsS3Client s3,
string sourceDirectory,
string destinationKeyPrefix,
int concurrency,
CancellationToken cancellationToken)
{
var count = 0;
foreach (var filePath in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories).OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
var files = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)
.Select(filePath =>
{
var relativePath = PayloadUtilities.NormalizeRelativePath(Path.GetRelativePath(sourceDirectory, filePath));
return new DirectoryUploadPlan(
SourcePath: filePath,
ObjectKey: $"{destinationKeyPrefix}/{relativePath}",
ContentType: ResolveContentType(filePath));
})
.OrderBy(x => x.ObjectKey, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (files.Length == 0)
{
var relativePath = PayloadUtilities.NormalizeRelativePath(Path.GetRelativePath(sourceDirectory, filePath));
var objectKey = $"{destinationKeyPrefix}/{relativePath}";
await s3.UploadFileAsync(new PlondsS3ObjectUpload(filePath, objectKey, ResolveContentType(filePath)), cancellationToken).ConfigureAwait(false);
count++;
Console.WriteLine($"No files found under {sourceDirectory}; skipping S3 directory upload to {destinationKeyPrefix}.");
return 0;
}
return count;
Console.WriteLine($"Uploading S3 directory {destinationKeyPrefix}: {files.Length} files with concurrency {concurrency}.");
var processed = 0;
var uploaded = 0;
var skipped = 0;
await Parallel.ForEachAsync(
files,
new ParallelOptions
{
MaxDegreeOfParallelism = concurrency,
CancellationToken = cancellationToken
},
async (file, token) =>
{
var didUpload = await s3.UploadFileIfChangedAsync(
new PlondsS3ObjectUpload(file.SourcePath, file.ObjectKey, file.ContentType),
token).ConfigureAwait(false);
if (didUpload)
{
Interlocked.Increment(ref uploaded);
}
else
{
Interlocked.Increment(ref skipped);
}
var current = Interlocked.Increment(ref processed);
if (current == files.Length || current % 10 == 0)
{
Console.WriteLine($"S3 directory progress {destinationKeyPrefix}: {current}/{files.Length} processed ({uploaded} uploaded, {skipped} skipped).");
}
}).ConfigureAwait(false);
Console.WriteLine($"Finished S3 directory {destinationKeyPrefix}: {files.Length} files processed ({uploaded} uploaded, {skipped} skipped).");
return files.Length;
}
private static async Task UploadArtifactAsync(
PlondsS3Client s3,
string sourcePath,
string objectKey,
string contentType,
CancellationToken cancellationToken)
{
var didUpload = await s3.UploadFileIfChangedAsync(
new PlondsS3ObjectUpload(sourcePath, objectKey, contentType),
cancellationToken).ConfigureAwait(false);
Console.WriteLine(didUpload
? $"Published S3 artifact {objectKey}."
: $"S3 artifact {objectKey} already exists with matching size.");
}
private static PlondsManifest LoadManifest(string manifestPath)
@@ -201,4 +263,9 @@ public sealed class PlondsPublisher
? throw new ArgumentException($"{name} is required.", name)
: value.Trim();
}
private sealed record DirectoryUploadPlan(
string SourcePath,
string ObjectKey,
string ContentType);
}

View File

@@ -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;
@@ -27,11 +28,19 @@ public sealed class PlondsS3Client : IDisposable
AccessKey = Require(options.AccessKey, nameof(options.AccessKey)),
SecretKey = Require(options.SecretKey, nameof(options.SecretKey)),
PublicBaseUrl = Require(options.PublicBaseUrl, nameof(options.PublicBaseUrl)).TrimEnd('/'),
PublicBaseKeyPrefix = NormalizeOptionalKeyPrefix(options.PublicBaseKeyPrefix)
PublicBaseKeyPrefix = NormalizeOptionalKeyPrefix(options.PublicBaseKeyPrefix),
RequestTimeout = options.RequestTimeout <= TimeSpan.Zero ? TimeSpan.FromMinutes(30) : options.RequestTimeout,
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)
};
this.httpClient = httpClient ?? new HttpClient();
ownsHttpClient = httpClient is null;
this.httpClient = httpClient ?? new HttpClient
{
Timeout = this.options.RequestTimeout
};
}
public async Task UploadFileAsync(PlondsS3ObjectUpload upload, CancellationToken cancellationToken = default)
@@ -47,20 +56,293 @@ public sealed class PlondsS3Client : IDisposable
var key = NormalizeKey(upload.Key);
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
{
await UploadFileOnceAsync(sourcePath, key, upload.ContentType, payloadHash, contentLength, attempt, cancellationToken).ConfigureAwait(false);
return;
}
catch (Exception ex) when (attempt < options.MaxUploadAttempts && IsRetriable(ex))
{
var delay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, attempt)));
Console.Error.WriteLine($"S3 upload retry {attempt + 1}/{options.MaxUploadAttempts} for {key} after {delay.TotalSeconds:0}s: {ex.Message}");
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
}
}
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);
var sourcePath = Path.GetFullPath(upload.SourcePath);
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException("S3 upload source file not found.", sourcePath);
}
var key = NormalizeKey(upload.Key);
var contentLength = new FileInfo(sourcePath).Length;
var existing = await TryGetObjectInfoForUploadAsync(key, cancellationToken).ConfigureAwait(false);
if (existing?.ContentLength == contentLength)
{
Console.WriteLine($"Skipping S3 object {key}; existing object has matching size {FormatBytes(contentLength)}.");
return false;
}
await UploadFileAsync(upload, cancellationToken).ConfigureAwait(false);
return true;
}
private async Task UploadFileOnceAsync(
string sourcePath,
string key,
string? contentType,
string payloadHash,
long contentLength,
int attempt,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var requestUri = BuildObjectUri(key);
Console.WriteLine($"Uploading S3 object {key} ({FormatBytes(contentLength)}), attempt {attempt}/{options.MaxUploadAttempts}.");
using var content = new StreamContent(File.OpenRead(sourcePath));
content.Headers.ContentType = new MediaTypeHeaderValue(string.IsNullOrWhiteSpace(upload.ContentType)
await using var fileStream = File.OpenRead(sourcePath);
using var content = new StreamContent(fileStream);
content.Headers.ContentType = new MediaTypeHeaderValue(string.IsNullOrWhiteSpace(contentType)
? "application/octet-stream"
: upload.ContentType);
: contentType);
content.Headers.ContentLength = contentLength;
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri)
{
Content = content
};
SignRequest(request, key, payloadHash, now);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
@@ -69,9 +351,21 @@ public sealed class PlondsS3Client : IDisposable
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"S3 upload failed for {key}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(body, 512)}");
}
Console.WriteLine($"Uploaded S3 object {key}.");
}
public async Task EnsureObjectExistsAsync(string key, CancellationToken cancellationToken = default)
{
var normalizedKey = NormalizeKey(key);
var objectInfo = await TryGetObjectInfoAsync(normalizedKey, cancellationToken).ConfigureAwait(false);
if (objectInfo is null)
{
throw new InvalidOperationException($"S3 object verification failed for {normalizedKey}: object was not found.");
}
}
public async Task<PlondsS3ObjectInfo?> TryGetObjectInfoAsync(string key, CancellationToken cancellationToken = default)
{
var normalizedKey = NormalizeKey(key);
var now = DateTimeOffset.UtcNow;
@@ -81,9 +375,32 @@ public sealed class PlondsS3Client : IDisposable
SignRequest(request, normalizedKey, EmptyPayloadHash, now);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (response.StatusCode is HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"S3 object verification failed for {normalizedKey}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
throw new InvalidOperationException($"S3 object metadata lookup failed for {normalizedKey}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
}
return new PlondsS3ObjectInfo(
Key: normalizedKey,
ContentLength: response.Content.Headers.ContentLength,
ETag: response.Headers.ETag?.Tag);
}
private async Task<PlondsS3ObjectInfo?> TryGetObjectInfoForUploadAsync(string key, CancellationToken cancellationToken)
{
try
{
return await TryGetObjectInfoAsync(key, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Console.Error.WriteLine($"S3 object metadata lookup for {key} failed; uploading anyway. {ex.Message}");
return null;
}
}
@@ -114,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;
@@ -137,7 +455,7 @@ public sealed class PlondsS3Client : IDisposable
[
request.Method.Method,
canonicalUri,
string.Empty,
canonicalQueryString,
canonicalHeaders.ToString(),
signedHeaders,
payloadHash
@@ -157,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;
@@ -182,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('/');
@@ -230,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));
@@ -257,4 +614,30 @@ public sealed class PlondsS3Client : IDisposable
return value[..maxLength];
}
private static bool IsRetriable(Exception exception)
{
if (exception is TaskCanceledException or TimeoutException or HttpRequestException)
{
return true;
}
return exception.InnerException is not null && IsRetriable(exception.InnerException);
}
private static string FormatBytes(long bytes)
{
string[] units = ["B", "KB", "MB", "GB"];
double value = bytes;
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
return $"{value:0.##} {units[unit]}";
}
private sealed record PlondsS3UploadedPart(int PartNumber, string ETag);
}

View File

@@ -7,4 +7,15 @@ public sealed record PlondsS3ClientOptions(
string AccessKey,
string SecretKey,
string PublicBaseUrl,
string PublicBaseKeyPrefix = "");
string PublicBaseKeyPrefix = "")
{
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;
}

View File

@@ -0,0 +1,6 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsS3ObjectInfo(
string Key,
long? ContentLength,
string? ETag);

View File

@@ -113,7 +113,15 @@ 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))).ConfigureAwait(false);
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);
Console.WriteLine($"Published PLONDS release {result.ReleaseTag}:");
Console.WriteLine($" Prefix: {result.VersionPrefix}");
@@ -212,6 +220,34 @@ internal static class PlondsCli
Console.WriteLine(" --s3-public-base-url <url> Public URL prefix for uploaded keys");
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");
}
private static int GetInt(IReadOnlyDictionary<string, string> options, string key, int defaultValue)
{
if (!options.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return defaultValue;
}
return int.TryParse(value, out var parsed) && parsed > 0
? 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.");
}
}