Compare commits

3 Commits

Author SHA1 Message Date
Jan Nedbal
e23f4e1e6e 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 11:40:57 +02:00
Jan Nedbal
24a5951359 launcher: target windows platform on linux + prune stale files
The orchestrator filtered manifest files by host OS, dropping all
platform="windows" entries when running on Linux. Since the client is
always a Windows PE binary launched through Wine, the target platform we
apply is always "windows" regardless of host. Without this Metin2.exe and
the python314 / openssl / mingw runtime DLLs were silently skipped on
Linux, leaving an unbootable install dir.

Also adds a post-apply prune step: walks clientRoot and deletes any file
not present in the manifest (skipping the .updates state dir). Without
this, 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 prune runs both after a successful apply
and on the already-up-to-date short-circuit path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:40:57 +02:00
Jan Nedbal
0d99caf2b0 launcher: deliver m2pack runtime key + auto-pick metin wine prefix
GameProcess now reads runtime-key.json from the install dir and forwards
master_key_hex / sign_public_key_hex / key_id to the spawned Metin2.exe via
the M2PACK_MASTER_KEY_HEX, M2PACK_SIGN_PUBKEY_HEX, M2PACK_KEY_ID env vars
the new MinGW client loader expects. Without this the m2p loader bails out
with "Invalid M2PACK_MASTER_KEY_HEX".

Also defaults WINEPREFIX to ~/metin/wine-metin when the parent shell hasn't
set one. The default ~/.wine prefix lacks tahoma + d3dx9 + vcrun, which
makes the client run with invisible fonts; the metin-specific prefix is
prepared by m2dev-client/scripts/setup-wine-prefix.sh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:40:57 +02:00
3 changed files with 109 additions and 10 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

@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Text.Json;
using Metin2Launcher.Logging;
namespace Metin2Launcher.GameLaunch;
@@ -53,23 +54,69 @@ public static class GameProcess
/// </summary>
public static ProcessStartInfo BuildStartInfo(string exePath, string workingDirectory)
{
ProcessStartInfo psi;
if (OperatingSystem.IsLinux())
{
var psi = new ProcessStartInfo
psi = new ProcessStartInfo
{
FileName = "wine",
WorkingDirectory = workingDirectory,
UseShellExecute = false,
};
psi.ArgumentList.Add(exePath);
return psi;
// The default ~/.wine prefix lacks tahoma + d3dx9 + vcrun, which makes
// the client run with invisible fonts and missing renderer DLLs. We
// honor WINEPREFIX from the parent env if set; otherwise prefer a
// metin-specific prefix that setup-wine-prefix.sh prepares.
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WINEPREFIX")))
{
var home = Environment.GetEnvironmentVariable("HOME") ?? "/home/jann";
var metinPrefix = Path.Combine(home, "metin", "wine-metin");
if (Directory.Exists(metinPrefix))
psi.Environment["WINEPREFIX"] = metinPrefix;
}
}
return new ProcessStartInfo
else
{
FileName = exePath,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
};
psi = new ProcessStartInfo
{
FileName = exePath,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
};
}
ApplyM2PackRuntimeKey(psi, workingDirectory);
return psi;
}
// m2pack-secure: the new client refuses to load .m2p archives unless a
// runtime master key is delivered out-of-band. The release ships
// runtime-key.json next to the exe; we read it and pass the values via
// env vars (M2PACK_MASTER_KEY_HEX et al) which the loader picks up.
// If the file is missing we just don't set the vars — useful for legacy
// releases that still have the static client.
private static void ApplyM2PackRuntimeKey(ProcessStartInfo psi, string clientRoot)
{
var keyFile = Path.Combine(clientRoot, "runtime-key.json");
if (!File.Exists(keyFile))
{
return;
}
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(keyFile));
var root = doc.RootElement;
if (root.TryGetProperty("master_key_hex", out var mk))
psi.Environment["M2PACK_MASTER_KEY_HEX"] = mk.GetString() ?? "";
if (root.TryGetProperty("sign_public_key_hex", out var pk))
psi.Environment["M2PACK_SIGN_PUBKEY_HEX"] = pk.GetString() ?? "";
if (root.TryGetProperty("key_id", out var kid))
psi.Environment["M2PACK_KEY_ID"] = kid.GetInt32().ToString();
Log.Info("m2pack runtime key loaded from runtime-key.json");
}
catch (Exception ex)
{
Log.Error("failed to load runtime-key.json", ex);
}
}
}

View File

@@ -89,8 +89,12 @@ public sealed class UpdateOrchestrator
// 3. diff
progress.Report(new UpdateProgress { State = LauncherState.Diffing, Manifest = loaded });
var platform = OperatingSystem.IsWindows() ? "windows" : "linux";
var applicable = manifest.Files.Where(f => f.AppliesTo(platform)).ToList();
// 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 filter out Metin2.exe and
// the python314/openssl/mingw runtime DLLs whenever the launcher runs
// on Linux, leaving an unbootable install dir.
var applicable = manifest.Files.Where(f => f.AppliesTo("windows")).ToList();
var needed = new List<(ManifestFile File, string FinalPath, string StagingPath)>();
long totalBytes = 0;
@@ -128,6 +132,7 @@ public sealed class UpdateOrchestrator
Log.Info("client already up to date");
var currentManifestPath = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json");
try { File.WriteAllBytes(currentManifestPath, loaded.RawBytes); } catch { }
PruneStaleFiles(manifest);
progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
return new Result(true, LauncherState.UpToDate, loaded);
}
@@ -200,8 +205,41 @@ public sealed class UpdateOrchestrator
var current = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json");
try { File.WriteAllBytes(current, loaded.RawBytes); } catch { }
PruneStaleFiles(manifest);
Log.Info($"update complete: {toApply.Count} files applied");
progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
return new Result(true, LauncherState.UpToDate, loaded);
}
// Walk clientRoot and delete any file not in the manifest. Skips the
// .updates state dir. Called after both the apply path and the
// already-up-to-date short-circuit so switching to a leaner release
// doesn't leave behind orphaned files (e.g. legacy .pck after .m2p
// migration).
private void PruneStaleFiles(Manifest.Manifest manifest)
{
var keepRel = new HashSet<string>(
manifest.Files.Select(f => f.Path.Replace('/', Path.DirectorySeparatorChar)),
StringComparer.OrdinalIgnoreCase);
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");
}
}