Files
metin-launcher/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs
Jan Nedbal d2775bcfd8 orchestration: target windows platform + prune stale files
Two fixes addressing review feedback on the previous avalonia-gui PR round.

1. Platform filter was using host OS (`IsWindows() ? "windows" : "linux"`),
   which dropped all `platform=windows` manifest entries whenever the
   launcher ran on Linux. Since the client is always a Windows PE binary
   launched through Wine on Linux hosts, the target platform we apply is
   always `"windows"` regardless of host. Before this, the orchestrator
   silently skipped Metin2.exe and the python314 / openssl / mingw runtime
   DLLs on Linux, leaving an unbootable install dir.

2. Added `PruneStaleFiles` that walks clientRoot after every successful
   update (both the normal apply path and the already-up-to-date
   short-circuit) and deletes any file not in the manifest. Without it,
   switching from a dirty release to a leaner one — e.g. dropping legacy
   .pck after a .m2p migration — left orphaned files on disk and inflated
   the install dir.

   The keep set is `manifest.files ∪ {manifest.launcher.path}`. Per the
   manifest spec in `m2dev-client/docs/update-manifest.md`, the top-level
   launcher entry is privileged and never listed in files; it must
   survive prune so a correctly-authored manifest does not delete the
   updater itself (caught in Jakub's review of the previous round).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:22:30 +02:00

316 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Metin2Launcher.Apply;
using Metin2Launcher.Config;
using Metin2Launcher.Formats;
using Metin2Launcher.Logging;
using Metin2Launcher.Manifest;
using Metin2Launcher.Runtime;
using Metin2Launcher.Telemetry;
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,
IReleaseFormat? Format = null,
RuntimeKey? RuntimeKey = null);
private readonly string _clientRoot;
private readonly string _stagingDir;
private readonly LauncherSettings _settings;
private readonly HttpClient _http;
private readonly ClientAppliedReporter? _reporter;
public UpdateOrchestrator(
string clientRoot,
LauncherSettings settings,
HttpClient http,
ClientAppliedReporter? reporter = null)
{
_clientRoot = clientRoot;
_stagingDir = Path.Combine(clientRoot, LauncherConfig.StateDirName, LauncherConfig.StagingDirName);
_settings = settings;
_http = http;
_reporter = reporter;
}
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, format={manifest.EffectiveFormat}, {manifest.Files.Count} files");
// 2b. dispatch on release format
IReleaseFormat format;
try
{
format = ReleaseFormatFactory.Resolve(manifest.EffectiveFormat);
}
catch (NotSupportedException ex)
{
Log.Error("unsupported release format, falling back offline: " + ex.Message);
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded);
}
// 3. diff
progress.Report(new UpdateProgress { State = LauncherState.Diffing, Manifest = loaded });
// The client is always a Windows PE binary launched through Wine on
// Linux hosts, so the manifest "platform" we apply is always "windows"
// regardless of host OS. Using host OS would drop Metin2.exe and the
// python314 / openssl / mingw runtime DLLs whenever the launcher runs
// on Linux, leaving an unbootable install dir.
const string platform = "windows";
var applicable = format.FilterApplicable(manifest, platform);
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, format);
}
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 { }
// Even on no-op we still need to re-load the runtime key so the
// game process can receive it on this launch.
var noopOutcome = new ReleaseOutcome();
try { format.OnApplied(_clientRoot, loaded, noopOutcome); }
catch (Exception ex) { Log.Warn("format OnApplied (no-op path) failed: " + ex.Message); }
PruneStaleFiles(manifest);
progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
return new Result(true, LauncherState.UpToDate, loaded, format, noopOutcome.RuntimeKey);
}
// 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, format);
}
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, format);
}
var current = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json");
try { File.WriteAllBytes(current, loaded.RawBytes); } catch { }
// 6. format-specific post-apply hook (e.g. load the m2pack runtime key)
var outcome = new ReleaseOutcome();
try
{
format.OnApplied(_clientRoot, loaded, outcome);
}
catch (Exception ex)
{
Log.Error("format OnApplied hook failed", ex);
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded, format);
}
// 7. opt-in telemetry ping (bounded, best-effort, never blocks)
if (_reporter is not null && _reporter.IsEnabled)
{
try
{
await _reporter.ReportAsync(format.Name, manifest.Version, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Warn("telemetry reporter threw: " + ex.Message);
}
}
PruneStaleFiles(manifest);
Log.Info($"update complete: {toApply.Count} files applied (format={format.Name})");
progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
return new Result(true, LauncherState.UpToDate, loaded, format, outcome.RuntimeKey);
}
// Walk clientRoot and delete any file not in the manifest. Skips the
// .updates state dir. Called after both the normal apply path and the
// already-up-to-date short-circuit so switching to a leaner release
// does not leave orphaned files on disk.
//
// The keep set is files {manifest.launcher.path}. Per the manifest
// spec (m2dev-client/docs/update-manifest.md), the top-level launcher
// entry is privileged and is never listed in files; it must survive
// prune so a correctly-authored manifest does not delete the updater.
private void PruneStaleFiles(Manifest.Manifest manifest)
{
var keepRel = new HashSet<string>(
manifest.Files.Select(f => f.Path.Replace('/', Path.DirectorySeparatorChar)),
StringComparer.OrdinalIgnoreCase);
if (manifest.Launcher?.Path is { Length: > 0 } launcherPath)
{
keepRel.Add(launcherPath.Replace('/', Path.DirectorySeparatorChar));
}
int pruned = 0;
try
{
var rootFull = Path.GetFullPath(_clientRoot);
var stateDirFull = Path.GetFullPath(Path.Combine(_clientRoot, LauncherConfig.StateDirName));
foreach (var path in Directory.EnumerateFiles(rootFull, "*", SearchOption.AllDirectories))
{
if (path.StartsWith(stateDirFull, StringComparison.Ordinal)) continue;
var rel = Path.GetRelativePath(rootFull, path);
if (keepRel.Contains(rel)) continue;
try { File.Delete(path); pruned++; }
catch (Exception ex) { Log.Warn($"failed to prune {rel}: {ex.Message}"); }
}
}
catch (Exception ex)
{
Log.Warn("prune walk failed: " + ex.Message);
}
if (pruned > 0) Log.Info($"pruned {pruned} stale files not in manifest");
}
}