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"); + } }