diff --git a/src/Metin2Launcher/Assets/Branding/morion2-logo.png b/src/Metin2Launcher/Assets/Branding/morion2-logo.png new file mode 100644 index 0000000..400d197 Binary files /dev/null and b/src/Metin2Launcher/Assets/Branding/morion2-logo.png differ diff --git a/src/Metin2Launcher/Assets/Branding/social-discord.png b/src/Metin2Launcher/Assets/Branding/social-discord.png new file mode 100644 index 0000000..a1719a5 Binary files /dev/null and b/src/Metin2Launcher/Assets/Branding/social-discord.png differ diff --git a/src/Metin2Launcher/Assets/Branding/social-facebook.png b/src/Metin2Launcher/Assets/Branding/social-facebook.png new file mode 100644 index 0000000..5db0df3 Binary files /dev/null and b/src/Metin2Launcher/Assets/Branding/social-facebook.png differ diff --git a/src/Metin2Launcher/Assets/Branding/social-instagram.png b/src/Metin2Launcher/Assets/Branding/social-instagram.png new file mode 100644 index 0000000..d61fd49 Binary files /dev/null and b/src/Metin2Launcher/Assets/Branding/social-instagram.png differ diff --git a/src/Metin2Launcher/Assets/Branding/social-youtube.png b/src/Metin2Launcher/Assets/Branding/social-youtube.png new file mode 100644 index 0000000..a5153f8 Binary files /dev/null and b/src/Metin2Launcher/Assets/Branding/social-youtube.png differ diff --git a/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs index 8a7de44..178df20 100644 --- a/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs +++ b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs @@ -274,15 +274,29 @@ public sealed class UpdateOrchestrator 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. + // Delete files that were installed by a previous manifest but are no + // longer in the current one. Called after both the normal apply path + // and the already-up-to-date short-circuit so switching from a fat + // release to a lean one doesn't 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. + // IMPORTANT SAFETY CONTRACT (see the post-mortem in commit message for + // why this matters): prune is STRICTLY scoped to files listed in a + // previous manifest that the launcher itself applied. It is NEVER a + // recursive walk of clientRoot. The earlier version of this method did + // a recursive walk and would have happily deleted a user's home + // directory if they ran the launcher from ~/ instead of a dedicated + // install dir. Fixed by switching to a content-addressed ledger: + // + // .updates/applied-files.txt — newline list of relative paths this + // launcher has ever written into + // clientRoot. + // + // On prune: read the ledger, subtract the current manifest's file set + // (plus manifest.launcher.path), delete each leftover. Then rewrite + // the ledger to contain exactly the current manifest's file set. + // + // Files we never wrote are never considered, so there is no blast + // radius beyond what the launcher itself created. private void PruneStaleFiles(Manifest.Manifest manifest) { var keepRel = new HashSet( @@ -292,24 +306,65 @@ public sealed class UpdateOrchestrator { keepRel.Add(launcherPath.Replace('/', Path.DirectorySeparatorChar)); } - int pruned = 0; + + var ledgerPath = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "applied-files.txt"); + + var previouslyApplied = new HashSet(StringComparer.OrdinalIgnoreCase); 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 (File.Exists(ledgerPath)) { - 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}"); } + foreach (var line in File.ReadAllLines(ledgerPath)) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0) continue; + previouslyApplied.Add(trimmed.Replace('/', Path.DirectorySeparatorChar)); + } } } catch (Exception ex) { - Log.Warn("prune walk failed: " + ex.Message); + Log.Warn($"failed to read applied-files ledger at {ledgerPath}: {ex.Message}"); + previouslyApplied.Clear(); + } + + int pruned = 0; + if (previouslyApplied.Count > 0) + { + foreach (var rel in previouslyApplied) + { + if (keepRel.Contains(rel)) continue; + // Defense in depth: refuse to touch anything that escapes + // clientRoot via symlink or path traversal. PathSafety rejects + // any rel that resolves outside clientRoot. + string absolute; + try { absolute = PathSafety.ResolveInside(_clientRoot, rel.Replace(Path.DirectorySeparatorChar, '/')); } + catch (UnsafePathException ex) + { + Log.Warn($"prune refusing unsafe path {rel}: {ex.Message}"); + continue; + } + if (!File.Exists(absolute)) continue; + try { File.Delete(absolute); pruned++; } + catch (Exception ex) { Log.Warn($"failed to prune {rel}: {ex.Message}"); } + } } if (pruned > 0) Log.Info($"pruned {pruned} stale files not in manifest"); + + // Rewrite ledger to match the current manifest. Launcher path is + // included so a future run doesn't try to delete the updater. + try + { + var stateDir = Path.Combine(_clientRoot, LauncherConfig.StateDirName); + Directory.CreateDirectory(stateDir); + var lines = keepRel + .Select(r => r.Replace(Path.DirectorySeparatorChar, '/')) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase); + File.WriteAllLines(ledgerPath, lines); + } + catch (Exception ex) + { + Log.Warn($"failed to write applied-files ledger at {ledgerPath}: {ex.Message}"); + } } } diff --git a/src/Metin2Launcher/UI/MainWindow.axaml b/src/Metin2Launcher/UI/MainWindow.axaml index 9d20883..02de444 100644 --- a/src/Metin2Launcher/UI/MainWindow.axaml +++ b/src/Metin2Launcher/UI/MainWindow.axaml @@ -1,252 +1,291 @@ - - - - - - - + + - - + + + - - - + + + - - - - - - - - + + + + + + + + + + + + + + - - - - + + + + - - - - + - - + + - - - - - - - + + - - - + + + + + + + + + - -