From d2775bcfd89c528244df368c88f94dd1a06e011a Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 15 Apr 2026 12:22:30 +0200 Subject: [PATCH] orchestration: target windows platform + prune stale files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Orchestration/UpdateOrchestrator.cs | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs index 57c3468..8a7de44 100644 --- a/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs +++ b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs @@ -116,7 +116,12 @@ public sealed class UpdateOrchestrator // 3. diff progress.Report(new UpdateProgress { State = LauncherState.Diffing, Manifest = loaded }); - var platform = OperatingSystem.IsWindows() ? "windows" : "linux"; + // 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)>(); @@ -162,6 +167,8 @@ public sealed class UpdateOrchestrator 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); } @@ -260,8 +267,49 @@ public sealed class UpdateOrchestrator } } + 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( + 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"); + } }