diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs index 40d9814..5de2b5b 100644 --- a/LanMountainDesktop.Launcher/AppJsonContext.cs +++ b/LanMountainDesktop.Launcher/AppJsonContext.cs @@ -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; diff --git a/LanMountainDesktop.Launcher/Services/IUpdateProgressReporter.cs b/LanMountainDesktop.Launcher/Services/IUpdateProgressReporter.cs new file mode 100644 index 0000000..3d4c33c --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/IUpdateProgressReporter.cs @@ -0,0 +1,9 @@ +using LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Launcher.Services; + +public interface IUpdateProgressReporter +{ + void ReportProgress(InstallProgressReport report); + void ReportComplete(InstallCompleteReport report); +} diff --git a/LanMountainDesktop.Launcher/Services/Ipc/LauncherUpdateProgressIpcServer.cs b/LanMountainDesktop.Launcher/Services/Ipc/LauncherUpdateProgressIpcServer.cs new file mode 100644 index 0000000..e35946d --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/Ipc/LauncherUpdateProgressIpcServer.cs @@ -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(); + } +} diff --git a/LanMountainDesktop.Launcher/Services/NullUpdateProgressReporter.cs b/LanMountainDesktop.Launcher/Services/NullUpdateProgressReporter.cs new file mode 100644 index 0000000..7164026 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/NullUpdateProgressReporter.cs @@ -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) { } +} diff --git a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs index 727a117..67cdb1c 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs @@ -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, diff --git a/LanMountainDesktop.Shared.Contracts/Update/UpdateManifest.cs b/LanMountainDesktop.Shared.Contracts/Update/UpdateManifest.cs new file mode 100644 index 0000000..0c37626 --- /dev/null +++ b/LanMountainDesktop.Shared.Contracts/Update/UpdateManifest.cs @@ -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 Files, + IReadOnlyList? InstallerMirrors, + IReadOnlyDictionary 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? 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); +} diff --git a/LanMountainDesktop.Shared.Contracts/Update/UpdateMessages.cs b/LanMountainDesktop.Shared.Contracts/Update/UpdateMessages.cs new file mode 100644 index 0000000..b486ee0 --- /dev/null +++ b/LanMountainDesktop.Shared.Contracts/Update/UpdateMessages.cs @@ -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); diff --git a/LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs b/LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs new file mode 100644 index 0000000..dc7d6e1 --- /dev/null +++ b/LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs @@ -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}}" + } + """; + } +} diff --git a/LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs b/LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs new file mode 100644 index 0000000..f16e28f --- /dev/null +++ b/LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs @@ -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; +} diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 2fe801e..6a79161 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -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", diff --git a/LanMountainDesktop/Localization/ja-JP.json b/LanMountainDesktop/Localization/ja-JP.json index 01ee308..eb50f9d 100644 --- a/LanMountainDesktop/Localization/ja-JP.json +++ b/LanMountainDesktop/Localization/ja-JP.json @@ -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": "更新", diff --git a/LanMountainDesktop/Localization/ko-KR.json b/LanMountainDesktop/Localization/ko-KR.json index 02dc502..e8b9640 100644 --- a/LanMountainDesktop/Localization/ko-KR.json +++ b/LanMountainDesktop/Localization/ko-KR.json @@ -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": "새로고침", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index f7bb7da..49b28ad 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -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": "刷新", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index d297ba1..7fedb9a 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -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; diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index ed669a7..67479d6 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -88,6 +88,7 @@ public sealed record UpdateSettingsState( string UpdateMode, string UpdateDownloadSource, int UpdateDownloadThreads, + bool UseGhProxyMirror, string? PendingUpdateInstallerPath, string? PendingUpdateVersion, long? PendingUpdatePublishedAtUtcMs, diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 5cdd095..4576e52 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -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; } } diff --git a/LanMountainDesktop/Services/Update/CliLauncherUpdateBridge.cs b/LanMountainDesktop/Services/Update/CliLauncherUpdateBridge.cs new file mode 100644 index 0000000..78dc134 --- /dev/null +++ b/LanMountainDesktop/Services/Update/CliLauncherUpdateBridge.cs @@ -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 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 ProgressStream => ObservableHelper.Empty; + + public Task SupportsIpcAsync() => Task.FromResult(false); +} diff --git a/LanMountainDesktop/Services/Update/CompositeManifestProvider.cs b/LanMountainDesktop/Services/Update/CompositeManifestProvider.cs new file mode 100644 index 0000000..164d3d1 --- /dev/null +++ b/LanMountainDesktop/Services/Update/CompositeManifestProvider.cs @@ -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 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 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> 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); + } +} diff --git a/LanMountainDesktop/Services/Update/GithubReleaseManifestProvider.cs b/LanMountainDesktop/Services/Update/GithubReleaseManifestProvider.cs new file mode 100644 index 0000000..132489f --- /dev/null +++ b/LanMountainDesktop/Services/Update/GithubReleaseManifestProvider.cs @@ -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 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 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> GetIncrementalChainAsync( + string channel, + string platform, + Version fromVersion, + Version toVersion, + CancellationToken ct) + { + return Task.FromResult>([]); + } + + 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 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}"; + } +} diff --git a/LanMountainDesktop/Services/Update/ILauncherUpdateBridge.cs b/LanMountainDesktop/Services/Update/ILauncherUpdateBridge.cs new file mode 100644 index 0000000..611a9c5 --- /dev/null +++ b/LanMountainDesktop/Services/Update/ILauncherUpdateBridge.cs @@ -0,0 +1,10 @@ +using LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Services.Update; + +public interface ILauncherUpdateBridge +{ + Task LaunchInstallerAsync(InstallRequest request, CancellationToken ct); + IObservable ProgressStream { get; } + Task SupportsIpcAsync(); +} diff --git a/LanMountainDesktop/Services/Update/IUpdateManifestProvider.cs b/LanMountainDesktop/Services/Update/IUpdateManifestProvider.cs new file mode 100644 index 0000000..15cbab7 --- /dev/null +++ b/LanMountainDesktop/Services/Update/IUpdateManifestProvider.cs @@ -0,0 +1,27 @@ +using LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Services.Update; + +public interface IUpdateManifestProvider +{ + string ProviderName { get; } + + Task GetLatestAsync( + string channel, + string platform, + Version currentVersion, + CancellationToken ct); + + Task GetByVersionAsync( + string version, + string channel, + string platform, + CancellationToken ct); + + Task> GetIncrementalChainAsync( + string channel, + string platform, + Version fromVersion, + Version toVersion, + CancellationToken ct); +} diff --git a/LanMountainDesktop/Services/Update/IpcLauncherUpdateBridge.cs b/LanMountainDesktop/Services/Update/IpcLauncherUpdateBridge.cs new file mode 100644 index 0000000..90e03f2 --- /dev/null +++ b/LanMountainDesktop/Services/Update/IpcLauncherUpdateBridge.cs @@ -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 ProgressStream => _progressSubject; + + public async Task 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 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.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.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.Shared.Return(payloadBuffer); + } + } + } + finally + { + ArrayPool.Shared.Return(lengthBuffer); + } + } + + public void Dispose() + { + _cts.Cancel(); + _progressSubject.OnCompleted(); + _cts.Dispose(); + } +} diff --git a/LanMountainDesktop/Services/Update/ObservableHelper.cs b/LanMountainDesktop/Services/Update/ObservableHelper.cs new file mode 100644 index 0000000..074c0ab --- /dev/null +++ b/LanMountainDesktop/Services/Update/ObservableHelper.cs @@ -0,0 +1,31 @@ +namespace LanMountainDesktop.Services.Update; + +internal static class ObservableHelper +{ + private sealed class EmptyObservable : IObservable + { + public IDisposable Subscribe(IObserver observer) => EmptyDisposable.Instance; + } + + private sealed class EmptyDisposable : IDisposable + { + public static readonly EmptyDisposable Instance = new(); + public void Dispose() { } + } + + public static readonly IObservable Empty = new EmptyObservable(); +} + +internal sealed class ActionObserver : IObserver +{ + private readonly Action _onNext; + + public ActionObserver(Action onNext) + { + _onNext = onNext; + } + + public void OnCompleted() { } + public void OnError(Exception error) { } + public void OnNext(T value) => _onNext(value); +} diff --git a/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs b/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs new file mode 100644 index 0000000..922daa3 --- /dev/null +++ b/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs @@ -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 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 GetByVersionAsync( + string version, + string channel, + string platform, + CancellationToken ct) + { + var distributionId = $"{channel}-{platform}-{version}"; + return await FetchDistributionManifestAsync(distributionId, version, channel, platform, ct); + } + + public Task> GetIncrementalChainAsync( + string channel, + string platform, + Version fromVersion, + Version toVersion, + CancellationToken ct) + { + return Task.FromResult>([]); + } + + private async Task 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(json, PlondsJsonOptions); + } + + private async Task 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(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(); + 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 ?? new Dictionary()); + } + + 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? Components, + List? InstallerMirrors, + List? Signatures, + Dictionary? Metadata); + + private sealed record PlondsComponentDto( + string? Id, + string? Root, + string? Mode, + List? 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); +} diff --git a/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs b/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs new file mode 100644 index 0000000..1aace67 --- /dev/null +++ b/LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs @@ -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 DownloadPayloadAsync( + UpdateManifest manifest, + string incomingDirectory, + string objectsDirectory, + int maxConcurrency, + IProgress? 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(StringComparer.OrdinalIgnoreCase); + var semaphore = new SemaphoreSlim(Math.Max(1, maxConcurrency), Math.Max(1, maxConcurrency)); + var errors = new List(); + 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 DownloadFullInstallerAsync( + UpdateManifest manifest, + string destinationPath, + int maxThreads, + IProgress? 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(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? 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 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(); + } +} diff --git a/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs b/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs new file mode 100644 index 0000000..ebea190 --- /dev/null +++ b/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs @@ -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 InstallAsync( + UpdatePayloadKind payloadKind, + string launcherRoot, + IProgress? 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; + } +} diff --git a/LanMountainDesktop/Services/Update/UpdateJsonContext.cs b/LanMountainDesktop/Services/Update/UpdateJsonContext.cs new file mode 100644 index 0000000..d66fbdd --- /dev/null +++ b/LanMountainDesktop/Services/Update/UpdateJsonContext.cs @@ -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; diff --git a/LanMountainDesktop/Services/Update/UpdateManifestMapper.cs b/LanMountainDesktop/Services/Update/UpdateManifestMapper.cs new file mode 100644 index 0000000..3b3bf91 --- /dev/null +++ b/LanMountainDesktop/Services/Update/UpdateManifestMapper.cs @@ -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(); + + 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 + { + ["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(); + var mirrors = new List(); + + 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 + { + ["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 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; + } +} diff --git a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs new file mode 100644 index 0000000..0747559 --- /dev/null +++ b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs @@ -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? PhaseChanged; + public event Action? ProgressChanged; + + public async Task 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 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(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 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(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(); + } +} diff --git a/LanMountainDesktop/Services/Update/UpdateProgressSubject.cs b/LanMountainDesktop/Services/Update/UpdateProgressSubject.cs new file mode 100644 index 0000000..51fdd7a --- /dev/null +++ b/LanMountainDesktop/Services/Update/UpdateProgressSubject.cs @@ -0,0 +1,105 @@ +using LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Services.Update; + +internal sealed class UpdateProgressSubject : IObservable, IObserver +{ + private readonly List> _observers = []; + private readonly object _gate = new(); + private bool _completed; + + public IDisposable Subscribe(IObserver observer) + { + lock (_gate) + { + if (_completed) + { + observer.OnCompleted(); + return EmptyDisposable.Instance; + } + + _observers.Add(observer); + } + + return new Subscription(this, observer); + } + + public void OnNext(InstallProgressReport value) + { + IObserver[] snapshot; + lock (_gate) + { + snapshot = _observers.ToArray(); + } + + foreach (var observer in snapshot) + { + observer.OnNext(value); + } + } + + public void OnError(Exception error) + { + IObserver[] snapshot; + lock (_gate) + { + _completed = true; + snapshot = _observers.ToArray(); + _observers.Clear(); + } + + foreach (var observer in snapshot) + { + observer.OnError(error); + } + } + + public void OnCompleted() + { + IObserver[] 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? _observer; + + public Subscription(UpdateProgressSubject subject, IObserver 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() { } + } +} diff --git a/LanMountainDesktop/Services/Update/UpdateStateStore.cs b/LanMountainDesktop/Services/Update/UpdateStateStore.cs new file mode 100644 index 0000000..201a176 --- /dev/null +++ b/LanMountainDesktop/Services/Update/UpdateStateStore.cs @@ -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? PhaseChanged; + public event Action? 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; +} diff --git a/LanMountainDesktop/Services/UpdateSettingsValues.cs b/LanMountainDesktop/Services/UpdateSettingsValues.cs index f55529b..7af27a0 100644 --- a/LanMountainDesktop/Services/UpdateSettingsValues.cs +++ b/LanMountainDesktop/Services/UpdateSettingsValues.cs @@ -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) diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs index 5444b9f..79e3eaf 100644 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -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); diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index 6ff08da..e09021f 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -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 UpdateChannelOptions { get; } - public IReadOnlyList UpdateSourceOptions { get; } - public IReadOnlyList UpdateModeOptions { get; } public IReadOnlyList 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(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(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 CreateUpdateChannelOptions() @@ -2520,16 +2565,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase ]; } - private IReadOnlyList CreateUpdateSourceOptions() - { - return - [ - new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText), - new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText), - new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText) - ]; - } - private IReadOnlyList 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 => diff --git a/LanMountainDesktop/ViewModels/UpdateProgressViewModel.cs b/LanMountainDesktop/ViewModels/UpdateProgressViewModel.cs new file mode 100644 index 0000000..dc3e34b --- /dev/null +++ b/LanMountainDesktop/ViewModels/UpdateProgressViewModel.cs @@ -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 progressStream) + { + _subscription = progressStream.Subscribe(new ActionObserver(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(); + } +} diff --git a/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs b/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs new file mode 100644 index 0000000..04bdd03 --- /dev/null +++ b/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs @@ -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; + } +} diff --git a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs index e44b96b..2c3edfe 100644 --- a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs +++ b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs @@ -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, diff --git a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml index 2ebf7eb..e2c540a 100644 --- a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml @@ -33,6 +33,13 @@ + + @@ -65,7 +72,7 @@ + + + + + - + + + Margin="0,4,0,4" + ShowProgressText="True" /> @@ -188,25 +224,14 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/UpdateProgressDialog.axaml b/LanMountainDesktop/Views/UpdateProgressDialog.axaml new file mode 100644 index 0000000..83de315 --- /dev/null +++ b/LanMountainDesktop/Views/UpdateProgressDialog.axaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +