Compare commits
5 Commits
9ffae5c7d9
...
db59f4963c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db59f4963c | ||
|
|
ac0034fc51 | ||
|
|
8f6f378a23 | ||
|
|
027786a79d | ||
|
|
d2775bcfd8 |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "<64 hex chars>"
|
||||
/// }
|
||||
/// </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<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<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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user