Add update contracts, IPC progress & providers

Introduce a new update subsystem: shared contracts for manifests, messages, paths and state (LanMountainDesktop.Shared.Contracts.Update). Add IPC and reporting infrastructure for installer progress (IUpdateProgressReporter, LauncherUpdateProgressIpcServer, NullUpdateProgressReporter) and integrate progress/complete reporting into UpdateEngineService. Add multiple update service components and providers (CompositeManifestProvider, GithubReleaseManifestProvider, PlondsApiManifestProvider, UpdateDownloadEngine, UpdateOrchestrator, UpdateInstallGateway, CLI launcher bridge, launcher bridge interfaces, observable helper, state store, progress subject, JSON context). Update settings and models to support UseGhProxyMirror and PLONDS/GitHub fallback logic, plus localization strings and UI/viewmodel files for update settings and progress. Misc: installer script tweak and a small change in Plonds generator. This adds end-to-end support for checking, downloading and reporting update progress and results.
This commit is contained in:
lincube
2026-05-03 19:31:04 +08:00
parent 01670147f6
commit 458494d131
42 changed files with 3626 additions and 192 deletions

View File

@@ -41,4 +41,6 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(StartupAttemptRecord))]
[JsonSerializable(typeof(PrivacyConfig))]
[JsonSerializable(typeof(PrivacyAgreementState))]
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

View File

@@ -0,0 +1,9 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services;
public interface IUpdateProgressReporter
{
void ReportProgress(InstallProgressReport report);
void ReportComplete(InstallCompleteReport report);
}

View File

@@ -0,0 +1,132 @@
using System.Buffers;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services.Ipc;
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
{
private const int LengthPrefixSize = 4;
private readonly string _pipeName;
private readonly CancellationTokenSource _cts = new();
private NamedPipeServerStream? _pipe;
private Task? _listenTask;
private volatile bool _clientConnected;
public LauncherUpdateProgressIpcServer(int launcherPid)
{
_pipeName = $"LanMountainDesktop_Update_{launcherPid}";
}
public string PipeName => _pipeName;
public void Start()
{
_listenTask = Task.Run(AcceptConnectionAsync, _cts.Token);
}
private async Task AcceptConnectionAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
_pipe = new NamedPipeServerStream(
_pipeName,
PipeDirection.Out,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await _pipe.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
_clientConnected = true;
return;
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.Warn($"Update progress IPC listen error: {ex.Message}");
try
{
await Task.Delay(200, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
public void ReportProgress(InstallProgressReport report)
{
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
{
return;
}
try
{
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallProgressReport));
}
catch (Exception ex)
{
Logger.Warn($"Failed to report progress via IPC: {ex.Message}");
}
}
public void ReportComplete(InstallCompleteReport report)
{
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
{
return;
}
try
{
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallCompleteReport));
}
catch (Exception ex)
{
Logger.Warn($"Failed to report completion via IPC: {ex.Message}");
}
}
private static void WriteMessage(Stream stream, string json)
{
var payload = Encoding.UTF8.GetBytes(json);
var lengthPrefix = BitConverter.GetBytes(payload.Length);
stream.Write(lengthPrefix, 0, LengthPrefixSize);
stream.Write(payload, 0, payload.Length);
stream.Flush();
}
public void Dispose()
{
_cts.Cancel();
try
{
_pipe?.Dispose();
}
catch
{
}
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(2));
}
catch
{
}
_cts.Dispose();
}
}

View File

@@ -0,0 +1,9 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class NullUpdateProgressReporter : IUpdateProgressReporter
{
public void ReportProgress(InstallProgressReport report) { }
public void ReportComplete(InstallCompleteReport report) { }
}

View File

@@ -2,6 +2,7 @@ using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services;
@@ -20,14 +21,16 @@ internal sealed class UpdateEngineService
private const string PublicKeyFileName = "public-key.pem";
private readonly DeploymentLocator _deploymentLocator;
private readonly IUpdateProgressReporter _progressReporter;
private readonly string _appRoot;
private readonly string _launcherRoot;
private readonly string _incomingRoot;
private readonly string _snapshotsRoot;
public UpdateEngineService(DeploymentLocator deploymentLocator)
public UpdateEngineService(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null)
{
_deploymentLocator = deploymentLocator;
_progressReporter = progressReporter ?? new NullUpdateProgressReporter();
_appRoot = deploymentLocator.GetAppRoot();
var resolver = new DataLocationResolver(_appRoot);
_launcherRoot = resolver.ResolveLauncherDataPath();
@@ -149,9 +152,11 @@ internal sealed class UpdateEngineService
};
}
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0));
var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName);
if (!verifyResult.Success)
{
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
return Failed("update.apply", "signature_failed", verifyResult.Message);
}
@@ -159,6 +164,7 @@ internal sealed class UpdateEngineService
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
if (fileMap is null || fileMap.Files.Count == 0)
{
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false));
return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
}
@@ -206,14 +212,21 @@ internal sealed class UpdateEngineService
Directory.CreateDirectory(extractRoot);
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count));
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(partialMarker, string.Empty);
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, 0, fileMap.Files.Count));
var fileIndex = 0;
foreach (var file in fileMap.Files)
{
ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
fileIndex++;
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (fileIndex * 30 / fileMap.Files.Count), file.Path, fileIndex, fileMap.Files.Count));
}
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, 0, fileMap.Files.Count));
var verifyIndex = 0;
foreach (var file in fileMap.Files)
{
if (!NeedsVerification(file))
@@ -227,16 +240,22 @@ internal sealed class UpdateEngineService
{
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
}
verifyIndex++;
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (verifyIndex * 15 / fileMap.Files.Count), file.Path, verifyIndex, fileMap.Files.Count));
}
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count));
ActivateDeployment(currentDeployment, targetDeployment);
snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
// 婵炴挸鎳愰幃濠囧籍瑜忔晶妤呭嫉椤掑﹦绀夊ù锝呮缁绘岸鎮惧▎鎰粯閺?濞戞搩浜炴晶妤呭嫉椤戝じ绨伴柡鈧娑樼槷闁搞儳鍋炵划?
CleanupDestroyedDeployments();
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileMap.Files.Count, fileMap.Files.Count));
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, currentVersion, targetVersion, null, false));
return new LauncherResult
{
Success = true,
@@ -249,9 +268,11 @@ internal sealed class UpdateEngineService
}
catch (Exception ex)
{
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
TryRollbackOnFailure(snapshot);
snapshot.Status = "rolled_back";
SaveSnapshot(snapshotPath, snapshot);
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, ex.Message, true));
return new LauncherResult
{
Success = false,
@@ -283,9 +304,11 @@ internal sealed class UpdateEngineService
string pdcSignaturePath,
string pdcUpdatePath)
{
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0));
var verifyResult = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName);
if (!verifyResult.Success)
{
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
return Failed("update.apply", "signature_failed", verifyResult.Message);
}
@@ -299,6 +322,7 @@ internal sealed class UpdateEngineService
if (fileEntries.Count == 0)
{
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false));
return Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found.");
}
@@ -347,17 +371,26 @@ internal sealed class UpdateEngineService
Directory.Delete(targetDeployment, true);
}
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count));
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(partialMarker, string.Empty);
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, 0, fileEntries.Count));
var fileIndex = 0;
foreach (var entry in fileEntries)
{
ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment);
fileIndex++;
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (fileIndex * 30 / fileEntries.Count), entry.Path, fileIndex, fileEntries.Count));
}
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, 0, fileEntries.Count));
var verifyIndex = 0;
foreach (var entry in fileEntries)
{
VerifyPlondsFileEntry(entry, targetDeployment);
verifyIndex++;
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (verifyIndex * 15 / fileEntries.Count), entry.Path, verifyIndex, fileEntries.Count));
}
if (isInitialDeployment)
@@ -370,6 +403,7 @@ internal sealed class UpdateEngineService
}
else
{
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count));
ActivateDeployment(currentDeployment!, targetDeployment);
}
@@ -378,6 +412,9 @@ internal sealed class UpdateEngineService
CleanupIncomingArtifacts();
CleanupDestroyedDeployments();
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count));
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false));
return new LauncherResult
{
Success = true,
@@ -405,6 +442,7 @@ internal sealed class UpdateEngineService
snapshot.Status = "failed";
SaveSnapshot(snapshotPath, snapshot);
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false));
return new LauncherResult
{
Success = false,
@@ -417,9 +455,11 @@ internal sealed class UpdateEngineService
};
}
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
TryRollbackOnFailure(snapshot);
snapshot.Status = "rolled_back";
SaveSnapshot(snapshotPath, snapshot);
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, ex.Message, true));
return new LauncherResult
{
Success = false,

View File

@@ -0,0 +1,86 @@
namespace LanMountainDesktop.Shared.Contracts.Update;
public sealed record UpdateManifest(
string DistributionId,
string FromVersion,
string ToVersion,
string Platform,
string Channel,
DateTimeOffset PublishedAt,
UpdatePayloadKind Kind,
string? FileMapUrl,
string? FileMapSignatureUrl,
string? FileMapSha256,
IReadOnlyList<UpdateFileEntry> Files,
IReadOnlyList<UpdateMirrorAsset>? InstallerMirrors,
IReadOnlyDictionary<string, string> Metadata)
{
public bool IsDelta => Kind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
public long EstimatedDeltaBytes
{
get
{
long total = 0;
foreach (var f in Files)
{
if (f.Action is not ("reuse" or "delete"))
{
total += f.Size;
}
}
return total;
}
}
}
public sealed record UpdateFileEntry(
string Path,
string Action,
string Sha256,
long Size,
string Mode,
string? ObjectKey,
string? ObjectUrl,
string? ArchiveSha256,
IReadOnlyDictionary<string, string>? Metadata);
public sealed record UpdateMirrorAsset(
string Platform,
string? Url,
string? Name,
string? Sha256,
long Size);
public sealed record UpdateSettingsState(
string UpdateChannel,
string UpdateMode,
string UpdateDownloadSource,
int UpdateDownloadThreads,
string? PreferredDistributionId,
string? LastAppliedVersion,
DateTimeOffset? LastAppliedAt,
int ConsecutiveFailCount,
DateTimeOffset? LastFailureAt,
string? PendingUpdateInstallerPath,
string? PendingUpdateVersion,
long? PendingUpdatePublishedAtUtcMs,
long? LastUpdateCheckUtcMs,
string? PendingUpdateSha256)
{
public static UpdateSettingsState Default => new(
UpdateChannel: "stable",
UpdateMode: "download_then_confirm",
UpdateDownloadSource: "plonds-api",
UpdateDownloadThreads: 4,
PreferredDistributionId: null,
LastAppliedVersion: null,
LastAppliedAt: null,
ConsecutiveFailCount: 0,
LastFailureAt: null,
PendingUpdateInstallerPath: null,
PendingUpdateVersion: null,
PendingUpdatePublishedAtUtcMs: null,
LastUpdateCheckUtcMs: null,
PendingUpdateSha256: null);
}

View File

@@ -0,0 +1,66 @@
namespace LanMountainDesktop.Shared.Contracts.Update;
public sealed record InstallProgressReport(
InstallStage Stage,
string Message,
int ProgressPercent,
string? CurrentFile,
int FilesCompleted,
int FilesTotal)
{
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
public sealed record InstallCompleteReport(
bool Success,
string? FromVersion,
string? ToVersion,
string? ErrorMessage,
bool WasRolledBack)
{
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
public sealed record DownloadProgressReport(
string CurrentFile,
long BytesDownloaded,
long BytesTotal,
double BytesPerSecond,
int FilesCompleted,
int FilesTotal,
double OverallFraction)
{
public int OverallPercent => (int)Math.Clamp(OverallFraction * 100, 0, 100);
}
public sealed record UpdateProgressReport(
UpdatePhase Phase,
string Message,
double ProgressFraction,
DownloadProgressReport? DownloadDetail,
InstallProgressReport? InstallDetail)
{
public int ProgressPercent => (int)Math.Clamp(ProgressFraction * 100, 0, 100);
}
public sealed record UpdateCheckReport(
bool IsUpdateAvailable,
string? LatestVersion,
string? CurrentVersion,
UpdatePayloadKind? PayloadKind,
string? DistributionId,
string? Channel,
DateTimeOffset? PublishedAt,
long? TotalDownloadBytes,
long? FullInstallerBytes,
string? ErrorMessage);
public sealed record InstallRequest(
UpdatePayloadKind PayloadKind,
string LauncherRoot,
string? LaunchSource = null);
public sealed record LaunchResult(
bool Success,
string? ErrorMessage,
int? ProcessId);

View File

@@ -0,0 +1,71 @@
namespace LanMountainDesktop.Shared.Contracts.Update;
public static class UpdatePaths
{
private const string LauncherDirectoryName = ".launcher";
private const string UpdateDirectoryName = "update";
private const string IncomingDirectoryName = "incoming";
private const string ObjectsDirectoryName = "objects";
private const string SnapshotsDirectoryName = "snapshots";
public static string ResolveLauncherRoot(string appBaseDirectory)
{
var trimmed = appBaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var parent = Path.GetDirectoryName(trimmed);
return string.IsNullOrWhiteSpace(parent) ? appBaseDirectory : parent;
}
public static string GetLauncherDataRoot(string launcherRoot)
{
return Path.Combine(launcherRoot, LauncherDirectoryName);
}
public static string GetIncomingDirectory(string launcherRoot)
{
return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName);
}
public static string GetObjectsDirectory(string launcherRoot)
{
return Path.Combine(GetIncomingDirectory(launcherRoot), ObjectsDirectoryName);
}
public static string GetSnapshotsDirectory(string launcherRoot)
{
return Path.Combine(launcherRoot, LauncherDirectoryName, SnapshotsDirectoryName);
}
public static string GetDownloadMarkerPath(string launcherRoot)
{
return Path.Combine(GetIncomingDirectory(launcherRoot), ".download-complete");
}
public static string GetPlondsFileMapName() => "plonds-filemap.json";
public static string GetPlondsSignatureName() => "plonds-filemap.sig";
public static string GetPlondsUpdateMetadataName() => "plonds-update.json";
public static string GetLegacyFileMapName() => "files.json";
public static string GetLegacySignatureName() => "files.json.sig";
public static string GetLegacyArchiveName() => "update.zip";
public static string GetPublicKeyFileName() => "public-key.pem";
public static string GetPlondsFileMapPath(string launcherRoot)
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsFileMapName());
public static string GetPlondsSignaturePath(string launcherRoot)
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsSignatureName());
public static string GetPlondsUpdateMetadataPath(string launcherRoot)
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsUpdateMetadataName());
public static string GetDownloadMarkerContent(string manifestSha256, string targetVersion, int objectCount)
{
return $$"""
{
"manifestSha256": "{{manifestSha256}}",
"targetVersion": "{{targetVersion}}",
"objectCount": {{objectCount}},
"completedAt": "{{DateTimeOffset.UtcNow:O}}"
}
""";
}
}

View File

@@ -0,0 +1,83 @@
namespace LanMountainDesktop.Shared.Contracts.Update;
public enum UpdatePhase
{
Idle,
Checking,
Checked,
Downloading,
Downloaded,
Installing,
Installed,
Verifying,
Completed,
Failed,
Recovering,
RollingBack,
RolledBack
}
public enum UpdatePayloadKind
{
DeltaPlonds,
DeltaLegacy,
FullInstaller
}
public enum InstallStage
{
None,
VerifySignature,
CreateTarget,
ApplyFiles,
VerifyHashes,
ActivateDeployment,
Cleanup,
Completed,
Failed,
RollingBack
}
public enum UpdateChannel
{
Stable,
Preview
}
public enum UpdateMode
{
Manual,
DownloadThenConfirm,
SilentOnExit
}
public enum UpdateDownloadSource
{
PlondsApi,
GitHub,
GhProxy
}
public static class UpdatePhaseExtensions
{
public static bool IsTerminal(this UpdatePhase phase) =>
phase is UpdatePhase.Completed or UpdatePhase.Failed or UpdatePhase.RolledBack;
public static bool IsBusy(this UpdatePhase phase) =>
phase is not (UpdatePhase.Idle or UpdatePhase.Checked or UpdatePhase.Downloaded
or UpdatePhase.Installed or UpdatePhase.Completed or UpdatePhase.Failed
or UpdatePhase.RolledBack);
public static bool CanCheck(this UpdatePhase phase) =>
phase is UpdatePhase.Idle or UpdatePhase.Checked or UpdatePhase.Downloaded
or UpdatePhase.Completed or UpdatePhase.Failed or UpdatePhase.RolledBack;
public static bool CanDownload(this UpdatePhase phase) =>
phase is UpdatePhase.Checked;
public static bool CanInstall(this UpdatePhase phase) =>
phase is UpdatePhase.Downloaded;
public static bool CanRollback(this UpdatePhase phase) =>
phase is UpdatePhase.Failed;
}

View File

@@ -646,6 +646,27 @@
"settings.update.status_check_failed": "Failed to check for updates.",
"settings.update.status_available_summary_format": "Update available: {0} (current: {1})",
"settings.update.status_up_to_date_format": "You are up to date ({0}).",
"settings.update.force_full_label": "Force Full Update",
"settings.update.force_full_desc": "Skip incremental update and force download the full installer. Use this if incremental update fails repeatedly.",
"settings.update.network_accel_label": "Network Acceleration",
"settings.update.network_accel_desc": "Use gh-proxy mirror to accelerate GitHub downloads. Only applies when falling back to GitHub for full updates.",
"settings.update.redownload_button": "Redownload",
"settings.update.phase_scanning": "Scanning update source...",
"settings.update.phase_force_scanning": "Force scanning update source...",
"settings.update.phase_locating_resources": "Locating update resources...",
"settings.update.phase_force_full": "Forcing full update...",
"settings.update.phase_downloading_full": "Downloading full installer...",
"settings.update.phase_downloading_delta": "Downloading incremental update...",
"settings.update.status_downloading_full": "Downloading full installer...",
"settings.update.status_force_full_checking": "Checking for full installer...",
"settings.update.status_force_full_failed": "No full installer available.",
"settings.update.status_downloaded_no_hash_format": "Update downloaded. Hash: {0}",
"settings.update.status_redownload_no_check": "Please check for updates first before redownloading.",
"settings.update.status_redownloading": "Redownloading installer...",
"settings.update.status_redownload_failed_format": "Redownload failed: {0}",
"settings.update.source_plonds": "PLONDS",
"settings.update.source_plonds_desc": "Prefer PLONDS distribution endpoints, then automatically fallback to GitHub.",
"settings.update.status_check_failed_plonds": "PLONDS update check failed, falling back to GitHub...",
"settings.window.drawer_default": "Details",
"market.toolbar.search_placeholder": "Search plugins",
"market.toolbar.refresh": "Refresh",

View File

@@ -579,6 +579,27 @@
"settings.update.status_check_failed": "アップデートの確認に失敗しました。",
"settings.update.status_available_summary_format": "アップデートあり: {0}(現在: {1}",
"settings.update.status_up_to_date_format": "最新版です({0})。",
"settings.update.force_full_label": "完全更新を強制",
"settings.update.force_full_desc": "差分更新をスキップし、完全インストーラを強制ダウンロードします。差分更新が繰り返し失敗する場合に使用してください。",
"settings.update.network_accel_label": "ネットワーク高速化",
"settings.update.network_accel_desc": "gh-proxyミラーを使用してGitHubダウンロードを加速します。GitHubフルアップデートにフォールバック時のみ適用されます。",
"settings.update.redownload_button": "再ダウンロード",
"settings.update.phase_scanning": "更新ソースをスキャン中...",
"settings.update.phase_force_scanning": "更新ソースを強制スキャン中...",
"settings.update.phase_locating_resources": "更新リソースを特定中...",
"settings.update.phase_force_full": "完全更新を強制中...",
"settings.update.phase_downloading_full": "完全インストーラをダウンロード中...",
"settings.update.phase_downloading_delta": "差分更新をダウンロード中...",
"settings.update.status_downloading_full": "完全インストーラをダウンロード中...",
"settings.update.status_force_full_checking": "完全インストーラを確認中...",
"settings.update.status_force_full_failed": "利用可能な完全インストーラがありません。",
"settings.update.status_downloaded_no_hash_format": "更新がダウンロードされました。ハッシュ:{0}",
"settings.update.status_redownload_no_check": "再ダウンロードする前に更新を確認してください。",
"settings.update.status_redownloading": "インストーラを再ダウンロード中...",
"settings.update.status_redownload_failed_format": "再ダウンロードに失敗しました:{0}",
"settings.update.source_plonds": "PLONDS",
"settings.update.source_plonds_desc": "PLONDS配信エンドポイントを優先し、利用不可時にGitHubに自動フォールバックします。",
"settings.update.status_check_failed_plonds": "PLONDS更新確認に失敗しました。GitHubにフォールバック中...",
"settings.window.drawer_default": "詳細",
"market.toolbar.search_placeholder": "プラグインを検索",
"market.toolbar.refresh": "更新",

View File

@@ -624,6 +624,27 @@
"settings.update.status_check_failed": "업데이트 확인 실패.",
"settings.update.status_available_summary_format": "업데이트 발견: {0} (현재: {1}).",
"settings.update.status_up_to_date_format": "현재 최신 버전입니다 ({0}).",
"settings.update.force_full_label": "전체 업데이트 강제",
"settings.update.force_full_desc": "증분 업데이트를 건너뛰고 전체 설치 프로그램을 강제로 다운로드합니다. 증분 업데이트가 반복적으로 실패할 때 사용하세요.",
"settings.update.network_accel_label": "네트워크 가속",
"settings.update.network_accel_desc": "gh-proxy 미러를 사용하여 GitHub 다운로드를 가속합니다. GitHub 전체 업데이트로 대체될 때만 적용됩니다.",
"settings.update.redownload_button": "다시 다운로드",
"settings.update.phase_scanning": "업데이트 소스 스캔 중...",
"settings.update.phase_force_scanning": "업데이트 소스 강제 스캔 중...",
"settings.update.phase_locating_resources": "업데이트 리소스 찾는 중...",
"settings.update.phase_force_full": "전체 업데이트 강제 중...",
"settings.update.phase_downloading_full": "전체 설치 프로그램 다운로드 중...",
"settings.update.phase_downloading_delta": "증분 업데이트 다운로드 중...",
"settings.update.status_downloading_full": "전체 설치 프로그램 다운로드 중...",
"settings.update.status_force_full_checking": "전체 설치 프로그램 확인 중...",
"settings.update.status_force_full_failed": "사용 가능한 전체 설치 프로그램이 없습니다.",
"settings.update.status_downloaded_no_hash_format": "업데이트가 다운로드되었습니다. 해시: {0}",
"settings.update.status_redownload_no_check": "다시 다운로드하기 전에 업데이트를 확인하세요.",
"settings.update.status_redownloading": "설치 프로그램 다시 다운로드 중...",
"settings.update.status_redownload_failed_format": "다시 다운로드 실패: {0}",
"settings.update.source_plonds": "PLONDS",
"settings.update.source_plonds_desc": "PLONDS 배포 엔드포인트를 우선 사용하며, 사용 불가 시 GitHub로 자동 대체합니다.",
"settings.update.status_check_failed_plonds": "PLONDS 업데이트 확인 실패, GitHub로 대체 중...",
"settings.window.drawer_default": "상세 정보",
"market.toolbar.search_placeholder": "플러그인 검색",
"market.toolbar.refresh": "새로고침",

View File

@@ -494,11 +494,11 @@
"settings.about.render_mode.impl_format": "运行时实现:{0}",
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
"settings.about.description": "应用信息。",
"settings.update.description": "检查更新、选择发布通道与下载源,并控制更新安装方式。",
"settings.update.description": "检查更新、选择发布通道与安装方式,并控制更新行为。",
"settings.update.status_card_title": "更新状态",
"settings.update.status_card_description": "检查新版本、查看发布信息,并在有更新时继续下载或安装。",
"settings.update.preferences_header": "更新偏好",
"settings.update.preferences_description": "选择发布通道、安装包下载源、安装方式以及下载并行线程数。",
"settings.update.preferences_description": "选择发布通道、安装方式、网络加速以及下载并行线程数。",
"settings.update.last_checked_label": "上次检查",
"settings.update.source_label": "下载源",
"settings.update.source_github": "GitHub",
@@ -640,6 +640,27 @@
"settings.update.status_check_failed": "检查更新失败。",
"settings.update.status_available_summary_format": "发现更新:{0}(当前:{1})。",
"settings.update.status_up_to_date_format": "当前已是最新版本({0})。",
"settings.update.force_full_label": "强制完整更新",
"settings.update.force_full_desc": "跳过增量更新,直接下载完整安装包。如果增量更新反复失败,可使用此项。",
"settings.update.network_accel_label": "网络加速",
"settings.update.network_accel_desc": "使用 gh-proxy 镜像加速 GitHub 下载。仅在回退到 GitHub 全量更新时生效。",
"settings.update.redownload_button": "重新下载",
"settings.update.phase_scanning": "正在扫描更新源...",
"settings.update.phase_force_scanning": "正在强制扫描更新源...",
"settings.update.phase_locating_resources": "正在定位更新资源...",
"settings.update.phase_force_full": "正在强制完整更新...",
"settings.update.phase_downloading_full": "正在下载完整安装包...",
"settings.update.phase_downloading_delta": "正在下载增量更新...",
"settings.update.status_downloading_full": "正在下载完整安装包...",
"settings.update.status_force_full_checking": "正在检查完整安装包...",
"settings.update.status_force_full_failed": "没有可用的完整安装包。",
"settings.update.status_downloaded_no_hash_format": "更新已下载。哈希值:{0}",
"settings.update.status_redownload_no_check": "请先检查更新后再重新下载。",
"settings.update.status_redownloading": "正在重新下载安装包...",
"settings.update.status_redownload_failed_format": "重新下载失败:{0}",
"settings.update.source_plonds": "PLONDS",
"settings.update.source_plonds_desc": "优先使用 PLONDS 分发端点,不可用时自动回退到 GitHub。",
"settings.update.status_check_failed_plonds": "PLONDS 更新检查失败,正在回退到 GitHub...",
"settings.window.drawer_default": "详情",
"market.toolbar.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新",

View File

@@ -87,7 +87,9 @@ public sealed class AppSettingsSnapshot
public string UpdateMode { get; set; } = "download_then_confirm";
public string UpdateDownloadSource { get; set; } = "stcn";
public string UpdateDownloadSource { get; set; } = "plonds-api";
public bool UseGhProxyMirror { get; set; }
public int UpdateDownloadThreads { get; set; } = 4;

View File

@@ -88,6 +88,7 @@ public sealed record UpdateSettingsState(
string UpdateMode,
string UpdateDownloadSource,
int UpdateDownloadThreads,
bool UseGhProxyMirror,
string? PendingUpdateInstallerPath,
string? PendingUpdateVersion,
long? PendingUpdatePublishedAtUtcMs,

View File

@@ -789,6 +789,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode),
UpdateSettingsValues.NormalizeDownloadSource(snapshot.UpdateDownloadSource),
UpdateSettingsValues.NormalizeDownloadThreads(snapshot.UpdateDownloadThreads),
snapshot.UseGhProxyMirror,
snapshot.PendingUpdateInstallerPath,
snapshot.PendingUpdateVersion,
snapshot.PendingUpdatePublishedAtUtcMs,
@@ -810,6 +811,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.UpdateMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
snapshot.UpdateDownloadSource = UpdateSettingsValues.NormalizeDownloadSource(state.UpdateDownloadSource);
snapshot.UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads);
snapshot.UseGhProxyMirror = state.UseGhProxyMirror;
snapshot.PendingUpdateInstallerPath = string.IsNullOrWhiteSpace(state.PendingUpdateInstallerPath)
? null
: state.PendingUpdateInstallerPath.Trim();
@@ -836,6 +838,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
nameof(AppSettingsSnapshot.UpdateMode),
nameof(AppSettingsSnapshot.UpdateDownloadSource),
nameof(AppSettingsSnapshot.UpdateDownloadThreads),
nameof(AppSettingsSnapshot.UseGhProxyMirror),
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
nameof(AppSettingsSnapshot.PendingUpdateVersion),
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs),
@@ -918,45 +921,37 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
bool isForce,
CancellationToken cancellationToken)
{
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
var plondsResult = isForce
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (plondsResult.Success)
{
var plondsResult = isForce
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (plondsResult.Success)
{
return plondsResult;
}
AppLogger.Warn(
"UpdateSettings",
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
var githubFallbackResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (githubFallbackResult.Success)
{
AppLogger.Info(
"UpdateSettings",
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
}
else
{
AppLogger.Warn(
"UpdateSettings",
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
}
return githubFallbackResult;
return plondsResult;
}
return isForce
AppLogger.Warn(
"UpdateSettings",
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
var githubFallbackResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (githubFallbackResult.Success)
{
AppLogger.Info(
"UpdateSettings",
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
}
else
{
AppLogger.Warn(
"UpdateSettings",
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
}
return githubFallbackResult;
}
}

View File

@@ -0,0 +1,48 @@
using System.Diagnostics;
using System.IO;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class CliLauncherUpdateBridge : ILauncherUpdateBridge
{
public Task<LaunchResult> LaunchInstallerAsync(InstallRequest request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
try
{
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
return Task.FromResult(new LaunchResult(false, "Launcher executable not found.", null));
}
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source {request.LaunchSource ?? "apply-update"}",
UseShellExecute = false,
WorkingDirectory = resolvedLauncherRoot
};
var process = Process.Start(startInfo);
if (process is null)
{
return Task.FromResult(new LaunchResult(false, "Failed to start Launcher process.", null));
}
return Task.FromResult(new LaunchResult(true, null, process.Id));
}
catch (Exception ex)
{
return Task.FromResult(new LaunchResult(false, ex.Message, null));
}
}
public IObservable<InstallProgressReport> ProgressStream => ObservableHelper<InstallProgressReport>.Empty;
public Task<bool> SupportsIpcAsync() => Task.FromResult(false);
}

View File

@@ -0,0 +1,99 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class CompositeManifestProvider : IUpdateManifestProvider
{
private readonly IUpdateManifestProvider _primary;
private readonly IUpdateManifestProvider _fallback;
public string ProviderName => $"{_primary.ProviderName}+{_fallback.ProviderName}";
public CompositeManifestProvider(IUpdateManifestProvider primary, IUpdateManifestProvider fallback)
{
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
_fallback = fallback ?? throw new ArgumentNullException(nameof(fallback));
}
public async Task<UpdateManifest?> GetLatestAsync(
string channel,
string platform,
Version currentVersion,
CancellationToken ct)
{
try
{
var result = await _primary.GetLatestAsync(channel, platform, currentVersion, ct);
if (result is not null)
{
return result;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
AppLogger.Warn("Update", $"{_primary.ProviderName} GetLatestAsync failed: {ex.Message}", ex);
}
AppLogger.Info("Update", $"Falling back to {_fallback.ProviderName} for GetLatestAsync");
return await _fallback.GetLatestAsync(channel, platform, currentVersion, ct);
}
public async Task<UpdateManifest?> GetByVersionAsync(
string version,
string channel,
string platform,
CancellationToken ct)
{
try
{
var result = await _primary.GetByVersionAsync(version, channel, platform, ct);
if (result is not null)
{
return result;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
AppLogger.Warn("Update", $"{_primary.ProviderName} GetByVersionAsync failed: {ex.Message}", ex);
}
AppLogger.Info("Update", $"Falling back to {_fallback.ProviderName} for GetByVersionAsync");
return await _fallback.GetByVersionAsync(version, channel, platform, ct);
}
public async Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
string channel,
string platform,
Version fromVersion,
Version toVersion,
CancellationToken ct)
{
try
{
var result = await _primary.GetIncrementalChainAsync(channel, platform, fromVersion, toVersion, ct);
if (result is not null && result.Count > 0)
{
return result;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
AppLogger.Warn("Update", $"{_primary.ProviderName} GetIncrementalChainAsync failed: {ex.Message}", ex);
}
AppLogger.Info("Update", $"Falling back to {_fallback.ProviderName} for GetIncrementalChainAsync");
return await _fallback.GetIncrementalChainAsync(channel, platform, fromVersion, toVersion, ct);
}
}

View File

@@ -0,0 +1,131 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
{
private readonly GitHubReleaseUpdateService _githubService;
private readonly bool _ownsService;
public string ProviderName => "github-release";
public GithubReleaseManifestProvider(string owner, string repo, GitHubReleaseUpdateService? githubService = null)
{
if (githubService is null)
{
_githubService = new GitHubReleaseUpdateService(owner, repo);
_ownsService = true;
}
else
{
_githubService = githubService;
_ownsService = false;
}
}
public async Task<UpdateManifest?> GetLatestAsync(
string channel,
string platform,
Version currentVersion,
CancellationToken ct)
{
var includePrerelease = string.Equals(channel, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
var result = await _githubService.CheckForUpdatesAsync(currentVersion, includePrerelease, ct);
if (!result.Success || !result.IsUpdateAvailable || result.Release is null)
{
return null;
}
return UpdateManifestMapper.FromGitHubRelease(result.Release, result.PlondsPayload, channel, platform);
}
public async Task<UpdateManifest?> GetByVersionAsync(
string version,
string channel,
string platform,
CancellationToken ct)
{
var tag = version.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? version : $"v{version}";
var release = await _githubService.GetReleaseByTagAsync(tag, ct);
if (release is null)
{
return null;
}
var plondsPayload = TryResolvePlondsPayload(release);
return UpdateManifestMapper.FromGitHubRelease(release, plondsPayload, channel, platform);
}
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
string channel,
string platform,
Version fromVersion,
Version toVersion,
CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
}
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
{
if (release.Assets is null || release.Assets.Count == 0)
{
return null;
}
var platformSuffix = GetPlatformAssetSuffix();
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
{
return null;
}
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
var channelId = release.IsPrerelease
? UpdateSettingsValues.ChannelPreview
: UpdateSettingsValues.ChannelStable;
return new PlondsUpdatePayload(
DistributionId: distributionId,
ChannelId: channelId,
SubChannel: platformSuffix,
FileMapJson: null,
FileMapSignature: null,
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
UpdateArchiveSha256: archiveAsset.Sha256,
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
}
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
{
return assets.FirstOrDefault(a => string.Equals(a.Name, assetName, StringComparison.OrdinalIgnoreCase));
}
private static string GetPlatformAssetSuffix()
{
var os = OperatingSystem.IsWindows()
? "windows"
: OperatingSystem.IsLinux()
? "linux"
: OperatingSystem.IsMacOS()
? "macos"
: "unknown";
var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
{
System.Runtime.InteropServices.Architecture.X86 => "x86",
System.Runtime.InteropServices.Architecture.Arm => "arm",
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
_ => "x64"
};
return $"{os}-{arch}";
}
}

View File

@@ -0,0 +1,10 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
public interface ILauncherUpdateBridge
{
Task<LaunchResult> LaunchInstallerAsync(InstallRequest request, CancellationToken ct);
IObservable<InstallProgressReport> ProgressStream { get; }
Task<bool> SupportsIpcAsync();
}

View File

@@ -0,0 +1,27 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
public interface IUpdateManifestProvider
{
string ProviderName { get; }
Task<UpdateManifest?> GetLatestAsync(
string channel,
string platform,
Version currentVersion,
CancellationToken ct);
Task<UpdateManifest?> GetByVersionAsync(
string version,
string channel,
string platform,
CancellationToken ct);
Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
string channel,
string platform,
Version fromVersion,
Version toVersion,
CancellationToken ct);
}

View File

@@ -0,0 +1,171 @@
using System.Buffers;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class IpcLauncherUpdateBridge : ILauncherUpdateBridge, IDisposable
{
private const int LengthPrefixSize = 4;
private const int MaxPayloadLength = 1024 * 1024;
private static readonly TimeSpan PipeConnectTimeout = TimeSpan.FromSeconds(5);
private readonly UpdateProgressSubject _progressSubject = new();
private readonly CancellationTokenSource _cts = new();
private int? _launcherPid;
public IObservable<InstallProgressReport> ProgressStream => _progressSubject;
public async Task<LaunchResult> LaunchInstallerAsync(InstallRequest request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
try
{
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
return new LaunchResult(false, "Launcher executable not found.", null);
}
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source {request.LaunchSource ?? "apply-update"}",
UseShellExecute = false,
WorkingDirectory = resolvedLauncherRoot
};
var process = Process.Start(startInfo);
if (process is null)
{
return new LaunchResult(false, "Failed to start Launcher process.", null);
}
_launcherPid = process.Id;
_ = Task.Run(() => ConnectAndReadProgressAsync(process.Id, ct), ct);
return new LaunchResult(true, null, process.Id);
}
catch (Exception ex)
{
return new LaunchResult(false, ex.Message, null);
}
}
public Task<bool> SupportsIpcAsync()
{
return Task.FromResult(true);
}
private async Task ConnectAndReadProgressAsync(int launcherPid, CancellationToken ct)
{
var pipeName = $"LanMountainDesktop_Update_{launcherPid}";
try
{
using var pipe = new NamedPipeClientStream(
".",
pipeName,
PipeDirection.In,
PipeOptions.Asynchronous);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
using var timeoutCts = new CancellationTokenSource(PipeConnectTimeout);
using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(linkedCts.Token, timeoutCts.Token);
await pipe.ConnectAsync(combinedCts.Token).ConfigureAwait(false);
await ReadProgressFromPipeAsync(pipe, linkedCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
catch (TimeoutException)
{
}
catch (IOException)
{
}
catch (Exception ex)
{
AppLogger.Warn("IpcLauncherUpdateBridge", $"Progress pipe connection failed (fire-and-forget): {ex.Message}");
}
}
private async Task ReadProgressFromPipeAsync(NamedPipeClientStream pipe, CancellationToken ct)
{
var lengthBuffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize);
try
{
while (pipe.IsConnected && !ct.IsCancellationRequested)
{
var totalRead = 0;
while (totalRead < LengthPrefixSize)
{
var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), ct).ConfigureAwait(false);
if (read == 0)
{
return;
}
totalRead += read;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
{
return;
}
var payloadBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
try
{
totalRead = 0;
while (totalRead < payloadLength)
{
var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), ct).ConfigureAwait(false);
if (read == 0)
{
return;
}
totalRead += read;
}
var json = Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
var report = JsonSerializer.Deserialize(json, UpdateJsonContext.Default.InstallProgressReport);
if (report is not null)
{
_progressSubject.OnNext(report);
}
}
catch (JsonException)
{
}
finally
{
ArrayPool<byte>.Shared.Return(payloadBuffer);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(lengthBuffer);
}
}
public void Dispose()
{
_cts.Cancel();
_progressSubject.OnCompleted();
_cts.Dispose();
}
}

View File

@@ -0,0 +1,31 @@
namespace LanMountainDesktop.Services.Update;
internal static class ObservableHelper<T>
{
private sealed class EmptyObservable : IObservable<T>
{
public IDisposable Subscribe(IObserver<T> observer) => EmptyDisposable.Instance;
}
private sealed class EmptyDisposable : IDisposable
{
public static readonly EmptyDisposable Instance = new();
public void Dispose() { }
}
public static readonly IObservable<T> Empty = new EmptyObservable();
}
internal sealed class ActionObserver<T> : IObserver<T>
{
private readonly Action<T> _onNext;
public ActionObserver(Action<T> onNext)
{
_onNext = onNext;
}
public void OnCompleted() { }
public void OnError(Exception error) { }
public void OnNext(T value) => _onNext(value);
}

View File

@@ -0,0 +1,247 @@
using System.Globalization;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
{
private const string ApiBasePath = "/api/plonds/v1";
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
public string ProviderName => "plonds-api";
public PlondsApiManifestProvider(string baseUrl, HttpClient? httpClient = null)
{
if (httpClient is null)
{
_httpClient = new HttpClient
{
BaseAddress = new Uri(baseUrl.TrimEnd('/')),
Timeout = TimeSpan.FromSeconds(30)
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_httpClient.BaseAddress ??= new Uri(baseUrl.TrimEnd('/'));
_ownsHttpClient = false;
}
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
}
}
public async Task<UpdateManifest?> GetLatestAsync(
string channel,
string platform,
Version currentVersion,
CancellationToken ct)
{
var pointer = await GetChannelPointerAsync(channel, platform, currentVersion, ct);
if (pointer is null)
{
return null;
}
return await FetchDistributionManifestAsync(pointer.DistributionId, pointer.Version, channel, platform, ct);
}
public async Task<UpdateManifest?> GetByVersionAsync(
string version,
string channel,
string platform,
CancellationToken ct)
{
var distributionId = $"{channel}-{platform}-{version}";
return await FetchDistributionManifestAsync(distributionId, version, channel, platform, ct);
}
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
string channel,
string platform,
Version fromVersion,
Version toVersion,
CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
}
private async Task<PlondsChannelPointerDto?> GetChannelPointerAsync(
string channel,
string platform,
Version currentVersion,
CancellationToken ct)
{
var url = $"{ApiBasePath}/channels/{Uri.EscapeDataString(channel)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(currentVersion.ToString())}";
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(ct);
AppLogger.Warn("Update", $"PLONDS API latest endpoint returned HTTP {(int)response.StatusCode}: {Truncate(errorBody, 256)}");
return null;
}
var json = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<PlondsChannelPointerDto>(json, PlondsJsonOptions);
}
private async Task<UpdateManifest?> FetchDistributionManifestAsync(
string distributionId,
string targetVersion,
string channel,
string platform,
CancellationToken ct)
{
var url = $"{ApiBasePath}/distributions/{Uri.EscapeDataString(distributionId)}";
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(ct);
AppLogger.Warn("Update", $"PLONDS API distribution endpoint returned HTTP {(int)response.StatusCode}: {Truncate(errorBody, 256)}");
return null;
}
var json = await response.Content.ReadAsStringAsync(ct);
var dto = JsonSerializer.Deserialize<PlondsDistributionDto>(json, PlondsJsonOptions);
if (dto is null)
{
return null;
}
return MapDistribution(dto, channel, platform);
}
private static UpdateManifest MapDistribution(PlondsDistributionDto dto, string channel, string platform)
{
var files = new List<UpdateFileEntry>();
if (dto.Components is not null)
{
foreach (var component in dto.Components)
{
if (component.Files is null)
{
continue;
}
foreach (var f in component.Files)
{
files.Add(new UpdateFileEntry(
Path: f.Path ?? string.Empty,
Action: f.Op ?? "add",
Sha256: f.ContentHash ?? string.Empty,
Size: f.Size,
Mode: f.Mode ?? "file-object",
ObjectKey: f.ObjectKey,
ObjectUrl: null,
ArchiveSha256: null,
Metadata: null));
}
}
}
var mirrors = dto.InstallerMirrors?.Select(m => new UpdateMirrorAsset(
Platform: m.Platform ?? platform,
Url: m.Url,
Name: m.FileName,
Sha256: m.Sha256,
Size: m.Size)).ToArray();
var fileMapSignatureUrl = dto.Signatures?.FirstOrDefault()?.Signature;
return new UpdateManifest(
DistributionId: dto.DistributionId ?? string.Empty,
FromVersion: dto.SourceVersion ?? string.Empty,
ToVersion: dto.Version ?? string.Empty,
Platform: platform,
Channel: channel,
PublishedAt: dto.PublishedAt,
Kind: UpdatePayloadKind.DeltaPlonds,
FileMapUrl: dto.FileMapUrl,
FileMapSignatureUrl: fileMapSignatureUrl,
FileMapSha256: null,
Files: files,
InstallerMirrors: mirrors,
Metadata: dto.Metadata as IReadOnlyDictionary<string, string> ?? new Dictionary<string, string>());
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value;
}
return value[..maxLength];
}
private static readonly JsonSerializerOptions PlondsJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
private sealed record PlondsChannelPointerDto(
string? Channel,
string? Platform,
string? DistributionId,
string? Version,
DateTimeOffset PublishedAt);
private sealed record PlondsDistributionDto(
string? DistributionId,
string? Version,
string? SourceVersion,
string? Channel,
string? Platform,
DateTimeOffset PublishedAt,
string? FileMapUrl,
List<PlondsComponentDto>? Components,
List<PlondsMirrorDto>? InstallerMirrors,
List<PlondsSignatureDto>? Signatures,
Dictionary<string, string>? Metadata);
private sealed record PlondsComponentDto(
string? Id,
string? Root,
string? Mode,
List<PlondsFileDto>? Files);
private sealed record PlondsFileDto(
string? Path,
string? Op,
string? ContentHash,
long Size,
string? Mode,
string? ObjectKey);
private sealed record PlondsMirrorDto(
string? Platform,
string? Url,
string? FileName,
string? Sha256,
long Size);
private sealed record PlondsSignatureDto(
string? Algorithm,
string? KeyId,
string? Signature);
}

View File

@@ -0,0 +1,384 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
public sealed record DownloadResult(bool Success, string? FilePath, string? ErrorMessage, bool HashVerified);
internal sealed class UpdateDownloadEngine
{
private readonly IUpdateManifestProvider _manifestProvider;
private readonly ResumableDownloadService _downloadService;
private const int MaxRetryAttempts = 3;
private const int RetryDelayMs = 1000;
public UpdateDownloadEngine(
IUpdateManifestProvider manifestProvider,
ResumableDownloadService downloadService)
{
_manifestProvider = manifestProvider ?? throw new ArgumentNullException(nameof(manifestProvider));
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
}
public async Task<DownloadResult> DownloadPayloadAsync(
UpdateManifest manifest,
string incomingDirectory,
string objectsDirectory,
int maxConcurrency,
IProgress<DownloadProgressReport>? progress,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(manifest);
try
{
Directory.CreateDirectory(incomingDirectory);
Directory.CreateDirectory(objectsDirectory);
}
catch (Exception ex)
{
return new DownloadResult(false, null, $"Failed to create download directories: {ex.Message}", false);
}
var fileMapPath = Path.Combine(incomingDirectory, UpdatePaths.GetPlondsFileMapName());
var signaturePath = Path.Combine(incomingDirectory, UpdatePaths.GetPlondsSignatureName());
try
{
if (manifest.FileMapUrl is not null)
{
await DownloadWithRetryAsync(manifest.FileMapUrl, fileMapPath, ct);
}
if (manifest.FileMapSignatureUrl is not null)
{
await DownloadWithRetryAsync(manifest.FileMapSignatureUrl, signaturePath, ct);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new DownloadResult(false, null, $"Failed to download file map: {ex.Message}", false);
}
var downloadableFiles = manifest.Files
.Where(f => f.Action is not ("reuse" or "delete") && !string.IsNullOrWhiteSpace(f.ObjectUrl))
.ToList();
var totalFiles = downloadableFiles.Count + 2;
var completedFiles = 2;
var seenHashes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var semaphore = new SemaphoreSlim(Math.Max(1, maxConcurrency), Math.Max(1, maxConcurrency));
var errors = new List<string>();
long totalBytes = downloadableFiles.Sum(f => f.Size);
long downloadedBytes = 0;
var lockObj = new object();
var tasks = downloadableFiles.Select(async entry =>
{
await semaphore.WaitAsync(ct);
try
{
if (!seenHashes.Add(entry.Sha256))
{
lock (lockObj)
{
completedFiles++;
}
ReportProgress(progress, entry.Path, downloadedBytes, totalBytes, completedFiles, totalFiles);
return;
}
var objectPath = GetObjectDestinationPath(objectsDirectory, entry.Sha256);
var objectDir = Path.GetDirectoryName(objectPath);
if (!string.IsNullOrWhiteSpace(objectDir))
{
Directory.CreateDirectory(objectDir);
}
if (File.Exists(objectPath))
{
var existingHash = await ComputeFileSha256Async(objectPath, ct);
if (string.Equals(existingHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
{
lock (lockObj)
{
completedFiles++;
downloadedBytes += entry.Size;
}
ReportProgress(progress, entry.Path, downloadedBytes, totalBytes, completedFiles, totalFiles);
return;
}
}
if (string.IsNullOrWhiteSpace(entry.ObjectUrl))
{
return;
}
for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++)
{
ct.ThrowIfCancellationRequested();
var result = await _downloadService.DownloadAsync(
entry.ObjectUrl,
objectPath,
cancellationToken: ct);
if (result.Success)
{
var actualHash = await ComputeFileSha256Async(objectPath, ct);
var hashVerified = string.Equals(actualHash, entry.Sha256, StringComparison.OrdinalIgnoreCase);
if (!hashVerified)
{
AppLogger.Warn("UpdateDownloadEngine",
$"Object {entry.Path} hash mismatch after download. Expected: {entry.Sha256}, Actual: {actualHash}");
}
lock (lockObj)
{
completedFiles++;
downloadedBytes += entry.Size;
}
ReportProgress(progress, entry.Path, downloadedBytes, totalBytes, completedFiles, totalFiles);
return;
}
if (attempt < MaxRetryAttempts)
{
AppLogger.Warn("UpdateDownloadEngine",
$"Object {entry.Path} download attempt {attempt}/{MaxRetryAttempts} failed: {result.ErrorMessage}. Retrying.");
await Task.Delay(RetryDelayMs * attempt, ct);
}
else
{
lock (lockObj)
{
errors.Add($"Failed to download {entry.Path}: {result.ErrorMessage}");
}
}
}
}
finally
{
semaphore.Release();
}
});
try
{
await Task.WhenAll(tasks);
}
catch (OperationCanceledException)
{
throw;
}
catch (AggregateException ae) when (ae.InnerExceptions.All(e => e is OperationCanceledException))
{
throw new OperationCanceledException(ct);
}
if (errors.Count > 0)
{
return new DownloadResult(false, null, string.Join("; ", errors), false);
}
var markerPath = Path.Combine(incomingDirectory, ".download-complete");
try
{
var manifestSha256 = ComputeStringSha256(System.Text.Json.JsonSerializer.Serialize(manifest));
var markerContent = UpdatePaths.GetDownloadMarkerContent(manifestSha256, manifest.ToVersion, downloadableFiles.Count);
await File.WriteAllTextAsync(markerPath, markerContent, ct);
}
catch (Exception ex)
{
AppLogger.Warn("UpdateDownloadEngine", $"Failed to write download marker: {ex.Message}");
}
AppLogger.Info("UpdateDownloadEngine", $"Delta payload downloaded to {incomingDirectory}. {downloadableFiles.Count} objects processed.");
return new DownloadResult(true, incomingDirectory, null, true);
}
public async Task<DownloadResult> DownloadFullInstallerAsync(
UpdateManifest manifest,
string destinationPath,
int maxThreads,
IProgress<DownloadProgressReport>? progress,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(manifest);
if (manifest.InstallerMirrors is null || manifest.InstallerMirrors.Count == 0)
{
return new DownloadResult(false, null, "No installer mirrors available.", false);
}
var mirror = manifest.InstallerMirrors.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m.Url));
if (mirror is null || string.IsNullOrWhiteSpace(mirror.Url))
{
return new DownloadResult(false, null, "No usable installer mirror URL found.", false);
}
var dir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(dir))
{
Directory.CreateDirectory(dir);
}
if (File.Exists(destinationPath) && !string.IsNullOrWhiteSpace(mirror.Sha256))
{
var existingHash = await ComputeFileSha256Async(destinationPath, ct);
if (string.Equals(existingHash, mirror.Sha256, StringComparison.OrdinalIgnoreCase))
{
AppLogger.Info("UpdateDownloadEngine", "Full installer already downloaded with matching hash, skipping.");
return new DownloadResult(true, destinationPath, null, true);
}
}
var downloadProgress = progress is null ? null : new Progress<DownloadProgressInfo>(p =>
{
progress.Report(new DownloadProgressReport(
Path.GetFileName(destinationPath),
p.DownloadedBytes,
p.TotalBytes ?? 0,
0,
0,
1,
p.Progress));
});
for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++)
{
ct.ThrowIfCancellationRequested();
var result = await _downloadService.DownloadAsync(
mirror.Url,
destinationPath,
new DownloadOptions(MaxParallelSegments: Math.Max(1, maxThreads)),
downloadProgress,
ct);
if (result.Success)
{
bool hashVerified;
if (!string.IsNullOrWhiteSpace(mirror.Sha256))
{
var actualHash = await ComputeFileSha256Async(destinationPath, ct);
hashVerified = string.Equals(actualHash, mirror.Sha256, StringComparison.OrdinalIgnoreCase);
if (!hashVerified)
{
AppLogger.Warn("UpdateDownloadEngine",
$"Full installer hash mismatch. Expected: {mirror.Sha256}, Actual: {actualHash}");
}
}
else
{
hashVerified = false;
}
AppLogger.Info("UpdateDownloadEngine", $"Full installer downloaded to {destinationPath}");
return new DownloadResult(true, destinationPath, null, hashVerified);
}
if (attempt < MaxRetryAttempts)
{
AppLogger.Warn("UpdateDownloadEngine",
$"Full installer download attempt {attempt}/{MaxRetryAttempts} failed: {result.ErrorMessage}. Retrying.");
await Task.Delay(RetryDelayMs * attempt, ct);
}
else
{
return new DownloadResult(false, null, $"Failed to download full installer after {MaxRetryAttempts} attempts: {result.ErrorMessage}", false);
}
}
return new DownloadResult(false, null, "Failed to download full installer.", false);
}
private static string GetObjectDestinationPath(string objectsDirectory, string objectHashHex)
{
var normalized = objectHashHex.Trim().ToLowerInvariant();
var shard = normalized.Length >= 2 ? normalized[..2] : normalized;
return Path.Combine(objectsDirectory, shard, normalized);
}
private static void ReportProgress(
IProgress<DownloadProgressReport>? progress,
string currentFile,
long bytesDownloaded,
long bytesTotal,
int filesCompleted,
int filesTotal)
{
if (progress is null)
{
return;
}
var fraction = filesTotal > 0 ? (double)filesCompleted / filesTotal : 0;
progress.Report(new DownloadProgressReport(
currentFile,
bytesDownloaded,
bytesTotal,
0,
filesCompleted,
filesTotal,
fraction));
}
private async Task DownloadWithRetryAsync(string url, string destinationPath, CancellationToken ct)
{
Exception? lastError = null;
for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++)
{
ct.ThrowIfCancellationRequested();
var result = await _downloadService.DownloadAsync(url, destinationPath, cancellationToken: ct);
if (result.Success)
{
return;
}
lastError = new InvalidOperationException(result.ErrorMessage ?? "Download failed.");
if (attempt < MaxRetryAttempts)
{
AppLogger.Warn("UpdateDownloadEngine",
$"Download of {url} attempt {attempt}/{MaxRetryAttempts} failed. Retrying.");
await Task.Delay(RetryDelayMs * attempt, ct);
}
}
throw lastError!;
}
private static async Task<string> ComputeFileSha256Async(string filePath, CancellationToken ct)
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, true);
using var hasher = SHA256.Create();
var hash = await hasher.ComputeHashAsync(stream, ct);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string ComputeStringSha256(string content)
{
using var hasher = SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
var hash = hasher.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,159 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
public sealed record InstallResult(bool Success, string? ErrorMessage, bool UserCancelledElevation);
internal sealed class UpdateInstallGateway
{
public async Task<InstallResult> InstallAsync(
UpdatePayloadKind payloadKind,
string launcherRoot,
IProgress<InstallProgressReport>? progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
try
{
progress?.Report(new InstallProgressReport(
InstallStage.VerifySignature,
"Verifying payload...",
0,
null,
0,
0));
if (payloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy)
{
var launched = LaunchLauncherForApplyUpdate(launcherRoot);
if (!launched)
{
return new InstallResult(false, "Failed to launch Launcher for delta update application.", false);
}
progress?.Report(new InstallProgressReport(
InstallStage.ActivateDeployment,
"Launcher launched for apply-update.",
100,
null,
0,
0));
return new InstallResult(true, null, false);
}
var installerPath = FindPendingInstaller(launcherRoot);
if (installerPath is null)
{
return new InstallResult(false, "No pending installer found.", false);
}
var installerLaunched = LaunchFullInstaller(installerPath);
if (!installerLaunched.Success)
{
return installerLaunched;
}
progress?.Report(new InstallProgressReport(
InstallStage.ActivateDeployment,
"Full installer launched.",
100,
null,
0,
0));
return new InstallResult(true, null, false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
AppLogger.Warn("UpdateInstallGateway", $"Install failed: {ex.Message}");
return new InstallResult(false, ex.Message, false);
}
}
private bool LaunchLauncherForApplyUpdate(string launcherRoot)
{
try
{
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
AppLogger.Warn("UpdateInstallGateway", "Launcher executable not found. Falling back to next-startup apply.");
return false;
}
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source apply-update",
UseShellExecute = false,
WorkingDirectory = resolvedLauncherRoot
};
Process.Start(startInfo);
AppLogger.Info("UpdateInstallGateway", $"Launched Launcher for apply-update: {launcherPath}");
return true;
}
catch (Exception ex)
{
AppLogger.Warn("UpdateInstallGateway", $"Failed to launch Launcher for apply-update: {ex.Message}");
return false;
}
}
private InstallResult LaunchFullInstaller(string installerPath)
{
try
{
AppLogger.Info("UpdateInstallGateway", "Launching full installer with elevation.");
var workingDir = Path.GetDirectoryName(installerPath) ?? Path.GetDirectoryName(installerPath)!;
var startInfo = new ProcessStartInfo
{
FileName = installerPath,
WorkingDirectory = workingDir,
UseShellExecute = true,
Verb = OperatingSystem.IsWindows() ? "runas" : string.Empty,
Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"
};
Process.Start(startInfo);
return new InstallResult(true, null, false);
}
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
return new InstallResult(false, ex.Message, true);
}
catch (Exception ex)
{
AppLogger.Warn("UpdateInstallGateway", $"Failed to launch full installer: {ex.Message}");
return new InstallResult(false, ex.Message, false);
}
}
private static string? FindPendingInstaller(string launcherRoot)
{
var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot);
if (!Directory.Exists(incomingDir))
{
return null;
}
var executables = Directory.GetFiles(incomingDir, "*.exe");
return executables.Length > 0 ? executables[0] : null;
}
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(InstallProgressReport))]
[JsonSerializable(typeof(InstallCompleteReport))]
[JsonSerializable(typeof(InstallRequest))]
[JsonSerializable(typeof(LaunchResult))]
internal sealed partial class UpdateJsonContext : JsonSerializerContext;

View File

@@ -0,0 +1,220 @@
using System.Runtime.InteropServices;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
internal static class UpdateManifestMapper
{
public static UpdateManifest FromGitHubRelease(
GitHubReleaseInfo release,
PlondsUpdatePayload? plondsPayload,
string channel,
string platform)
{
if (plondsPayload is not null)
{
return FromPlondsPayload(plondsPayload, release, channel, platform);
}
return FromFullInstaller(release, channel, platform);
}
public static UpdateManifest FromPlondsPayload(
PlondsUpdatePayload payload,
GitHubReleaseInfo release,
string channel,
string platform)
{
var files = new List<UpdateFileEntry>();
if (payload.UpdateArchiveUrl is not null)
{
files.Add(new UpdateFileEntry(
Path: "update.zip",
Action: "add",
Sha256: payload.UpdateArchiveSha256 ?? string.Empty,
Size: payload.UpdateArchiveSizeBytes ?? 0,
Mode: "compressed-object",
ObjectKey: null,
ObjectUrl: payload.UpdateArchiveUrl,
ArchiveSha256: null,
Metadata: null));
}
var mirrors = release.Assets
.Where(IsInstallerAsset)
.Select(a => new UpdateMirrorAsset(
Platform: platform,
Url: a.BrowserDownloadUrl,
Name: a.Name,
Sha256: a.Sha256,
Size: a.SizeBytes))
.ToArray();
var metadata = new Dictionary<string, string>
{
["source"] = "github-plonds",
["releaseTag"] = release.TagName
};
return new UpdateManifest(
DistributionId: payload.DistributionId,
FromVersion: string.Empty,
ToVersion: NormalizeTagVersion(release.TagName),
Platform: platform,
Channel: channel,
PublishedAt: release.PublishedAt,
Kind: UpdatePayloadKind.DeltaPlonds,
FileMapUrl: payload.FileMapJsonUrl,
FileMapSignatureUrl: payload.FileMapSignatureUrl,
FileMapSha256: null,
Files: files,
InstallerMirrors: mirrors,
Metadata: metadata);
}
public static UpdateManifest FromFullInstaller(
GitHubReleaseInfo release,
string channel,
string platform)
{
var installerAsset = SelectPreferredInstallerAsset(release.Assets);
var files = new List<UpdateFileEntry>();
var mirrors = new List<UpdateMirrorAsset>();
if (installerAsset is not null)
{
files.Add(new UpdateFileEntry(
Path: installerAsset.Name,
Action: "add",
Sha256: installerAsset.Sha256 ?? string.Empty,
Size: installerAsset.SizeBytes,
Mode: "file-object",
ObjectKey: null,
ObjectUrl: installerAsset.BrowserDownloadUrl,
ArchiveSha256: null,
Metadata: null));
foreach (var asset in release.Assets)
{
if (IsInstallerAsset(asset) && asset != installerAsset)
{
mirrors.Add(new UpdateMirrorAsset(
Platform: platform,
Url: asset.BrowserDownloadUrl,
Name: asset.Name,
Sha256: asset.Sha256,
Size: asset.SizeBytes));
}
}
}
var distributionId = $"github-{release.TagName.Trim().TrimStart('v')}-{platform}";
var metadata = new Dictionary<string, string>
{
["source"] = "github-release",
["releaseTag"] = release.TagName
};
return new UpdateManifest(
DistributionId: distributionId,
FromVersion: string.Empty,
ToVersion: NormalizeTagVersion(release.TagName),
Platform: platform,
Channel: channel,
PublishedAt: release.PublishedAt,
Kind: UpdatePayloadKind.FullInstaller,
FileMapUrl: null,
FileMapSignatureUrl: null,
FileMapSha256: null,
Files: files,
InstallerMirrors: mirrors,
Metadata: metadata);
}
private static string NormalizeTagVersion(string tagName)
{
var v = tagName.Trim();
if (v.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
v = v[1..];
}
return v;
}
private static bool IsInstallerAsset(GitHubReleaseAsset asset)
{
var name = asset.Name;
return name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)
|| name.EndsWith(".msi", StringComparison.OrdinalIgnoreCase)
|| name.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase)
|| name.EndsWith(".deb", StringComparison.OrdinalIgnoreCase)
|| name.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase)
|| name.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase);
}
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
{
if (assets is null || assets.Count == 0)
{
return null;
}
var architectureToken = RuntimeInformation.OSArchitecture switch
{
Architecture.Arm64 => "arm64",
Architecture.X86 => "x86",
_ => "x64"
};
if (OperatingSystem.IsWindows())
{
return assets
.Where(a => a.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)
|| a.Name.EndsWith(".msi", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(a => ScoreAsset(a.Name, architectureToken))
.FirstOrDefault();
}
if (OperatingSystem.IsLinux())
{
return assets
.Where(a => a.Name.EndsWith(".deb", StringComparison.OrdinalIgnoreCase)
|| a.Name.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase)
|| a.Name.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(a => ScoreAsset(a.Name, architectureToken))
.FirstOrDefault();
}
if (OperatingSystem.IsMacOS())
{
return assets
.Where(a => a.Name.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase)
|| a.Name.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(a => ScoreAsset(a.Name, architectureToken))
.FirstOrDefault();
}
return null;
}
private static int ScoreAsset(string name, string archToken)
{
var score = 0;
if (name.Contains(archToken, StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
if (name.Contains("setup", StringComparison.OrdinalIgnoreCase)
|| name.Contains("installer", StringComparison.OrdinalIgnoreCase))
{
score += 20;
}
return score;
}
}

View File

@@ -0,0 +1,483 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.Contracts.Update;
using SettingsUpdateSettingsState = LanMountainDesktop.Services.Settings.UpdateSettingsState;
namespace LanMountainDesktop.Services.Update;
public sealed class UpdateOrchestrator : IDisposable
{
private readonly IUpdateManifestProvider _manifestProvider;
private readonly UpdateDownloadEngine _downloadEngine;
private readonly UpdateInstallGateway _installGateway;
private readonly UpdateStateStore _stateStore;
private readonly SemaphoreSlim _operationGate = new(1, 1);
private bool _disposed;
internal UpdateOrchestrator(
IUpdateManifestProvider manifestProvider,
UpdateDownloadEngine downloadEngine,
UpdateInstallGateway installGateway,
UpdateStateStore stateStore)
{
_manifestProvider = manifestProvider ?? throw new ArgumentNullException(nameof(manifestProvider));
_downloadEngine = downloadEngine ?? throw new ArgumentNullException(nameof(downloadEngine));
_installGateway = installGateway ?? throw new ArgumentNullException(nameof(installGateway));
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
_stateStore.PhaseChanged += OnPhaseChanged;
_stateStore.ProgressChanged += OnProgressChanged;
}
public UpdatePhase CurrentPhase => _stateStore.CurrentPhase;
public UpdateManifest? CurrentManifest => _stateStore.PendingManifest;
public event Action<UpdatePhase>? PhaseChanged;
public event Action<UpdateProgressReport>? ProgressChanged;
public async Task<UpdateCheckReport> CheckAsync(CancellationToken ct)
{
await _operationGate.WaitAsync(ct);
try
{
if (!CurrentPhase.CanCheck())
{
return new UpdateCheckReport(
false, null, null, null, null, null, null, null, null,
$"Cannot check in phase {CurrentPhase}.");
}
_stateStore.TransitionTo(UpdatePhase.Checking);
var settings = _stateStore.GetSettings();
var channel = UpdateSettingsValues.NormalizeChannel(settings.UpdateChannel);
var currentVersionText = _stateStore.GetSettings().PendingUpdateVersion
?? AppVersionProvider.ResolveForCurrentProcess().Version;
if (!Version.TryParse(currentVersionText, out var currentVersion))
{
currentVersion = new Version(0, 0, 0);
}
UpdateManifest? manifest;
try
{
manifest = await _manifestProvider.GetLatestAsync(
channel,
"win-x64",
currentVersion,
ct);
}
catch (OperationCanceledException)
{
_stateStore.TransitionTo(UpdatePhase.Idle);
throw;
}
catch (Exception ex)
{
_stateStore.TransitionTo(UpdatePhase.Failed);
_stateStore.RecordFailure(ex.Message);
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, ex.Message);
}
if (manifest is null)
{
_stateStore.TransitionTo(UpdatePhase.Checked);
return new UpdateCheckReport(
false, null, currentVersionText, null, null, null, null, null, null, null);
}
_stateStore.PendingManifest = manifest;
_stateStore.TransitionTo(UpdatePhase.Checked);
long? totalBytes = manifest.IsDelta ? manifest.EstimatedDeltaBytes : null;
long? installerBytes = manifest.InstallerMirrors?.Count > 0
? manifest.InstallerMirrors[0].Size
: null;
return new UpdateCheckReport(
true,
manifest.ToVersion,
currentVersionText,
manifest.Kind,
manifest.DistributionId,
manifest.Channel,
manifest.PublishedAt,
totalBytes,
installerBytes,
null);
}
finally
{
_operationGate.Release();
}
}
public async Task<DownloadResult> DownloadAsync(CancellationToken ct)
{
await _operationGate.WaitAsync(ct);
try
{
if (!CurrentPhase.CanDownload())
{
return new DownloadResult(false, null, $"Cannot download in phase {CurrentPhase}.", false);
}
var manifest = _stateStore.PendingManifest;
if (manifest is null)
{
return new DownloadResult(false, null, "No manifest available for download.", false);
}
_stateStore.TransitionTo(UpdatePhase.Downloading);
var settings = _stateStore.GetSettings();
var maxThreads = UpdateSettingsValues.NormalizeDownloadThreads(settings.UpdateDownloadThreads);
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
var downloadProgress = new Progress<DownloadProgressReport>(p =>
{
var overallFraction = manifest.IsDelta
? (double)p.FilesCompleted / Math.Max(1, p.FilesTotal)
: p.OverallFraction;
ProgressChanged?.Invoke(new UpdateProgressReport(
UpdatePhase.Downloading,
$"Downloading {p.CurrentFile}",
overallFraction,
p,
null));
});
try
{
DownloadResult result;
if (manifest.IsDelta)
{
var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot);
var objectsDir = UpdatePaths.GetObjectsDirectory(launcherRoot);
result = await _downloadEngine.DownloadPayloadAsync(
manifest,
incomingDir,
objectsDir,
maxThreads,
downloadProgress,
ct);
}
else
{
var fileName = $"{manifest.DistributionId}-{manifest.ToVersion}-installer.exe";
var destinationPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Updates",
fileName);
result = await _downloadEngine.DownloadFullInstallerAsync(
manifest,
destinationPath,
maxThreads,
downloadProgress,
ct);
}
if (result.Success)
{
_stateStore.TransitionTo(UpdatePhase.Downloaded);
var state = _stateStore.GetSettings();
_stateStore.SaveSettings(state with
{
PendingUpdateInstallerPath = result.FilePath,
PendingUpdateVersion = manifest.ToVersion,
PendingUpdatePublishedAtUtcMs = manifest.PublishedAt.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = null
});
AppLogger.Info("UpdateOrchestrator", $"Update downloaded successfully: {manifest.ToVersion}");
}
else
{
_stateStore.TransitionTo(UpdatePhase.Failed);
_stateStore.RecordFailure(result.ErrorMessage ?? "Download failed");
}
return result;
}
catch (OperationCanceledException)
{
_stateStore.TransitionTo(UpdatePhase.Idle);
throw;
}
catch (Exception ex)
{
_stateStore.TransitionTo(UpdatePhase.Failed);
_stateStore.RecordFailure(ex.Message);
return new DownloadResult(false, null, ex.Message, false);
}
}
finally
{
_operationGate.Release();
}
}
public async Task<InstallResult> InstallAsync(CancellationToken ct)
{
await _operationGate.WaitAsync(ct);
try
{
if (!CurrentPhase.CanInstall())
{
return new InstallResult(false, $"Cannot install in phase {CurrentPhase}.", false);
}
var manifest = _stateStore.PendingManifest;
if (manifest is null)
{
return new InstallResult(false, "No manifest available for install.", false);
}
_stateStore.TransitionTo(UpdatePhase.Installing);
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
var installProgress = new Progress<InstallProgressReport>(p =>
{
var fraction = p.FilesTotal > 0 ? (double)p.FilesCompleted / p.FilesTotal : p.ProgressPercent / 100.0;
ProgressChanged?.Invoke(new UpdateProgressReport(
UpdatePhase.Installing,
p.Message,
fraction,
null,
p));
});
try
{
var result = await _installGateway.InstallAsync(
manifest.Kind,
launcherRoot,
installProgress,
ct);
if (result.Success)
{
_stateStore.TransitionTo(UpdatePhase.Installed);
_stateStore.RecordSuccess(manifest.ToVersion);
AppLogger.Info("UpdateOrchestrator", $"Update install initiated: {manifest.ToVersion}");
}
else
{
_stateStore.TransitionTo(UpdatePhase.Failed);
_stateStore.RecordFailure(result.ErrorMessage ?? "Install failed");
}
return result;
}
catch (OperationCanceledException)
{
_stateStore.TransitionTo(UpdatePhase.Failed);
throw;
}
catch (Exception ex)
{
_stateStore.TransitionTo(UpdatePhase.Failed);
_stateStore.RecordFailure(ex.Message);
return new InstallResult(false, ex.Message, false);
}
}
finally
{
_operationGate.Release();
}
}
public async Task RollbackAsync(CancellationToken ct)
{
await _operationGate.WaitAsync(ct);
try
{
if (!CurrentPhase.CanRollback())
{
return;
}
_stateStore.TransitionTo(UpdatePhase.RollingBack);
try
{
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (!string.IsNullOrWhiteSpace(launcherPath) && File.Exists(launcherPath))
{
var launcherRoot = Path.GetDirectoryName(launcherPath)!;
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = launcherPath,
Arguments = $"rollback --app-root \"{launcherRoot}\"",
UseShellExecute = false,
WorkingDirectory = launcherRoot
};
System.Diagnostics.Process.Start(startInfo);
AppLogger.Info("UpdateOrchestrator", "Launched Launcher for rollback.");
}
_stateStore.TransitionTo(UpdatePhase.RolledBack);
}
catch (Exception ex)
{
AppLogger.Warn("UpdateOrchestrator", $"Rollback failed: {ex.Message}");
_stateStore.TransitionTo(UpdatePhase.Failed);
}
}
finally
{
_operationGate.Release();
}
}
public async Task CancelAsync()
{
if (!CurrentPhase.IsBusy())
{
return;
}
_stateStore.TransitionTo(UpdatePhase.Idle);
_stateStore.PendingManifest = null;
AppLogger.Info("UpdateOrchestrator", "Update operation cancelled.");
await Task.CompletedTask;
}
public async Task AutoCheckIfEnabledAsync(CancellationToken ct)
{
var settings = _stateStore.GetSettings();
var mode = UpdateSettingsValues.NormalizeMode(settings.UpdateMode);
if (string.Equals(mode, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase))
{
return;
}
try
{
await CheckAsync(ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
AppLogger.Warn("UpdateOrchestrator", "Automatic update check failed.", ex);
}
}
public bool TryApplyOnExit()
{
var settings = _stateStore.GetSettings();
var mode = UpdateSettingsValues.NormalizeMode(settings.UpdateMode);
if (!string.Equals(mode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var manifest = _stateStore.PendingManifest;
if (manifest is null)
{
return false;
}
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
if (manifest.IsDelta)
{
AppLogger.Info("UpdateOrchestrator", "Delta update pending. Launching Launcher to apply on exit.");
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
return false;
}
try
{
var resolvedRoot = Path.GetDirectoryName(launcherPath)!;
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = launcherPath,
Arguments = $"apply-update --app-root \"{resolvedRoot}\" --launch-source apply-update",
UseShellExecute = false,
WorkingDirectory = resolvedRoot
};
System.Diagnostics.Process.Start(startInfo);
return true;
}
catch (Exception ex)
{
AppLogger.Warn("UpdateOrchestrator", $"Failed to launch Launcher on exit: {ex.Message}");
return false;
}
}
var installerPath = settings.PendingUpdateInstallerPath?.Trim();
if (string.IsNullOrWhiteSpace(installerPath) || !File.Exists(installerPath))
{
return false;
}
try
{
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = installerPath,
WorkingDirectory = Path.GetDirectoryName(installerPath)!,
UseShellExecute = true,
Verb = System.OperatingSystem.IsWindows() ? "runas" : string.Empty,
Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"
};
System.Diagnostics.Process.Start(startInfo);
return true;
}
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
return false;
}
catch (Exception ex)
{
AppLogger.Warn("UpdateOrchestrator", $"Failed to launch installer on exit: {ex.Message}");
return false;
}
}
private void OnPhaseChanged(UpdatePhase phase)
{
PhaseChanged?.Invoke(phase);
}
private void OnProgressChanged(UpdateProgressReport report)
{
ProgressChanged?.Invoke(report);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_stateStore.PhaseChanged -= OnPhaseChanged;
_stateStore.ProgressChanged -= OnProgressChanged;
_operationGate.Dispose();
}
}

View File

@@ -0,0 +1,105 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class UpdateProgressSubject : IObservable<InstallProgressReport>, IObserver<InstallProgressReport>
{
private readonly List<IObserver<InstallProgressReport>> _observers = [];
private readonly object _gate = new();
private bool _completed;
public IDisposable Subscribe(IObserver<InstallProgressReport> observer)
{
lock (_gate)
{
if (_completed)
{
observer.OnCompleted();
return EmptyDisposable.Instance;
}
_observers.Add(observer);
}
return new Subscription(this, observer);
}
public void OnNext(InstallProgressReport value)
{
IObserver<InstallProgressReport>[] snapshot;
lock (_gate)
{
snapshot = _observers.ToArray();
}
foreach (var observer in snapshot)
{
observer.OnNext(value);
}
}
public void OnError(Exception error)
{
IObserver<InstallProgressReport>[] snapshot;
lock (_gate)
{
_completed = true;
snapshot = _observers.ToArray();
_observers.Clear();
}
foreach (var observer in snapshot)
{
observer.OnError(error);
}
}
public void OnCompleted()
{
IObserver<InstallProgressReport>[] snapshot;
lock (_gate)
{
_completed = true;
snapshot = _observers.ToArray();
_observers.Clear();
}
foreach (var observer in snapshot)
{
observer.OnCompleted();
}
}
private sealed class Subscription : IDisposable
{
private readonly UpdateProgressSubject _subject;
private IObserver<InstallProgressReport>? _observer;
public Subscription(UpdateProgressSubject subject, IObserver<InstallProgressReport> observer)
{
_subject = subject;
_observer = observer;
}
public void Dispose()
{
if (_observer is null)
{
return;
}
lock (_subject._gate)
{
_subject._observers.Remove(_observer);
}
_observer = null;
}
}
private sealed class EmptyDisposable : IDisposable
{
public static readonly EmptyDisposable Instance = new();
public void Dispose() { }
}
}

View File

@@ -0,0 +1,79 @@
using System;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Shared.Contracts.Update;
using SettingsUpdateSettingsState = LanMountainDesktop.Services.Settings.UpdateSettingsState;
namespace LanMountainDesktop.Services.Update;
internal sealed class UpdateStateStore
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly object _sync = new();
private const int AutoDowngradeThreshold = 3;
private int _consecutiveFailCount;
public UpdateStateStore(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
CurrentPhase = UpdatePhase.Idle;
}
public UpdatePhase CurrentPhase { get; private set; }
public event Action<UpdatePhase>? PhaseChanged;
public event Action<UpdateProgressReport>? ProgressChanged;
public void TransitionTo(UpdatePhase newPhase)
{
lock (_sync)
{
if (CurrentPhase == newPhase)
{
return;
}
CurrentPhase = newPhase;
}
PhaseChanged?.Invoke(newPhase);
ProgressChanged?.Invoke(new UpdateProgressReport(
newPhase,
$"Phase changed to {newPhase}",
0,
null,
null));
}
public SettingsUpdateSettingsState GetSettings()
{
return _settingsFacade.Update.Get();
}
public void SaveSettings(SettingsUpdateSettingsState state)
{
_settingsFacade.Update.Save(state);
}
public UpdateManifest? PendingManifest { get; set; }
public void RecordFailure(string errorMessage)
{
Interlocked.Increment(ref _consecutiveFailCount);
AppLogger.Warn("UpdateStateStore", $"Update failure recorded (consecutive: {_consecutiveFailCount}): {errorMessage}");
}
public void RecordSuccess(string appliedVersion)
{
Interlocked.Exchange(ref _consecutiveFailCount, 0);
var state = GetSettings();
SaveSettings(state with
{
PendingUpdateVersion = appliedVersion,
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
}
public bool ShouldAutoDowngrade => Volatile.Read(ref _consecutiveFailCount) >= AutoDowngradeThreshold;
}

View File

@@ -12,11 +12,12 @@ public static class UpdateSettingsValues
public const string ModeSilentOnExit = "silent_on_exit";
// NOTE: keep constant name for compatibility with existing call sites.
public const string DownloadSourcePlonds = "stcn";
public const string DownloadSourcePlonds = "plonds-api";
public const string DownloadSourcePdc = DownloadSourcePlonds;
public const string DownloadSourceStcn = DownloadSourcePlonds;
public const string LegacyDownloadSourcePlonds = "pdc";
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
public const string LegacyDownloadSourceStcn = "stcn";
public const string DownloadSourceGitHub = "github";
public const string DownloadSourceGhProxy = "gh-proxy";
@@ -59,7 +60,12 @@ public static class UpdateSettingsValues
{
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceStcn;
return DownloadSourcePlonds;
}
if (string.Equals(value, LegacyDownloadSourceStcn, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePlonds;
}
if (string.Equals(value, DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
@@ -77,8 +83,7 @@ public static class UpdateSettingsValues
return DownloadSourceGitHub;
}
// Default to STCN(PLONDS/S3). Runtime will fallback to GitHub if STCN is unavailable.
return DownloadSourceStcn;
return DownloadSourcePlonds;
}
public static int NormalizeDownloadThreads(int value)

View File

@@ -171,7 +171,9 @@ public sealed class UpdateWorkflowService
}
var state = _settingsFacade.Update.Get();
var downloadSource = state.UpdateDownloadSource;
var downloadSource = state.UseGhProxyMirror
? UpdateSettingsValues.DownloadSourceGhProxy
: UpdateSettingsValues.DownloadSourceGitHub;
var downloadThreads = state.UpdateDownloadThreads;
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
@@ -312,7 +314,9 @@ public sealed class UpdateWorkflowService
payload,
incomingDir,
objectsDir,
state.UpdateDownloadSource,
state.UseGhProxyMirror
? UpdateSettingsValues.DownloadSourceGhProxy
: UpdateSettingsValues.DownloadSourceGitHub,
downloadThreads,
progress,
cancellationToken);
@@ -502,7 +506,9 @@ public sealed class UpdateWorkflowService
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UseGhProxyMirror
? UpdateSettingsValues.DownloadSourceGhProxy
: UpdateSettingsValues.DownloadSourceGitHub,
state.UpdateDownloadThreads,
progress,
cancellationToken);

View File

@@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Media;
@@ -1609,8 +1610,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
public IReadOnlyList<SelectionOption> UpdateChannelOptions { get; }
public IReadOnlyList<SelectionOption> UpdateSourceOptions { get; }
public IReadOnlyList<SelectionOption> UpdateModeOptions { get; }
public IReadOnlyList<SelectionOption> DownloadThreadOptions { get; }
@@ -1624,7 +1623,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
RefreshLocalizedText();
UpdateChannelOptions = CreateUpdateChannelOptions();
UpdateSourceOptions = CreateUpdateSourceOptions();
UpdateModeOptions = CreateUpdateModeOptions();
DownloadThreadOptions = CreateDownloadThreadOptions();
@@ -1640,9 +1638,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
[ObservableProperty]
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
[ObservableProperty]
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
@@ -1667,6 +1662,18 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _downloadProgressText = string.Empty;
[ObservableProperty]
private string _updatePhaseText = string.Empty;
[ObservableProperty]
private double _phaseProgressValue;
[ObservableProperty]
private string _updateTypeText = string.Empty;
[ObservableProperty]
private bool _useGhProxyMirror;
[ObservableProperty]
private string _pageTitle = string.Empty;
@@ -1688,9 +1695,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _updateChannelLabel = string.Empty;
[ObservableProperty]
private string _updateSourceLabel = string.Empty;
[ObservableProperty]
private string _updateModeLabel = string.Empty;
@@ -1754,9 +1758,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _selectedUpdateModeDescription = string.Empty;
[ObservableProperty]
private string _selectedUpdateSourceDescription = string.Empty;
[ObservableProperty]
private string _downloadThreadsLabel = string.Empty;
@@ -1769,21 +1770,24 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _forceCheckUpdateDescription = string.Empty;
[ObservableProperty]
private string _forceFullUpdateLabel = string.Empty;
[ObservableProperty]
private string _forceFullUpdateDescription = string.Empty;
[ObservableProperty]
private string _networkAccelerationLabel = string.Empty;
[ObservableProperty]
private string _networkAccelerationDescription = string.Empty;
[ObservableProperty]
private string _stableChannelText = string.Empty;
[ObservableProperty]
private string _previewChannelText = string.Empty;
[ObservableProperty]
private string _pdcSourceText = string.Empty;
[ObservableProperty]
private string _gitHubSourceText = string.Empty;
[ObservableProperty]
private string _ghProxySourceText = string.Empty;
[ObservableProperty]
private string _manualModeText = string.Empty;
@@ -1796,9 +1800,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private SelectionOption? _selectedUpdateChannelOption;
[ObservableProperty]
private SelectionOption? _selectedUpdateSourceOption;
[ObservableProperty]
private SelectionOption? _selectedUpdateModeOption;
@@ -1814,15 +1815,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
public bool IsPreviewChannelSelected =>
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
public bool IsPdcSourceSelected =>
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase);
public bool IsGitHubSourceSelected =>
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
public bool IsGhProxySourceSelected =>
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase);
public bool IsManualModeSelected =>
string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase);
@@ -1840,6 +1832,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading;
public bool IsUpdateTypeVisible => !string.IsNullOrEmpty(UpdateTypeText) && !HasPendingInstaller;
public string DownloadThreadsValueText =>
UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
@@ -1854,15 +1848,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
}
}
partial void OnSelectedUpdateSourceOptionChanged(SelectionOption? value)
{
if (value is not null &&
!string.Equals(SelectedUpdateSourceValue, value.Value, StringComparison.OrdinalIgnoreCase))
{
SelectedUpdateSourceValue = value.Value;
}
}
partial void OnSelectedUpdateModeOptionChanged(SelectionOption? value)
{
if (value is not null &&
@@ -1910,19 +1895,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
RefreshActionState();
}
partial void OnSelectedUpdateSourceValueChanged(string value)
{
if (_isInitializing)
{
return;
}
SaveUpdateSettings();
SelectedUpdateSourceDescription = BuildUpdateSourceDescription(value);
UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved.");
SyncSelectedOptions();
}
partial void OnSelectedUpdateModeValueChanged(string value)
{
if (_isInitializing)
@@ -1988,6 +1960,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
CheckForUpdatesCommand.NotifyCanExecuteChanged();
DownloadLatestReleaseCommand.NotifyCanExecuteChanged();
InstallPendingUpdateCommand.NotifyCanExecuteChanged();
ForceFullUpdateCommand.NotifyCanExecuteChanged();
}
partial void OnIsDownloadingChanged(bool value)
@@ -1995,6 +1968,18 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
CheckForUpdatesCommand.NotifyCanExecuteChanged();
DownloadLatestReleaseCommand.NotifyCanExecuteChanged();
InstallPendingUpdateCommand.NotifyCanExecuteChanged();
ForceFullUpdateCommand.NotifyCanExecuteChanged();
}
partial void OnUseGhProxyMirrorChanged(bool value)
{
if (_isInitializing)
{
return;
}
SaveUpdateSettings();
UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved.");
}
[RelayCommand]
@@ -2009,24 +1994,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
}
[RelayCommand]
private void SelectPdcSource()
{
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
}
[RelayCommand]
private void SelectGitHubSource()
{
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub;
}
[RelayCommand]
private void SelectGhProxySource()
{
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGhProxy;
}
[RelayCommand]
private void SelectManualMode()
{
@@ -2056,7 +2023,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
StringComparison.OrdinalIgnoreCase),
UpdateChannel = SelectedUpdateChannelValue,
UpdateMode = SelectedUpdateModeValue,
UpdateDownloadSource = SelectedUpdateSourceValue,
UseGhProxyMirror = UseGhProxyMirror,
UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue))
});
}
@@ -2077,6 +2044,86 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
await CheckForUpdatesCoreAsync(isForce: true);
}
private bool CanForceFullUpdate() => !IsBusy;
[RelayCommand(CanExecute = nameof(CanForceFullUpdate))]
private async Task ForceFullUpdateAsync()
{
try
{
IsCheckingForUpdates = true;
IsDownloadProgressVisible = true;
UpdatePhaseText = L("settings.update.phase_force_full", "Forcing full update...");
PhaseProgressValue = 0;
DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdateStatus = L("settings.update.status_force_full_checking", "Checking for full installer...");
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce: true);
_lastCheckResult = result.Success ? result : null;
if (!result.Success || result.PreferredAsset is null)
{
UpdateStatus = L("settings.update.status_force_full_failed", "No full installer available.");
return;
}
UpdateTypeText = L("settings.update.type_full", "Full Update");
await DownloadFullInstallerCoreAsync(result);
}
finally
{
IsCheckingForUpdates = false;
IsDownloadProgressVisible = false;
}
}
private async Task DownloadFullInstallerCoreAsync(UpdateCheckResult result)
{
try
{
IsDownloading = true;
IsDownloadProgressVisible = true;
UpdatePhaseText = L("settings.update.phase_downloading_full", "Downloading full installer...");
DownloadProgressValue = 0;
PhaseProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdateStatus = L("settings.update.status_downloading_full", "Downloading full installer...");
var progress = new Progress<double>(value =>
{
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
PhaseProgressValue = DownloadProgressValue;
DownloadProgressText = string.Format(
CultureInfo.CurrentCulture,
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
DownloadProgressValue);
});
var downloadResult = await _updateWorkflowService.DownloadReleaseAsync(result, progress, CancellationToken.None);
if (!downloadResult.Success)
{
UpdateStatus = string.Format(
CultureInfo.CurrentCulture,
L("settings.update.status_download_failed_format", "Download failed: {0}"),
downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates."));
return;
}
ApplyPendingState(_settingsFacade.Update.Get());
UpdateStatus = downloadResult.HashVerified
? BuildPendingReadyStatus()
: string.Format(
CultureInfo.CurrentCulture,
L("settings.update.status_downloaded_no_hash_format", "Update downloaded. Hash: {0}"),
downloadResult.ActualHash ?? "N/A");
}
finally
{
IsDownloading = false;
}
}
private async Task CheckForUpdatesCoreAsync(bool isForce)
{
try
@@ -2085,6 +2132,10 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
IsDownloadProgressVisible = false;
DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdatePhaseText = isForce
? L("settings.update.phase_force_scanning", "Force scanning update source...")
: L("settings.update.phase_scanning", "Scanning update source...");
PhaseProgressValue = 0;
UpdateStatus = isForce
? L("settings.update.status_force_checking", "Force checking update source...")
: L("settings.update.status_checking", "Checking update source...");
@@ -2093,6 +2144,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
_lastCheckResult = result.Success ? result : null;
RefreshLastCheckedFromSettings();
UpdatePhaseText = L("settings.update.phase_locating_resources", "Locating update resources...");
PhaseProgressValue = 10;
if (!result.Success)
{
UpdateStatus = string.IsNullOrWhiteSpace(result.ErrorMessage)
@@ -2105,6 +2159,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
}
ApplyCheckResultDisplay(result);
UpdateTypeText = UpdateWorkflowService.IsDeltaUpdateAvailable(result)
? L("settings.update.type_delta", "Incremental Update")
: L("settings.update.type_full", "Full Update");
if (!result.IsUpdateAvailable && !isForce)
{
return;
@@ -2255,12 +2312,15 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
PreferencesHeader = L("settings.update.preferences_header", "Update Preferences");
PreferencesDescription = L("settings.update.preferences_description", "Choose your release channel, download source, behavior, and download speed.");
UpdateChannelLabel = L("settings.update.channel_label", "Update Channel");
UpdateSourceLabel = L("settings.update.source_label", "Download Source");
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison.");
ForceFullUpdateLabel = L("settings.update.force_full_label", "Force Full Update");
ForceFullUpdateDescription = L("settings.update.force_full_desc", "Skip incremental update and force download the full installer. Use this if incremental update fails repeatedly.");
NetworkAccelerationLabel = L("settings.update.network_accel_label", "Network Acceleration");
NetworkAccelerationDescription = L("settings.update.network_accel_desc", "Use gh-proxy mirror to accelerate GitHub downloads. Only applies when falling back to GitHub for full updates.");
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
@@ -2272,15 +2332,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
StableChannelText = L("settings.update.channel_stable", "Stable");
PreviewChannelText = L("settings.update.channel_preview", "Preview");
PdcSourceText = L("settings.update.source_pdc", "PDC");
GitHubSourceText = L("settings.update.source_github", "GitHub");
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
ManualModeText = L("settings.update.mode_manual", "Manual Update");
DownloadThenConfirmModeText = L("settings.update.mode_download_then_confirm", "Silent Download");
SilentOnExitModeText = L("settings.update.mode_silent_on_exit", "Silent Install");
SelectedUpdateChannelDescription = BuildUpdateChannelDescription(SelectedUpdateChannelValue);
SelectedUpdateModeDescription = BuildUpdateModeDescription(SelectedUpdateModeValue);
SelectedUpdateSourceDescription = BuildUpdateSourceDescription(SelectedUpdateSourceValue);
}
private void LoadStateFromSettings()
@@ -2288,7 +2344,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
var update = _settingsFacade.Update.Get();
_isInitializing = true;
SelectedUpdateChannelValue = UpdateSettingsValues.NormalizeChannel(update.UpdateChannel, update.IncludePrereleaseUpdates);
SelectedUpdateSourceValue = UpdateSettingsValues.NormalizeDownloadSource(update.UpdateDownloadSource);
UseGhProxyMirror = update.UseGhProxyMirror;
SelectedUpdateModeValue = UpdateSettingsValues.NormalizeMode(update.UpdateMode);
DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(update.UpdateDownloadThreads);
DownloadThreadsText = ((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
@@ -2368,10 +2424,14 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
IsDownloadProgressVisible = true;
DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdatePhaseText = UpdateWorkflowService.IsDeltaUpdateAvailable(result)
? L("settings.update.phase_downloading_delta", "Downloading incremental update...")
: L("settings.update.phase_downloading_full", "Downloading full installer...");
var progress = new Progress<double>(value =>
{
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
PhaseProgressValue = DownloadProgressValue;
DownloadProgressText = string.Format(
CultureInfo.CurrentCulture,
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
@@ -2466,22 +2526,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
};
}
private string BuildUpdateSourceDescription(string? value)
{
return UpdateSettingsValues.NormalizeDownloadSource(value) switch
{
UpdateSettingsValues.DownloadSourcePdc => L(
"settings.update.source_pdc_desc",
"Prefer PDC metadata and distribution endpoints, then automatically fallback to GitHub."),
UpdateSettingsValues.DownloadSourceGhProxy => L(
"settings.update.source_ghproxy_desc",
"Use the gh-proxy mirror when downloading GitHub release assets."),
_ => L(
"settings.update.source_github_desc",
"Download release assets directly from GitHub.")
};
}
private string FormatTimestamp(long? utcMs)
{
if (utcMs is not > 0)
@@ -2509,6 +2553,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
OnPropertyChanged(nameof(IsRedownloadButtonVisible));
OnPropertyChanged(nameof(DownloadThreadsValueText));
RedownloadUpdateCommand.NotifyCanExecuteChanged();
ForceFullUpdateCommand.NotifyCanExecuteChanged();
}
private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions()
@@ -2520,16 +2565,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
];
}
private IReadOnlyList<SelectionOption> CreateUpdateSourceOptions()
{
return
[
new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText),
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
];
}
private IReadOnlyList<SelectionOption> CreateUpdateModeOptions()
{
return
@@ -2554,8 +2589,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
SelectedUpdateChannelOption = UpdateChannelOptions.FirstOrDefault(option =>
string.Equals(option.Value, SelectedUpdateChannelValue, StringComparison.OrdinalIgnoreCase));
SelectedUpdateSourceOption = UpdateSourceOptions.FirstOrDefault(option =>
string.Equals(option.Value, SelectedUpdateSourceValue, StringComparison.OrdinalIgnoreCase));
SelectedUpdateModeOption = UpdateModeOptions.FirstOrDefault(option =>
string.Equals(option.Value, SelectedUpdateModeValue, StringComparison.OrdinalIgnoreCase));
SelectedDownloadThreadsOption = DownloadThreadOptions.FirstOrDefault(option =>

View File

@@ -0,0 +1,94 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LanMountainDesktop.Services.Update;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.ViewModels;
public sealed partial class UpdateProgressViewModel : ViewModelBase, IDisposable
{
private readonly IDisposable _subscription;
private readonly CancellationTokenSource _cts = new();
private bool _disposed;
public UpdateProgressViewModel(IObservable<InstallProgressReport> progressStream)
{
_subscription = progressStream.Subscribe(new ActionObserver<InstallProgressReport>(OnNext));
}
[ObservableProperty] private string _stageText = string.Empty;
[ObservableProperty] private double _progressFraction;
[ObservableProperty] private string _currentFile = string.Empty;
[ObservableProperty] private int _filesCompleted;
[ObservableProperty] private int _filesTotal;
[ObservableProperty] private bool _isCompleted;
[ObservableProperty] private bool _isSuccess;
[ObservableProperty] private string _errorMessage = string.Empty;
public int ProgressPercent => (int)Math.Clamp(ProgressFraction * 100, 0, 100);
partial void OnProgressFractionChanged(double value)
{
OnPropertyChanged(nameof(ProgressPercent));
}
[RelayCommand]
private void Cancel()
{
_cts.Cancel();
IsCompleted = true;
IsSuccess = false;
ErrorMessage = "Cancelled by user.";
}
public CancellationToken CancellationToken => _cts.Token;
private void OnNext(InstallProgressReport report)
{
StageText = report.Message;
ProgressFraction = report.FilesTotal > 0
? (double)report.FilesCompleted / report.FilesTotal
: report.ProgressPercent / 100.0;
CurrentFile = report.CurrentFile ?? string.Empty;
FilesCompleted = report.FilesCompleted;
FilesTotal = report.FilesTotal;
if (report.Stage is InstallStage.Completed)
{
IsCompleted = true;
IsSuccess = true;
}
else if (report.Stage is InstallStage.Failed)
{
IsCompleted = true;
IsSuccess = false;
ErrorMessage = report.Message;
}
}
private void OnError(Exception ex)
{
IsCompleted = true;
IsSuccess = false;
ErrorMessage = ex.Message;
}
private void OnCompleted()
{
IsCompleted = true;
IsSuccess = true;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_subscription.Dispose();
_cts.Dispose();
}
}

View File

@@ -0,0 +1,208 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Services.Update;
using LanMountainDesktop.Shared.Contracts.Update;
using UpdateSettingsValues = LanMountainDesktop.Services.UpdateSettingsValues;
namespace LanMountainDesktop.ViewModels;
public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
{
private readonly UpdateOrchestrator _orchestrator;
private readonly ISettingsFacadeService _settingsFacade;
private bool _disposed;
public UpdateSettingsViewModel(UpdateOrchestrator orchestrator, ISettingsFacadeService settingsFacade)
{
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
CurrentPhase = _orchestrator.CurrentPhase;
CurrentVersionText = _settingsFacade.ApplicationInfo.GetAppVersionText();
LoadPreferenceState();
_orchestrator.PhaseChanged += OnOrchestratorPhaseChanged;
_orchestrator.ProgressChanged += OnOrchestratorProgressChanged;
}
[ObservableProperty] private UpdatePhase _currentPhase = UpdatePhase.Idle;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private double _progressFraction;
[ObservableProperty] private string _progressDetail = string.Empty;
[ObservableProperty] private string _currentVersionText = string.Empty;
[ObservableProperty] private string _latestVersionText = string.Empty;
[ObservableProperty] private string _publishedAtText = string.Empty;
[ObservableProperty] private string _lastCheckedText = string.Empty;
[ObservableProperty] private string _updateTypeText = string.Empty;
[ObservableProperty] private bool _isUpdateAvailable;
[ObservableProperty] private bool _isDeltaUpdate;
[ObservableProperty] private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
[ObservableProperty] private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
[ObservableProperty] private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
[ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads;
public bool IsBusy => CurrentPhase.IsBusy();
public bool CanCheck => CurrentPhase.CanCheck();
public bool CanDownload => CurrentPhase.CanDownload();
public bool CanInstall => CurrentPhase.CanInstall();
public bool CanRollback => CurrentPhase.CanRollback();
public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack;
partial void OnCurrentPhaseChanged(UpdatePhase value)
{
OnPropertyChanged(nameof(IsBusy));
OnPropertyChanged(nameof(CanCheck));
OnPropertyChanged(nameof(CanDownload));
OnPropertyChanged(nameof(CanInstall));
OnPropertyChanged(nameof(CanRollback));
OnPropertyChanged(nameof(IsProgressVisible));
CheckCommand.NotifyCanExecuteChanged();
DownloadCommand.NotifyCanExecuteChanged();
InstallCommand.NotifyCanExecuteChanged();
RollbackCommand.NotifyCanExecuteChanged();
}
partial void OnSelectedUpdateChannelValueChanged(string value)
{
SavePreferenceState();
}
partial void OnSelectedUpdateSourceValueChanged(string value)
{
SavePreferenceState();
}
partial void OnSelectedUpdateModeValueChanged(string value)
{
SavePreferenceState();
}
partial void OnDownloadThreadsSliderValueChanged(double value)
{
SavePreferenceState();
}
[RelayCommand(CanExecute = nameof(CanCheck))]
private async Task CheckAsync()
{
var report = await _orchestrator.CheckAsync(CancellationToken.None);
if (report.IsUpdateAvailable)
{
IsUpdateAvailable = true;
LatestVersionText = report.LatestVersion ?? string.Empty;
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g") ?? string.Empty;
UpdateTypeText = report.PayloadKind?.ToString() ?? string.Empty;
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
StatusMessage = $"New version {report.LatestVersion} is available.";
}
else
{
IsUpdateAvailable = false;
LatestVersionText = string.Empty;
PublishedAtText = string.Empty;
UpdateTypeText = string.Empty;
IsDeltaUpdate = false;
StatusMessage = report.ErrorMessage ?? "You are up to date.";
}
}
[RelayCommand(CanExecute = nameof(CanDownload))]
private async Task DownloadAsync()
{
StatusMessage = "Downloading update...";
var result = await _orchestrator.DownloadAsync(CancellationToken.None);
if (result.Success)
{
StatusMessage = "Download complete. Ready to install.";
}
else
{
StatusMessage = result.ErrorMessage ?? "Download failed.";
}
}
[RelayCommand(CanExecute = nameof(CanInstall))]
private async Task InstallAsync()
{
StatusMessage = "Installing update...";
var result = await _orchestrator.InstallAsync(CancellationToken.None);
if (result.Success)
{
StatusMessage = "Update installed successfully.";
}
else
{
StatusMessage = result.ErrorMessage ?? "Install failed.";
}
}
[RelayCommand(CanExecute = nameof(CanRollback))]
private async Task RollbackAsync()
{
StatusMessage = "Rolling back...";
await _orchestrator.RollbackAsync(CancellationToken.None);
StatusMessage = "Rollback complete.";
}
private void OnOrchestratorPhaseChanged(UpdatePhase phase)
{
CurrentPhase = phase;
}
private void OnOrchestratorProgressChanged(UpdateProgressReport report)
{
ProgressFraction = report.ProgressFraction;
StatusMessage = report.Message;
if (report.DownloadDetail is not null)
{
ProgressDetail = $"{report.DownloadDetail.CurrentFile} ({report.DownloadDetail.OverallPercent}%)";
}
else if (report.InstallDetail is not null)
{
ProgressDetail = report.InstallDetail.CurrentFile ?? report.InstallDetail.Message;
}
else
{
ProgressDetail = string.Empty;
}
}
private void LoadPreferenceState()
{
var state = _settingsFacade.Update.Get();
SelectedUpdateChannelValue = state.UpdateChannel;
SelectedUpdateSourceValue = state.UpdateDownloadSource;
SelectedUpdateModeValue = state.UpdateMode;
DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads);
}
private void SavePreferenceState()
{
var current = _settingsFacade.Update.Get();
_settingsFacade.Update.Save(current with
{
UpdateChannel = SelectedUpdateChannelValue,
UpdateDownloadSource = SelectedUpdateSourceValue,
UpdateMode = SelectedUpdateModeValue,
UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue))
});
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_orchestrator.PhaseChanged -= OnOrchestratorPhaseChanged;
_orchestrator.ProgressChanged -= OnOrchestratorProgressChanged;
}
}

View File

@@ -76,6 +76,7 @@ public partial class MainWindow : Window
string.Equals(key, nameof(AppSettingsSnapshot.UpdateChannel), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateMode), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UseGhProxyMirror), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableFadeTransition), StringComparison.OrdinalIgnoreCase) ||
@@ -661,6 +662,7 @@ public partial class MainWindow : Window
UpdateMode = latestUpdateState.UpdateMode,
UpdateDownloadSource = latestUpdateState.UpdateDownloadSource,
UpdateDownloadThreads = latestUpdateState.UpdateDownloadThreads,
UseGhProxyMirror = latestUpdateState.UseGhProxyMirror,
PendingUpdateInstallerPath = latestUpdateState.PendingUpdateInstallerPath,
PendingUpdateVersion = latestUpdateState.PendingUpdateVersion,
PendingUpdatePublishedAtUtcMs = latestUpdateState.PendingUpdatePublishedAtUtcMs,

View File

@@ -33,6 +33,13 @@
<Setter Property="MaxWidth" Value="200" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<Style Selector="TextBlock.update-phase-text">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
</UserControl.Styles>
<ScrollViewer VerticalScrollBarVisibility="Auto">
@@ -65,7 +72,7 @@
</Grid>
<Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto,Auto"
RowDefinitions="Auto,Auto,Auto,Auto"
ColumnSpacing="20"
RowSpacing="16">
<StackPanel Grid.Row="0"
@@ -116,9 +123,19 @@
<TextBlock Classes="update-kv-value"
Text="{Binding PendingUpdateTypeText}" />
</StackPanel>
<StackPanel Grid.Row="2"
Grid.Column="1"
Spacing="4"
IsVisible="{Binding IsUpdateTypeVisible}">
<TextBlock Classes="update-kv-label"
Text="{Binding UpdateTypeLabel}" />
<TextBlock Classes="update-kv-value"
Text="{Binding UpdateTypeText}" />
</StackPanel>
</Grid>
<StackPanel Spacing="12"
<StackPanel Spacing="8"
HorizontalAlignment="Left">
<TextBlock Classes="settings-item-description"
Text="{Binding UpdateStatus}"
@@ -126,12 +143,19 @@
HorizontalAlignment="Left"
MaxWidth="500" />
<TextBlock Classes="update-phase-text"
IsVisible="{Binding IsDownloadProgressVisible}"
Text="{Binding UpdatePhaseText}"
TextWrapping="Wrap"
HorizontalAlignment="Left" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding DownloadProgressValue}"
Value="{Binding PhaseProgressValue}"
IsVisible="{Binding IsDownloadProgressVisible}"
HorizontalAlignment="Stretch"
Margin="0,4,0,4" />
Margin="0,4,0,4"
ShowProgressText="True" />
<TextBlock Classes="settings-item-description"
IsVisible="{Binding IsDownloadProgressVisible}"
@@ -144,15 +168,27 @@
<StackPanel Orientation="Horizontal"
Spacing="10">
<Button Command="{Binding DownloadLatestReleaseCommand}"
Content="{Binding DownloadButtonText}"
IsVisible="{Binding IsDownloadButtonVisible}" />
IsVisible="{Binding IsDownloadButtonVisible}">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="ArrowDownload" FontSize="14" />
<TextBlock Text="{Binding DownloadButtonText}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Command="{Binding RedownloadUpdateCommand}"
Content="{Binding RedownloadButtonText}"
IsVisible="{Binding IsRedownloadButtonVisible}" />
IsVisible="{Binding IsRedownloadButtonVisible}">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="ArrowUndo" FontSize="14" />
<TextBlock Text="{Binding RedownloadButtonText}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Classes="settings-accent-button"
Command="{Binding InstallPendingUpdateCommand}"
Content="{Binding InstallNowButtonText}"
IsVisible="{Binding IsInstallButtonVisible}" />
IsVisible="{Binding IsInstallButtonVisible}">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="Play" FontSize="14" />
<TextBlock Text="{Binding InstallNowButtonText}" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Border>
@@ -188,25 +224,14 @@
<ui:FAFontIconSource Glyph="&#xF0FD4;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpanderItem.IconSource>
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="{Binding UpdateSourceLabel}"
Description="{Binding SelectedUpdateSourceDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF182C;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ComboBox Width="220"
ItemsSource="{Binding UpdateSourceOptions}"
SelectedItem="{Binding SelectedUpdateSourceOption}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpander.Footer>
<ui:FASettingsExpanderItem Content="{Binding ForceFullUpdateLabel}"
Description="{Binding ForceFullUpdateDescription}"
IsClickEnabled="True"
Command="{Binding ForceFullUpdateCommand}">
<ui:FASettingsExpanderItem.IconSource>
<ui:FAFontIconSource Glyph="&#xF0E9F;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpanderItem.IconSource>
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
@@ -228,6 +253,17 @@
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="{Binding NetworkAccelerationLabel}"
Description="{Binding NetworkAccelerationDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF01BB;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding UseGhProxyMirror}" />
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="{Binding DownloadThreadsLabel}"
Description="{Binding DownloadThreadsDescription}">

View File

@@ -0,0 +1,87 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
x:Class="LanMountainDesktop.Views.UpdateProgressDialog"
x:DataType="vm:UpdateProgressViewModel"
Title="阑山桌面 - Installing Update"
Width="480"
Height="320"
CanResize="False"
WindowStartupLocation="CenterScreen"
WindowDecorations="None"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None">
<Grid>
<Grid VerticalAlignment="Top" Margin="24,24,24,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center" Spacing="8">
<TextBlock Text="阑山桌面"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<Border Background="{DynamicResource AccentFillColorDefaultBrush}"
CornerRadius="4"
Padding="6,2"
VerticalAlignment="Center">
<TextBlock Text="Update"
FontSize="11"
FontWeight="SemiBold"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}" />
</Border>
</StackPanel>
</Grid>
<Grid VerticalAlignment="Center" Margin="24,0,24,0">
<StackPanel Spacing="12" HorizontalAlignment="Stretch">
<TextBlock Text="{Binding StageText}"
FontSize="14"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap"
HorizontalAlignment="Center" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding ProgressPercent}"
Height="4"
IsIndeterminate="False"
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
Background="{DynamicResource ControlStrokeColorDefaultBrush}"
HorizontalAlignment="Stretch" />
<TextBlock Text="{Binding CurrentFile}"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.8"
TextTrimming="CharacterEllipsis"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4" VerticalAlignment="Bottom">
<TextBlock FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.8"
VerticalAlignment="Bottom">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} / {1} files">
<Binding Path="FilesCompleted" />
<Binding Path="FilesTotal" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
<Button Grid.Column="1"
Classes="settings-accent-button"
Content="Cancel"
Command="{Binding CancelCommand}"
IsVisible="{Binding !IsCompleted}" />
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,26 @@
using Avalonia;
using Avalonia.Controls;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views;
public partial class UpdateProgressDialog : Window
{
public UpdateProgressDialog()
{
InitializeComponent();
}
public UpdateProgressDialog(UpdateProgressViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
viewModel.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(UpdateProgressViewModel.IsCompleted) && viewModel.IsCompleted)
{
Close(viewModel.IsSuccess);
}
};
}
}

View File

@@ -36,8 +36,12 @@ AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DisableDirPage=no
UsePreviousAppDir=no
ShowLanguageDialog=yes
UsePreviousLanguage=no
; 语言对话框行为:
; - 全新安装:显示语言选择对话框
; - 升级安装:自动沿用之前选择的语言,不弹出对话框
; - 用户可以在欢迎页面点击语言按钮手动切换
ShowLanguageDialog=auto
UsePreviousLanguage=yes
LanguageDetectionMethod=uilanguage
DefaultGroupName={cm:AppShortcutName}
UninstallDisplayIcon={app}\{#MyAppExeName}
@@ -112,6 +116,10 @@ english.DotNetRuntimeOpenFailedMessage=Unable to open the download page automati
chinesesimplified.DotNetRuntimeOpenFailedMessage=无法自动打开下载页面。
english.DotNetRuntimeOpenFailedAction=Please open this URL manually:
chinesesimplified.DotNetRuntimeOpenFailedAction=请手动打开以下链接:
english.LanguageButtonCaption=Language
chinesesimplified.LanguageButtonCaption=语言
english.LanguageButtonHint=Click to change the installation language
chinesesimplified.LanguageButtonHint=点击更改安装语言
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
@@ -158,6 +166,7 @@ var
ExistingInstallWas64Bit: Boolean;
ExistingInstallIsPerUser: Boolean;
ExistingInstallRemoved: Boolean;
LanguageButton: TNewButton;
function NormalizePathValue(const Value: String): String;
begin
@@ -341,6 +350,59 @@ begin
TryLoadExistingInstallation(HKCU32, False, True);
end;
{ 语言切换按钮点击处理 }
{ 注意Inno Setup 不支持运行时切换语言,所以我们显示一个简单对话框,
让用户选择语言,然后重启安装程序以应用新语言 }
procedure LanguageButtonClick(Sender: TObject);
var
NewLanguage: String;
Params: String;
ResultCode: Integer;
begin
{ 根据当前语言显示对应的提示 }
if ActiveLanguage = 'chinesesimplified' then
begin
{ 当前是中文,询问是否切换到英文 }
if MsgBox('当前语言:简体中文' + #13#10#13#10 + '是否切换到 English' + #13#10 + '(安装程序将重新启动以应用新语言)', mbConfirmation, MB_YESNO) = IDYES then
begin
NewLanguage := 'english';
end
else
begin
exit;
end;
end
else
begin
{ 当前是英文,询问是否切换到中文 }
if MsgBox('Current language: English' + #13#10#13#10 + 'Switch to 简体中文?' + #13#10 + '(The setup will restart to apply the new language)', mbConfirmation, MB_YESNO) = IDYES then
begin
NewLanguage := 'chinesesimplified';
end
else
begin
exit;
end;
end;
{ 构建重启参数,带上新语言设置 }
Params := '/LANG="' + NewLanguage + '"';
if WizardSilent then
begin
Params := Params + ' /SILENT';
end;
if WizardVerySilent then
begin
Params := Params + ' /VERYSILENT';
end;
{ 重启安装程序并退出当前实例 }
if Exec(ExpandConstant('{srcexe}'), Params, '', SW_SHOWNORMAL, ewNoWait, ResultCode) then
begin
WizardForm.Close;
end;
end;
function SelectedUpgradeChoice(): Integer;
begin
if UpgradeModePage <> nil then
@@ -589,6 +651,16 @@ var
begin
DetectExistingInstallation;
{ 在欢迎页面添加语言切换按钮 }
LanguageButton := TNewButton.Create(WizardForm);
LanguageButton.Parent := WizardForm.WelcomePage;
LanguageButton.Caption := CustomMessage('LanguageButtonCaption');
LanguageButton.Hint := CustomMessage('LanguageButtonHint');
LanguageButton.ShowHint := True;
LanguageButton.Left := WizardForm.WelcomePage.ClientWidth - LanguageButton.Width - 20;
LanguageButton.Top := 12;
LanguageButton.OnClick := @LanguageButtonClick;
if not ExistingInstallFound then
begin
exit;

View File

@@ -1,3 +1,4 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -49,19 +50,25 @@ public sealed class PlondsGenerator
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
var publishedAt = DateTimeOffset.UtcNow;
var generatedAt = DateTimeOffset.UtcNow;
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
? options.PreviousVersion
: options.BaselineVersion;
var arch = ResolveArch(options.Platform);
var fileMap = new FileMapDocument(
FormatVersion: "1.0",
FormatVersion: "2.0",
DistributionId: distributionId,
FromVersion: options.PreviousVersion,
ToVersion: options.CurrentVersion,
Version: options.CurrentVersion,
Platform: options.Platform,
Arch: arch,
Channel: options.Channel,
PublishedAt: publishedAt,
Capabilities: ["file-object"],
GeneratedAt: generatedAt,
BaselineVersion: baselineVersion,
Capabilities: ["file-object", "compressed-object"],
Components:
[
new ComponentDocument(
@@ -89,12 +96,13 @@ public sealed class PlondsGenerator
Version: options.CurrentVersion,
Channel: options.Channel,
Platform: options.Platform,
Arch: arch,
PublishedAt: publishedAt,
FileMapUrl: options.FileMapUrl,
FileMapSignatureUrl: options.FileMapSignatureUrl,
Components: fileMap.Components,
InstallerMirrors: installerMirrors,
Capabilities: ["file-object"],
Capabilities: ["file-object", "compressed-object"],
Metadata: new Dictionary<string, string>
{
["protocol"] = "PLONDS",
@@ -135,6 +143,12 @@ public sealed class PlondsGenerator
installerMirrors.Select(x => x.FileName ?? string.Empty).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray());
}
public static void WriteBundle(string fileMapPath, string signatureBase64)
{
var fileMapJson = File.ReadAllText(fileMapPath);
WriteBundle(fileMapPath, fileMapJson, signatureBase64);
}
private static Dictionary<string, FileFingerprint> ScanDirectory(string? root)
{
var manifest = new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase);
@@ -181,12 +195,14 @@ public sealed class PlondsGenerator
Mode: "file-object",
ObjectKey: null,
ObjectUrl: null,
Metadata: null));
ArchiveSha256: null,
Metadata: new Dictionary<string, string> { ["reuseVerified"] = "true" }));
continue;
}
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
var objectKey = CopyContentObject(current.FullPath, repoRoot, current.Sha256);
var (objectKey, archiveSha256, mode) = CopyContentObjectWithCompression(
current.FullPath, repoRoot, current.Sha256, current.Size);
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
? null
: $"{repoBaseUrl.TrimEnd('/')}/{objectKey}";
@@ -196,10 +212,11 @@ public sealed class PlondsGenerator
Action: action,
Sha256: current.Sha256,
Size: current.Size,
Mode: "file-object",
Mode: mode,
ObjectKey: objectKey,
ObjectUrl: objectUrl,
Metadata: new Dictionary<string, string> { ["mode"] = "file-object" }));
ArchiveSha256: string.IsNullOrEmpty(archiveSha256) ? null : archiveSha256,
Metadata: new Dictionary<string, string> { ["mode"] = mode }));
}
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
@@ -214,6 +231,7 @@ public sealed class PlondsGenerator
Mode: "file-object",
ObjectKey: null,
ObjectUrl: null,
ArchiveSha256: null,
Metadata: null));
}
}
@@ -301,6 +319,56 @@ public sealed class PlondsGenerator
return relativeKey.Replace('\\', '/');
}
private static (string ObjectKey, string ArchiveSha256, string Mode) CopyContentObjectWithCompression(
string sourcePath, string repoRoot, string sha256, long fileSize)
{
if (fileSize > 65536)
{
var compressedBytes = CompressGzip(sourcePath);
var archiveSha256 = ComputeSha256FromBytes(compressedBytes);
var archiveKey = CopyBytesToObjectStore(compressedBytes, repoRoot, archiveSha256);
return (archiveKey, archiveSha256, "compressed-object");
}
var key = CopyContentObject(sourcePath, repoRoot, sha256);
return (key, string.Empty, "file-object");
}
private static byte[] CompressGzip(string filePath)
{
using var input = File.OpenRead(filePath);
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionMode.Compress, leaveOpen: true))
{
input.CopyTo(gzip);
}
return output.ToArray();
}
private static string ComputeSha256FromBytes(byte[] data)
{
return Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
}
private static string CopyBytesToObjectStore(byte[] data, string repoRoot, string sha256)
{
var prefix = sha256[..Math.Min(2, sha256.Length)];
var relativeKey = $"{prefix}/{sha256}";
var destinationPath = Path.Combine(repoRoot, prefix, sha256);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
if (!File.Exists(destinationPath))
{
File.WriteAllBytes(destinationPath, data);
}
return relativeKey.Replace('\\', '/');
}
private static void WriteBundle(string fileMapPath, string fileMapJson, string signatureBase64)
{
var bundle = new BundleDocument(fileMapJson, signatureBase64);
WriteJson(fileMapPath + ".bundle.json", bundle);
}
private static string ComputeSha256(string filePath)
{
using var stream = File.OpenRead(filePath);
@@ -320,9 +388,13 @@ public sealed class PlondsGenerator
string DistributionId,
string FromVersion,
string ToVersion,
string Version,
string Platform,
string Arch,
string Channel,
DateTimeOffset PublishedAt,
DateTimeOffset GeneratedAt,
string? BaselineVersion,
IReadOnlyList<string> Capabilities,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyDictionary<string, string>? Metadata);
@@ -332,6 +404,7 @@ public sealed class PlondsGenerator
string Version,
string Channel,
string Platform,
string Arch,
DateTimeOffset PublishedAt,
string? FileMapUrl,
string? FileMapSignatureUrl,
@@ -362,6 +435,7 @@ public sealed class PlondsGenerator
string Mode,
string? ObjectKey,
string? ObjectUrl,
string? ArchiveSha256,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record InstallerMirrorDocument(
@@ -372,4 +446,6 @@ public sealed class PlondsGenerator
string? FileName,
string? Sha256,
long Size);
private sealed record BundleDocument(string Manifest, string Signature);
}