namespace AcDream.Launcher.Core.Updates; public enum LauncherUpdatePhase { Idle, Checking, DownloadingClient, ExtractingClient, ActivatingClient, DownloadingLauncher, StagingLauncher, RollingBack, Completed, Cancelled, Failed, } public sealed record LauncherUpdateProgress( LauncherUpdatePhase Phase, string Status, long Completed = 0, long Total = 0) { public double Percent => Total <= 0 ? 0 : Math.Clamp(Completed * 100d / Total, 0, 100); } public sealed record LauncherUpdateCheckResult( ReleaseManifest Manifest, string Rid, LauncherVersion LauncherVersion, LauncherVersion? InstalledClientVersion, bool IsClientUpdateAvailable, bool IsLauncherUpdateAvailable, bool IsLauncherMinimumSatisfied, string Status); public interface ILauncherUpdater { ClientVersionResolution CurrentClient { get; } Task InitializeAsync( CancellationToken cancellationToken = default); Task CheckAsync( CancellationToken cancellationToken = default); Task InstallClientAsync( LauncherUpdateCheckResult check, IProgress? progress = null, CancellationToken cancellationToken = default); Task StageLauncherAsync( LauncherUpdateCheckResult check, IProgress? progress = null, CancellationToken cancellationToken = default); Task RollbackClientAsync( IProgress? progress = null, CancellationToken cancellationToken = default); } /// /// Canonical LA10 update transaction. It holds the cross-process exclusive /// barrier for recovery/download/extraction/promotion/pointer publication and /// leaves LA9's verified DAT/pak record untouched. /// public sealed class LauncherUpdater : ILauncherUpdater { private readonly IReleaseManifestClient _manifestClient; private readonly ClientVersionStore _versions; private readonly LauncherSelfUpdateManager _selfUpdates; private readonly VerifiedArtifactDownloader _downloader; private readonly SafeZipExtractor _extractor; private readonly LauncherVersion _launcherVersion; private readonly string _rid; private readonly string _launcherTargetDirectory; private readonly Func _hasRunningSessions; private readonly SemaphoreSlim _operationGate = new(1, 1); public LauncherUpdater( IReleaseManifestClient manifestClient, HttpClient httpClient, ClientVersionStore versions, LauncherSelfUpdateManager selfUpdates, LauncherVersion launcherVersion, string rid, string launcherTargetDirectory, Func? hasRunningSessions = null, SafeZipExtractor? extractor = null) { _manifestClient = manifestClient ?? throw new ArgumentNullException(nameof(manifestClient)); _versions = versions ?? throw new ArgumentNullException(nameof(versions)); _selfUpdates = selfUpdates ?? throw new ArgumentNullException(nameof(selfUpdates)); _launcherVersion = launcherVersion ?? throw new ArgumentNullException(nameof(launcherVersion)); if (!LauncherRuntimeIdentity.IsValidRid(rid)) { throw new ArgumentException("RID is invalid.", nameof(rid)); } _rid = rid; ArgumentException.ThrowIfNullOrWhiteSpace(launcherTargetDirectory); _launcherTargetDirectory = Path.GetFullPath(launcherTargetDirectory); _hasRunningSessions = hasRunningSessions ?? (() => false); _downloader = new VerifiedArtifactDownloader( httpClient ?? throw new ArgumentNullException(nameof(httpClient))); _extractor = extractor ?? new SafeZipExtractor(); } public ClientVersionResolution CurrentClient => _versions.CachedResolution; public Task InitializeAsync( CancellationToken cancellationToken = default) => _versions.LoadAndRecoverAsync(_rid, cancellationToken); public async Task CheckAsync( CancellationToken cancellationToken = default) { await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ReleaseManifest manifest = await _manifestClient.FetchAsync(cancellationToken) .ConfigureAwait(false); _ = manifest.RequireClient(_rid); _ = manifest.RequireLauncher(_rid); ClientVersionResolution installed = _versions.CachedResolution; LauncherVersion? installedVersion = installed.IsVerified ? installed.Version : null; bool clientAvailable = installedVersion is null || manifest.Version > installedVersion; bool launcherAvailable = manifest.Version > _launcherVersion; bool minimumSatisfied = _launcherVersion >= manifest.MinimumLauncherVersion; string status = BuildCheckStatus( manifest, installedVersion, clientAvailable, launcherAvailable, minimumSatisfied); return new LauncherUpdateCheckResult( manifest, _rid, _launcherVersion, installedVersion, clientAvailable, launcherAvailable, minimumSatisfied, status); } finally { _operationGate.Release(); } } public async Task InstallClientAsync( LauncherUpdateCheckResult check, IProgress? progress = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(check); ValidateCheck(check); await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { RefuseRunningSessions(); using UpdateSessionBarrier.ExclusiveLease lease = _versions.Barrier.AcquireExclusive(); RefuseRunningSessions(); ClientVersionResolution current = await _versions .LoadAndRecoverUnderLeaseAsync(_rid, cancellationToken) .ConfigureAwait(false); if (!check.IsLauncherMinimumSatisfied) { throw new LauncherUpdateException( $"Client {check.Manifest.Version} requires launcher " + $"{check.Manifest.MinimumLauncherVersion} or newer. " + "Stage the launcher update first."); } if (current.IsVerified && current.Version is not null && current.Version >= check.Manifest.Version) { Report( progress, LauncherUpdatePhase.Completed, $"Client {current.Version} is already current.", 1, 1); return current; } ReleaseArtifact artifact = check.Manifest.RequireClient(_rid); Guid transactionId = Guid.NewGuid(); string staging = _versions.CreateClientStagingDirectory(transactionId); string archive = Path.Combine( _versions.AppDirectory, $".client-download-{transactionId:N}.zip"); try { Report( progress, LauncherUpdatePhase.DownloadingClient, $"Downloading client {check.Manifest.Version}...", 0, artifact.Size); var downloadProgress = new ForwardProgress(value => Report( progress, LauncherUpdatePhase.DownloadingClient, $"Downloading client {check.Manifest.Version}: " + $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes", value.BytesReceived, value.TotalBytes)); _ = await _downloader.DownloadAsync( artifact, archive, downloadProgress, cancellationToken) .ConfigureAwait(false); Report( progress, LauncherUpdatePhase.ExtractingClient, "Verifying paths and extracting the client archive..."); IReadOnlyList files = await _extractor.ExtractAsync( archive, staging, cancellationToken) .ConfigureAwait(false); Report( progress, LauncherUpdatePhase.ActivatingClient, $"Atomically activating client {check.Manifest.Version}..."); ClientVersionResolution result = await _versions .PromoteAndActivateUnderLeaseAsync( staging, check.Manifest.Version, _rid, artifact, files, cancellationToken) .ConfigureAwait(false); Report( progress, LauncherUpdatePhase.Completed, $"Client {check.Manifest.Version} installed and activated.", 1, 1); return result; } catch (OperationCanceledException) { Report( progress, LauncherUpdatePhase.Cancelled, "Client update cancelled; the active version was not changed."); throw; } catch (Exception ex) { Report( progress, LauncherUpdatePhase.Failed, $"Client update failed: {ex.Message}"); throw; } finally { VerifiedArtifactDownloader.TryDelete(archive); SafeZipExtractor.TryDeleteDirectory(staging); } } finally { _operationGate.Release(); } } public async Task StageLauncherAsync( LauncherUpdateCheckResult check, IProgress? progress = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(check); ValidateCheck(check); await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { RefuseRunningSessions(); if (check.Manifest.Version <= _launcherVersion) { throw new LauncherUpdateException( $"Launcher {_launcherVersion} is already current."); } Report( progress, LauncherUpdatePhase.DownloadingLauncher, $"Downloading launcher {check.Manifest.Version}..."); var downloadProgress = new ForwardProgress(value => Report( progress, LauncherUpdatePhase.DownloadingLauncher, $"Downloading launcher {check.Manifest.Version}: " + $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes", value.BytesReceived, value.TotalBytes)); try { SelfUpdateStageResult result = await _selfUpdates.StageAsync( check.Manifest, _rid, _launcherTargetDirectory, downloadProgress, cancellationToken) .ConfigureAwait(false); Report( progress, LauncherUpdatePhase.StagingLauncher, result.Status, 1, 1); return result; } catch (OperationCanceledException) { Report( progress, LauncherUpdatePhase.Cancelled, "Launcher update staging cancelled."); throw; } catch (Exception ex) { Report( progress, LauncherUpdatePhase.Failed, $"Launcher update staging failed: {ex.Message}"); throw; } } finally { _operationGate.Release(); } } public async Task RollbackClientAsync( IProgress? progress = null, CancellationToken cancellationToken = default) { await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { RefuseRunningSessions(); Report( progress, LauncherUpdatePhase.RollingBack, "Verifying and activating the previous client version..."); ClientVersionResolution result = await _versions.RollbackAsync( _rid, cancellationToken) .ConfigureAwait(false); Report( progress, LauncherUpdatePhase.Completed, $"Rolled back to client {result.Version}.", 1, 1); return result; } finally { _operationGate.Release(); } } private void ValidateCheck(LauncherUpdateCheckResult check) { if (!string.Equals(check.Rid, _rid, StringComparison.Ordinal) || !check.LauncherVersion.Equals(_launcherVersion)) { throw new LauncherUpdateException( "The update check belongs to a different launcher runtime."); } _ = check.Manifest.RequireClient(_rid); _ = check.Manifest.RequireLauncher(_rid); } private void RefuseRunningSessions() { if (_hasRunningSessions()) { throw new LauncherUpdateException( "Stop every launcher session before installing or rolling back an update."); } } private static string BuildCheckStatus( ReleaseManifest manifest, LauncherVersion? installed, bool clientAvailable, bool launcherAvailable, bool minimumSatisfied) { if (!minimumSatisfied) { return $"Release {manifest.Version} requires launcher " + $"{manifest.MinimumLauncherVersion} or newer."; } if (clientAvailable && launcherAvailable) { return $"Client and launcher {manifest.Version} are available."; } if (clientAvailable) { return installed is null ? $"Client {manifest.Version} is available for installation." : $"Client update {installed} -> {manifest.Version} is available."; } if (launcherAvailable) { return $"Launcher {manifest.Version} is available."; } return "Client and launcher are up to date."; } private static void Report( IProgress? progress, LauncherUpdatePhase phase, string status, long completed = 0, long total = 0) => progress?.Report(new LauncherUpdateProgress( phase, status, completed, total)); private sealed class ForwardProgress(Action callback) : IProgress { public void Report(T value) => callback(value); } }