launcher: wire m2pack key delivery, wine prefix, platform filter, and prune #1

Merged
jann merged 5 commits from avalonia-gui into main 2026-04-15 14:17:33 +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);
}
}