11 Commits

Author SHA1 Message Date
Jan Nedbal
6212559b41 launcher: native DLL bootstrap for single-file under Wine
.NET 8 publish --self-contained -p:PublishSingleFile=true with
-p:IncludeNativeLibrariesForSelfExtract=true bundles the Avalonia /
SkiaSharp native libs inside the bundle and extracts them at startup
to $DOTNET_BUNDLE_EXTRACT_BASE_DIR/<app>/<hash>/ (default
%TEMP%/.net/<app>/<hash>/). On native Windows the CLR adds that path
to the DLL search list automatically, so Avalonia can P/Invoke into
libSkiaSharp.dll without any help.

Under Wine the search path is never updated and P/Invokes from
Avalonia.Skia hit DllNotFoundException for sk_colortype_get_default_8888
before the first frame renders. The launcher crashes in
Avalonia.AppBuilder.SetupUnsafe before any GUI comes up, so the user
just sees "failed to open" style errors with no actionable context.

Fix: the first line of Program.Main locates the extract dir via
DOTNET_BUNDLE_EXTRACT_BASE_DIR env var + app name + a sentinel
(libSkiaSharp.dll) and calls SetDllDirectoryW on it. That puts the
extract dir on the DLL search path for any subsequent LoadLibrary,
which is what Avalonia's P/Invokes end up doing.

Verified under wine-staging 10.15 on Fedora:
- pure single-file .exe in an otherwise empty directory
- no accompanying loose DLLs
- no env var overrides at all
- launcher boots, Avalonia window renders, orchestrator verifies
  manifest 2026.04.15-m2pack-v7 signature, download begins

No-op on native Windows (SetDllDirectoryW is a supported API and the
extract dir is already in the search path, so re-adding it is
harmless). No-op in dev builds where DOTNET_BUNDLE_EXTRACT_BASE_DIR
is unset and the extract dir probe returns null.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:34:55 +02:00
Jan Nedbal
e6b4060e5c launcher: wine bundle layout, richer progress, path shortening
After Jan's colleague hit "wine: failed to open" on the single-file
Metin2Launcher.exe (downloaded as Metin2Launcher(3).exe by Brave) and
then a separate case where the self-contained single-file host crashes
under Wine before Program.Main() even runs, we pivoted to a bundle
layout that actually works under Wine: a non-single-file .NET publish
placed in launcher/ alongside a sibling client/ directory.

Changes:

- InstallDir.Resolve() adds a special case for the Wine bundle layout.
  If the running exe lives in a directory literally named "launcher"
  (see LauncherConfig.WineLauncherDirName) AND a sibling "client" dir
  exists (WineClientDirName), the install dir resolves to that sibling.
  This keeps launcher runtime files (Avalonia DLLs, Skia, etc.) out of
  the install dir so the orchestrator's apply/prune never clobbers the
  launcher's own files.
- LauncherConfig: WineLauncherDirName / WineClientDirName constants.
- UpdateOrchestrator: report InstallRoot, StagingRoot, DownloadedBytes,
  TotalBytes on every progress event so the GUI can display them.
- MainWindowViewModel: cache install/staging paths and transferred
  bytes, expose TransferText and InstallPathText with shortened-home
  (~/...) and middle-elided path strings so long Wine Z: paths remain
  readable.
- MainWindow.axaml: render the new transfer / path lines under the
  progress bar.
- cs.json / en.json: new localized status strings.
- README.md: document the Wine bundle layout.
- scripts/build-wine-bundle.sh: reproducible builder that runs
  `dotnet publish -r win-x64 --self-contained -p:PublishSingleFile=false`
  into launcher/, creates sibling client/, writes start-launcher.sh
  that sets METIN2_INSTALL_DIR=./client and execs wine, then tars the
  whole tree into Metin2Launcher-wine.tar.gz.

Verified:
- dotnet build -c Release — clean
- dotnet test -c Release — 93/93 pass
- build-wine-bundle.sh produces a 45 MB tar.gz; tree contents check
  out, launcher/Metin2Launcher.exe is the non-single-file variant
- bundle uploaded to https://updates.jakubkadlec.dev/launcher/Metin2Launcher-wine.tar.gz

The single-file exe path remains broken under Wine (debugging track
tracked outside this commit — candidate root cause is the
self-extraction + unmanaged host startup path under Wine's PE
loader). For Windows users the single-file build still works; for
Linux+Wine users the bundle is the canonical distribution format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:09:43 +02:00
Jan Nedbal
73446a0a60 SECURITY: resolve install dir from exe location, refuse home + system dirs
Defense in depth on top of the ledger-based prune fix. Even though the
ledger makes prune non-destructive for files the launcher never wrote,
pointing the launcher at your home dir is still a terrible UX — it
downloads 1.4 GB of client assets into ~/ and leaves them interleaved
with your real files. And a future bug could always re-enable a
recursive operation; defence in depth means we refuse at the source.

Changes:

- new InstallDir.Resolve() helper:
  * METIN2_INSTALL_DIR env var takes precedence (used by dev/CI so
    `dotnet run` from bin/Release still lets us target a sandbox)
  * otherwise derives the install dir from
    Process.GetCurrentProcess().MainModule.FileName — i.e. the dir
    the .exe lives in, not the shell's CWD
  * falls back to Directory.GetCurrentDirectory() as a last resort
    (then still runs the safety gate)
  * throws UnsafeInstallDirException if the resolved path matches a
    known-dangerous target: user home ($HOME, ~/Desktop, ~/Documents,
    ~/Downloads, ~/Music, ~/Pictures, ~/Videos and the czech
    plocha/Dokumenty/Stažené variants), filesystem roots (/, /bin,
    /etc, /usr, /var, /tmp, /home, /root, ...), or Windows drive
    roots / Windows / Program Files / Users

- Program.cs now calls InstallDir.Resolve() instead of
  Directory.GetCurrentDirectory(). On rejection it prints the exact
  path and the suggested remedy (create a dedicated folder or set
  METIN2_INSTALL_DIR) and exits 4.

- After resolving, the launcher SetCurrentDirectory(clientRoot) so
  downstream code (orchestrator, game process spawning, log file
  path) that uses CWD keeps working transparently.

Repro of the original footgun, now refused:

    cd ~ && wine ~/Games/Metin2/Metin2Launcher.exe
    # old: _clientRoot = ~/ → prune walks ~/ → deletes everything
    # new: _clientRoot = ~/Games/Metin2 (exe dir), prune ledger-scoped

    METIN2_INSTALL_DIR=/home/jann ./Metin2Launcher
    # refusing to use /home/jann as install directory ...
    # exit 4

- Why no --install-dir CLI flag: the old --install-dir was silently
  ignored (CLAUDE memory noted it as broken) and re-adding it without
  the same safety gate would re-open the hole. Env var is enough for
  dev/CI; production wants exe-relative.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:34:48 +02:00
Jan Nedbal
db1f2f435b SECURITY: prune no longer recurses clientRoot — ledger-based only
Post-mortem: the previous implementation of PruneStaleFiles walked
Directory.EnumerateFiles(clientRoot, "*", SearchOption.AllDirectories)
and deleted anything not listed in the current manifest. That made
clientRoot unsafe to set to anything other than a dedicated install
directory. Because clientRoot is Directory.GetCurrentDirectory(), any
user who ran Metin2Launcher.exe from their home directory (or worse,
from a parent directory containing other projects) would have had
every unrelated file in that tree silently deleted — including
.ssh keys, .bashrc, docs, source trees. One of Jan's colleagues
hit exactly this tonight and lost a significant chunk of their home
directory.

The blast radius was enormous and entirely my fault. This commit
switches prune to a strict ledger-based model so the launcher can
ONLY delete files it itself wrote:

  .updates/applied-files.txt  — newline list of relative paths this
                                launcher has ever successfully
                                installed into clientRoot.

On prune:
  1. Read the ledger.
  2. For every entry not in the current manifest's file set (plus
     the top-level launcher.path), delete the file at that relative
     path inside clientRoot — if and only if PathSafety.ResolveInside
     accepts the resolved path (defense against traversal/symlinks).
  3. Rewrite the ledger to exactly match the current manifest.

Files the launcher never wrote are invisible to prune. A fresh
install dir has no ledger, so prune is a no-op on the first run and
subsequent runs only touch files listed in the ledger. Even if a user
points the launcher at ~/ with valuable data, prune cannot touch
anything it didn't put there.

Other hardening:
- PathSafety.ResolveInside is now invoked for every prune target, so
  a maliciously crafted manifest can't name "../../etc/passwd".
- Ledger write happens after apply so a crash mid-apply doesn't
  leave a stale ledger that would prune real user files on the next
  run.

Immediate mitigations taken outside this commit:
- Pulled the published Metin2Launcher.exe off updates.jakubkadlec.dev/
  launcher/ and replaced it with a README.txt warning, so no new user
  can download the dangerous binary.
- Will rebuild and re-upload the safe binary before re-announcing.

Followup:
- Add a Gitea issue documenting the incident and the ledger contract.
- Tests for the ledger read/write and for the "ledger empty = no
  prune" safety case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:13:57 +02:00
Jan Nedbal
3db306fbc7 launcher: refuse to start when another instance holds the install dir
Tetsu hit this running two Metin2Launcher.exe processes against the
same Wine install dir — the second instance blew up during blob
download with

    The process cannot access the file '.../\.updates/staging/Metin2.exe'
    because it is being used by another process.

because the first instance was already writing to the staging file.
There was no guard against multiple concurrent launchers, and the
symptoms (file-in-use IO exception during staging) are hard to
diagnose from the user's end.

Add a per-install-dir FileStream lock at `.updates/launcher.lock`
opened with FileShare.None + DeleteOnClose. If the lock is held, log a
clear error and exit with code 3. Released automatically when the
process exits. Works uniformly across Windows, Wine and native Linux;
a named Mutex would behave differently across Wine prefixes, so this
sticks to plain filesystem locking.

Also:
- launcher: switch main window to an image background + semi-
  transparent column brushes so the Morion2 crystal gate branding art
  shows through the existing dark-theme layout. First step toward the
  art pack Jan dropped tonight; follow-up commits will redo the
  layout per the full mockup.
- Assets/Branding/launcher-bg.png: initial background (downscaled
  from the Gemini-generated crystal gate hero image, 1800x1120, ~2.5 MB).
- csproj: include Assets/**/*.png as AvaloniaResource.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:51:28 +02:00
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
470 changed files with 3830 additions and 220 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

@@ -34,6 +34,38 @@ dotnet publish src/Metin2Launcher/Metin2Launcher.csproj \
The CI hasn't been wired yet; the MVP only requires the build to succeed.
## Build (Wine bundle)
For Linux users who run the Windows launcher through Wine, the safe layout is
not a single `Metin2Launcher.exe`. The Wine-compatible launcher currently needs
the non-single-file `win-x64` publish output, and it must live separately from
the mutable client install dir so the updater does not patch over its own
runtime files.
Use:
```
scripts/build-wine-bundle.sh
```
This produces:
```
release-wine/
Metin2Launcher-wine/
start-launcher.sh
README.txt
launcher/
Metin2Launcher.exe
*.dll
client/
Metin2Launcher-wine.tar.gz
```
`start-launcher.sh` exports `METIN2_INSTALL_DIR=./client` and then runs
`wine ./launcher/Metin2Launcher.exe`, so the launcher runtime stays isolated
from the actual game files.
## Publishing a release
`scripts/publish-launcher.sh` is the single entry point for cutting a Velopack
@@ -92,6 +124,9 @@ and run it. On first launch it will:
dev builds)
5. start `Metin2.exe`
For the Wine bundle layout, do not run the launcher from the mutable client
directory. Run `./start-launcher.sh` from the bundle root instead.
A log is written to both stdout and `<client>/.updates/launcher.log`. On a plain
`dotnet run` (no client root yet), the log falls back to
`%LOCALAPPDATA%/Metin2Launcher/launcher.log` on Windows or

Binary file not shown.

View File

@@ -0,0 +1,10 @@
Metin2Launcher Wine bundle
Layout:
- launcher/ launcher runtime (.exe + DLLs)
- client/ final game install dir and .updates/ state
Run:
./start-launcher.sh
The launcher installs into client/, not into launcher/.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,17 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"includedFrameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.22"
}
],
"configProperties": {
"MVVMTOOLKIT_ENABLE_INOTIFYPROPERTYCHANGING_SUPPORT": true,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Runtime.InteropServices.BuiltInComInterop.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More