From d4b9f56cb3fd8e8d339c10a26492af09dc19f0ca Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 13:15:33 +0200 Subject: [PATCH] launcher: extract update orchestrator with progress events Lifts the manifest fetch / verify / diff / download / apply pipeline out of Program.cs into a reusable UpdateOrchestrator that emits typed LauncherState progress events through IProgress. The existing logic, error handling, and signature-failure semantics are preserved verbatim; the orchestrator just drives a sink instead of the console so the GUI and the headless --nogui path can share one pipeline. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Orchestration/UpdateOrchestrator.cs | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs diff --git a/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs new file mode 100644 index 0000000..8f004ad --- /dev/null +++ b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs @@ -0,0 +1,207 @@ +using Metin2Launcher.Apply; +using Metin2Launcher.Config; +using Metin2Launcher.Logging; +using Metin2Launcher.Manifest; +using Metin2Launcher.Transfer; + +namespace Metin2Launcher.Orchestration; + +public enum LauncherState +{ + Idle, + FetchingManifest, + VerifyingSignature, + Diffing, + Downloading, + Applying, + UpToDate, + OfflineFallback, + SignatureFailed, + Launching, +} + +public sealed class UpdateProgress +{ + public LauncherState State { get; init; } + public double? Percent { get; init; } // 0..100, null = indeterminate + public string? Detail { get; init; } // current file / target path + public LoadedManifest? Manifest { get; init; } +} + +/// +/// Owns the update flow extracted from the original CLI Program.cs. Drives an +/// sink instead of writing to the console. +/// All logic — manifest fetch, signature verify, diff, download, apply — is preserved. +/// +public sealed class UpdateOrchestrator +{ + public sealed record Result(bool Success, LauncherState FinalState, LoadedManifest? Manifest); + + private readonly string _clientRoot; + private readonly string _stagingDir; + private readonly LauncherSettings _settings; + private readonly HttpClient _http; + + public UpdateOrchestrator(string clientRoot, LauncherSettings settings, HttpClient http) + { + _clientRoot = clientRoot; + _stagingDir = Path.Combine(clientRoot, LauncherConfig.StateDirName, LauncherConfig.StagingDirName); + _settings = settings; + _http = http; + } + + public async Task RunAsync(IProgress progress, CancellationToken ct) + { + LoadedManifest? loaded = null; + + // 1. fetch manifest + progress.Report(new UpdateProgress { State = LauncherState.FetchingManifest }); + var loader = new ManifestLoader(_http); + try + { + using var fetchCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + fetchCts.CancelAfter(LauncherConfig.ManifestFetchTimeout); + loaded = await loader.FetchAsync( + _settings.EffectiveManifestUrl(), + _settings.EffectiveSignatureUrl(), + fetchCts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Warn("manifest fetch failed: " + ex.Message); + progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100 }); + return new Result(false, LauncherState.OfflineFallback, null); + } + + // 2. verify signature + progress.Report(new UpdateProgress { State = LauncherState.VerifyingSignature, Manifest = loaded }); + if (!SignatureVerifier.Verify(loaded.RawBytes, loaded.Signature, LauncherConfig.PublicKeyHex)) + { + Log.Error("manifest signature verification FAILED — refusing to launch"); + progress.Report(new UpdateProgress { State = LauncherState.SignatureFailed, Percent = 0, Manifest = loaded }); + throw new ManifestSignatureException( + "Ed25519 signature over manifest.json did not verify against the launcher's public key"); + } + + var manifest = loaded.Manifest; + Log.Info($"manifest {manifest.Version} verified, {manifest.Files.Count} files"); + + // 3. diff + progress.Report(new UpdateProgress { State = LauncherState.Diffing, Manifest = loaded }); + + var platform = OperatingSystem.IsWindows() ? "windows" : "linux"; + var applicable = manifest.Files.Where(f => f.AppliesTo(platform)).ToList(); + + var needed = new List<(ManifestFile File, string FinalPath, string StagingPath)>(); + long totalBytes = 0; + foreach (var file in applicable) + { + ct.ThrowIfCancellationRequested(); + string finalPath, stagingPath; + try + { + finalPath = PathSafety.ResolveInside(_clientRoot, file.Path); + stagingPath = PathSafety.ResolveInside(_stagingDir, file.Path); + } + catch (UnsafePathException ex) + { + if (file.IsRequired) + { + Log.Error($"required file rejected by path safety: {ex.Message}"); + progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded }); + return new Result(false, LauncherState.OfflineFallback, loaded); + } + Log.Warn($"optional file rejected by path safety: {ex.Message}"); + continue; + } + + var localHash = FileHasher.HashFileOrNull(finalPath); + if (localHash == file.Sha256) + continue; + + needed.Add((file, finalPath, stagingPath)); + totalBytes += file.Size; + } + + if (needed.Count == 0) + { + Log.Info("client already up to date"); + var currentManifestPath = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json"); + try { File.WriteAllBytes(currentManifestPath, loaded.RawBytes); } catch { } + progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded }); + return new Result(true, LauncherState.UpToDate, loaded); + } + + // 4. download + progress.Report(new UpdateProgress { State = LauncherState.Downloading, Percent = 0, Manifest = loaded }); + + var downloader = new BlobDownloader(_http, _settings.EffectiveBlobBaseUrl(), LauncherConfig.MaxBlobRetries); + var toApply = new List(); + + long completedBytes = 0; + int idx = 0; + foreach (var (file, finalPath, stagingPath) in needed) + { + ct.ThrowIfCancellationRequested(); + idx++; + + long lastReported = 0; + var perFileProgress = new Progress(b => + { + var delta = b - lastReported; + lastReported = b; + var current = Interlocked.Add(ref completedBytes, delta); + var pct = totalBytes > 0 ? (double)current / totalBytes * 100.0 : 0; + progress.Report(new UpdateProgress + { + State = LauncherState.Downloading, + Percent = Math.Min(100, pct), + Detail = $"{file.Path} ({idx}/{needed.Count})", + Manifest = loaded, + }); + }); + + try + { + await downloader.DownloadAsync(file.Sha256, file.Size, stagingPath, ct, perFileProgress).ConfigureAwait(false); + toApply.Add(new AtomicApplier.StagedFile(stagingPath, finalPath)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (file.IsRequired) + { + Log.Error($"required file {file.Path} failed to download", ex); + progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded }); + return new Result(false, LauncherState.OfflineFallback, loaded); + } + Log.Warn($"optional file {file.Path} skipped: {ex.Message}"); + // make sure the bytes from this file don't leave the bar stuck low + Interlocked.Add(ref completedBytes, file.Size - lastReported); + } + } + + // 5. apply + progress.Report(new UpdateProgress { State = LauncherState.Applying, Percent = 0, Manifest = loaded }); + try + { + int applied = 0; + // AtomicApplier doesn't expose progress today; report begin and end. + AtomicApplier.Apply(toApply); + applied = toApply.Count; + progress.Report(new UpdateProgress { State = LauncherState.Applying, Percent = 100, Detail = $"{applied} files", Manifest = loaded }); + } + catch (Exception ex) + { + Log.Error("apply failed", ex); + progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded }); + return new Result(false, LauncherState.OfflineFallback, loaded); + } + + var current = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json"); + try { File.WriteAllBytes(current, loaded.RawBytes); } catch { } + + Log.Info($"update complete: {toApply.Count} files applied"); + progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded }); + return new Result(true, LauncherState.UpToDate, loaded); + } +}