Compare commits

6 Commits

Author SHA1 Message Date
Jan Nedbal
db59f4963c runtime: tolerate m2pack export-runtime-key JSON shape
The canonical launcher runtime-key.json shape is
  { key_id: string, master_key_hex, sign_pubkey_hex }
but `m2pack export-runtime-key --format json` emits
  { version, mapping_name, key_id: int, master_key_hex, sign_public_key_hex }
because its JSON is really a dump of the Windows shared-memory struct.

Parsing the CLI output with the old strict deserializer throws
JsonException on key_id (int != string) and silently drops the public
key field (name mismatch), after which Validate() rejects the key as
not 64 hex chars and the m2pack release fails to boot with
"runtime master key with key_id=1 required for 'pack/root.m2p'".

Hit this tonight during the 2026.04.15-m2pack-v2 release and worked
around it by hand-writing runtime-key.json. Fix: parse into a
JsonElement and extract fields tolerantly — key_id accepts either a
JSON string or a JSON number (stringified), and the pubkey field is
looked up under both "sign_pubkey_hex" and "sign_public_key_hex".

Added a test covering the m2pack CLI shape end to end. Also kept the
malformed-input path on JsonSerializer.Deserialize so it still throws
JsonException (JsonDocument.Parse throws its internal subtype which
breaks Assert.Throws<JsonException>).

Tracked separately as metin-server/m2pack-secure#3 — the m2pack side
should also align its JSON to the canonical shape; this commit is the
client-side belt to the server-side suspenders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:03:47 +02:00
Jan Nedbal
ac0034fc51 gitignore: exclude runtime client data dropped into source tree
When the launcher is run from its bin/ dir during local dev, CWD
becomes the source tree and the orchestrator stages the client release
into src/Metin2Launcher/{pack,bgm,mark,config,.updates,...}. None of
that should ever land in git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:22:30 +02:00
Jan Nedbal
8f6f378a23 gui: thread m2pack runtime key from orchestrator result into play command
Headless Program.cs already passes `result.RuntimeKey` to
GameProcess.Launch, but the GUI Play command dropped it and spawned
Metin2.exe without any env vars. Any m2p-capable client then hit
"Invalid M2PACK_MASTER_KEY_HEX" and refused to load .m2p archives.

Cache the runtime key on the view model when StartUpdateAsync completes
and pass it through on Play. Matches the headless path and the
EnvVarKeyDelivery wiring already in main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:22:30 +02:00
Jan Nedbal
027786a79d game: auto-pick metin wine prefix when WINEPREFIX is unset
The default ~/.wine prefix lacks tahoma + d3dx9 + vcrun, which makes the
client run with invisible fonts and missing renderer DLLs — exactly what
Jan hit when running the launcher against a fresh install dir on Fedora.

If the parent shell already set WINEPREFIX, honor it. Otherwise fall
back to ~/metin/wine-metin, which is what
m2dev-client/scripts/setup-wine-prefix.sh prepares with the right
runtime deps. The fallback is guarded on the dir existing, so
deployments without that setup are a no-op rather than a broken prefix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:22:30 +02:00
Jan Nedbal
d2775bcfd8 orchestration: target windows platform + prune stale files
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>
2026-04-15 12:22:30 +02:00
Jan Nedbal
9ffae5c7d9 Merge branch 'claude/m2pack-launcher'
Add m2pack-secure release format support to the launcher without
removing the legacy JSON-blob path. Manifests now carry an optional
'format' field; a ReleaseFormatFactory dispatches to either
LegacyJsonBlobFormat (default, unchanged behaviour) or M2PackFormat
(new, treats .m2p files as opaque content-addressed blobs and loads
a runtime-key.json sidecar to hand off to the game via env vars).
Unknown formats fail closed.

Adds RuntimeKey model + EnvVarKeyDelivery (cross-platform) + a stub
SharedMemoryKeyDelivery for the future Windows path, an opt-in
ClientAppliedReporter for Morion2 telemetry with 5s timeout and
graceful fallback, plus 60 new tests (total 92) covering every branch.
2026-04-14 21:18:12 +02:00
6 changed files with 152 additions and 17 deletions

14
.gitignore vendored
View File

@@ -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

View File

@@ -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
{

View File

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

View File

@@ -10,9 +10,9 @@ namespace Metin2Launcher.Runtime;
/// through to the game executable via one of the <see cref="IRuntimeKeyDelivery"/>
/// 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):
/// <code>
/// {
/// "key_id": "2026.04.14-1",
@@ -20,32 +20,64 @@ namespace Metin2Launcher.Runtime;
/// "sign_pubkey_hex": "&lt;64 hex chars&gt;"
/// }
/// </code>
///
/// 2. <c>m2pack export-runtime-key --format json</c> output, where
/// <c>key_id</c> is emitted as an integer and the public key field is
/// named <c>sign_public_key_hex</c>. Extra fields like <c>version</c> and
/// <c>mapping_name</c> are ignored. See
/// <c>metin-server/m2pack-secure#3</c>.
/// </summary>
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,
};
/// <summary>Parses a runtime-key.json blob. Throws on malformed or incomplete data.</summary>
public static RuntimeKey Parse(ReadOnlySpan<byte> raw)
{
var rk = JsonSerializer.Deserialize<RuntimeKey>(raw, _opts)
?? throw new InvalidDataException("runtime-key deserialized to null");
// Go through JsonSerializer.Deserialize&lt;JsonElement&gt; rather than
// JsonDocument.Parse so malformed input throws JsonException (as the
// old path did) rather than its internal JsonReaderException subtype,
// which Assert.Throws&lt;JsonException&gt; in the tests won't match.
var root = JsonSerializer.Deserialize<JsonElement>(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);

View File

@@ -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)

View File

@@ -80,4 +80,27 @@ public class RuntimeKeyTests
Assert.Throws<System.Text.Json.JsonException>(() =>
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);
}
}