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<UpdateProgress>. 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) <noreply@anthropic.com>
This commit is contained in:
Jan Nedbal
2026-04-14 13:15:33 +02:00
parent 1e6ab386cb
commit d4b9f56cb3

View File

@@ -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; }
}
/// <summary>
/// Owns the update flow extracted from the original CLI Program.cs. Drives an
/// <see cref="IProgress{UpdateProgress}"/> sink instead of writing to the console.
/// All logic — manifest fetch, signature verify, diff, download, apply — is preserved.
/// </summary>
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<Result> RunAsync(IProgress<UpdateProgress> 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<AtomicApplier.StagedFile>();
long completedBytes = 0;
int idx = 0;
foreach (var (file, finalPath, stagingPath) in needed)
{
ct.ThrowIfCancellationRequested();
idx++;
long lastReported = 0;
var perFileProgress = new Progress<long>(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);
}
}