6 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
468 changed files with 3766 additions and 236 deletions

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.

Binary file not shown.

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