diff --git a/.gitignore b/.gitignore index df7b1b1..d1a6960 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,17 @@ obj/ *.suo .vs/ publish/ + +# Runtime client data accidentally left when launcher is run from its bin dir +# (CWD becomes the source tree). These should never be committed. +src/Metin2Launcher/.updates/ +src/Metin2Launcher/bgm/ +src/Metin2Launcher/config/ +src/Metin2Launcher/mark/ +src/Metin2Launcher/pack/ +src/Metin2Launcher/log/ +src/Metin2Launcher/Metin2.exe +src/Metin2Launcher/*.dll +src/Metin2Launcher/*.pyd +src/Metin2Launcher/python314* +src/Metin2Launcher/runtime-key.json diff --git a/src/Metin2Launcher/GameLaunch/GameProcess.cs b/src/Metin2Launcher/GameLaunch/GameProcess.cs index add6aac..1d337b4 100644 --- a/src/Metin2Launcher/GameLaunch/GameProcess.cs +++ b/src/Metin2Launcher/GameLaunch/GameProcess.cs @@ -67,6 +67,21 @@ public static class GameProcess UseShellExecute = false, }; psi.ArgumentList.Add(exePath); + // The default ~/.wine prefix lacks tahoma + d3dx9 + vcrun, which + // makes the client run with invisible fonts and missing renderer + // DLLs. If the caller already set WINEPREFIX, honor it; otherwise + // fall back to the metin-specific prefix prepared by + // m2dev-client/scripts/setup-wine-prefix.sh. + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WINEPREFIX"))) + { + var home = Environment.GetEnvironmentVariable("HOME"); + if (!string.IsNullOrEmpty(home)) + { + var metinPrefix = Path.Combine(home, "metin", "wine-metin"); + if (Directory.Exists(metinPrefix)) + psi.Environment["WINEPREFIX"] = metinPrefix; + } + } } else { 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"); + } } diff --git a/src/Metin2Launcher/Runtime/RuntimeKey.cs b/src/Metin2Launcher/Runtime/RuntimeKey.cs index 25a214d..fc49ef3 100644 --- a/src/Metin2Launcher/Runtime/RuntimeKey.cs +++ b/src/Metin2Launcher/Runtime/RuntimeKey.cs @@ -10,9 +10,9 @@ namespace Metin2Launcher.Runtime; /// through to the game executable via one of the /// mechanisms. /// -/// File layout (runtime-key.json, placed next to the manifest by the release -/// tool, never signed independently — its integrity comes from the signed -/// manifest that references it): +/// Tolerant of two JSON shapes seen in the wild: +/// +/// 1. Native launcher shape (canonical): /// /// { /// "key_id": "2026.04.14-1", @@ -20,32 +20,64 @@ namespace Metin2Launcher.Runtime; /// "sign_pubkey_hex": "<64 hex chars>" /// } /// +/// +/// 2. m2pack export-runtime-key --format json output, where +/// key_id is emitted as an integer and the public key field is +/// named sign_public_key_hex. Extra fields like version and +/// mapping_name are ignored. See +/// metin-server/m2pack-secure#3. /// public sealed class RuntimeKey { - [JsonPropertyName("key_id")] public string KeyId { get; set; } = ""; - - [JsonPropertyName("master_key_hex")] public string MasterKeyHex { get; set; } = ""; - - [JsonPropertyName("sign_pubkey_hex")] public string SignPubkeyHex { get; set; } = ""; - private static readonly JsonSerializerOptions _opts = new() - { - PropertyNameCaseInsensitive = false, - }; - /// Parses a runtime-key.json blob. Throws on malformed or incomplete data. public static RuntimeKey Parse(ReadOnlySpan raw) { - var rk = JsonSerializer.Deserialize(raw, _opts) - ?? throw new InvalidDataException("runtime-key deserialized to null"); + // Go through JsonSerializer.Deserialize<JsonElement> rather than + // JsonDocument.Parse so malformed input throws JsonException (as the + // old path did) rather than its internal JsonReaderException subtype, + // which Assert.Throws<JsonException> in the tests won't match. + var root = JsonSerializer.Deserialize(raw); + if (root.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("runtime-key root must be a JSON object"); + + var rk = new RuntimeKey + { + KeyId = ReadKeyId(root), + MasterKeyHex = ReadString(root, "master_key_hex"), + SignPubkeyHex = ReadString(root, "sign_pubkey_hex", "sign_public_key_hex"), + }; Validate(rk); return rk; } + // key_id can be a plain string or an integer (m2pack CLI emits it as an + // int). Stringify numeric variants so callers always see a string. + private static string ReadKeyId(JsonElement root) + { + if (!root.TryGetProperty("key_id", out var el)) + return ""; + return el.ValueKind switch + { + JsonValueKind.String => el.GetString() ?? "", + JsonValueKind.Number => el.TryGetInt64(out var n) ? n.ToString() : el.GetRawText(), + _ => "", + }; + } + + private static string ReadString(JsonElement root, params string[] names) + { + foreach (var name in names) + { + if (root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String) + return el.GetString() ?? ""; + } + return ""; + } + public static RuntimeKey Load(string path) { var bytes = File.ReadAllBytes(path); diff --git a/src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs b/src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs index 53576b8..76b265a 100644 --- a/src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs @@ -14,6 +14,7 @@ using Metin2Launcher.Localization; using Metin2Launcher.Logging; using Metin2Launcher.Manifest; using Metin2Launcher.Orchestration; +using Metin2Launcher.Runtime; using Velopack; using Velopack.Sources; @@ -63,6 +64,7 @@ public sealed partial class MainWindowViewModel : ObservableObject private LauncherState _state = LauncherState.Idle; private LoadedManifest? _lastManifest; + private RuntimeKey? _lastRuntimeKey; private bool _signatureBlocked; public MainWindowViewModel() @@ -136,6 +138,7 @@ public sealed partial class MainWindowViewModel : ObservableObject if (result is not null) { _lastManifest = result.Manifest; + _lastRuntimeKey = result.RuntimeKey; ApplyState(result.FinalState); await TryFetchNewsAsync(result.Manifest); } @@ -293,7 +296,7 @@ public sealed partial class MainWindowViewModel : ObservableObject { if (!CanPlay) return; ApplyState(LauncherState.Launching); - var ok = GameProcess.Launch(_clientRoot, LauncherConfig.GameExecutable); + var ok = GameProcess.Launch(_clientRoot, LauncherConfig.GameExecutable, _lastRuntimeKey); if (ok) { if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d) diff --git a/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs b/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs index bb7ef70..98fbfe3 100644 --- a/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs +++ b/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs @@ -80,4 +80,27 @@ public class RuntimeKeyTests Assert.Throws(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes("not json"))); } + + // m2pack export-runtime-key emits key_id as an integer and the public + // key field as `sign_public_key_hex`, plus extra version/mapping_name + // fields that are Windows-shared-memory bookkeeping. RuntimeKey must + // tolerate that shape so release pipelines can pipe the CLI output + // directly. See metin-server/m2pack-secure#3. + [Fact] + public void Parse_accepts_m2pack_cli_shape() + { + var json = $$""" + { + "version": 1, + "mapping_name": "Local\\M2PackSharedKeys", + "key_id": 1, + "master_key_hex": "{{ValidHex64}}", + "sign_public_key_hex": "{{ValidHex64}}" + } + """; + var rk = RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)); + Assert.Equal("1", rk.KeyId); + Assert.Equal(ValidHex64, rk.MasterKeyHex); + Assert.Equal(ValidHex64, rk.SignPubkeyHex); + } }