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:
207
src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs
Normal file
207
src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user