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>
316 lines
13 KiB
C#
316 lines
13 KiB
C#
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");
|
||
}
|
||
}
|