.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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
adds docs/m2pack-integration.md covering the signature boundary,
runtime key env-var delivery, telemetry opt-in, backward compatibility
and expected on-disk layout. README gains a short "Release formats"
section pointing at the new doc, and CHANGELOG tracks the [Unreleased]
entries.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
adds ~60 new tests across RuntimeKey parsing, EnvVarKeyDelivery, the
legacy and m2pack formats, ReleaseFormatFactory dispatch, manifest
loader tolerance of unknown top-level fields, orchestrator wiring and
the ClientAppliedReporter (disabled-by-default, success, 5xx, timeout,
connection refused). The telemetry tests spin up an in-process
HttpListener helper — no new NuGet dependency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
UpdateOrchestrator now resolves an IReleaseFormat from the verified
manifest and uses it to filter applicable files, run the post-apply
hook (which loads the m2pack runtime key when present) and drive the
opt-in client-applied telemetry ping. GameProcess.BuildStartInfo
accepts a RuntimeKey? and forwards it through EnvVarKeyDelivery onto
the child ProcessStartInfo, scoped to the child environment only.
Program.cs wires the reporter and threads the key from the update
result into the game launch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
adds ClientAppliedReporter that fires a single bounded (5s) best-effort
POST after a successful update, carrying only release format and
version. the launcher config exposes TelemetryUrlTemplate defaulted to
empty string — when empty the reporter short-circuits and nothing goes
over the network.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
adds IReleaseFormat with a legacy-json-blob implementation lifting the
existing per-file update behaviour, and an m2pack implementation that
loads runtime-key.json after apply. a central ReleaseFormatFactory
maps Manifest.EffectiveFormat onto concrete strategies and throws on
unknown values so a signed but unsupported format cannot silently
downgrade.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
adds RuntimeKey DTO parsed from runtime-key.json, an IRuntimeKeyDelivery
strategy interface, and the env-var delivery used by the m2pack MVP
(M2PACK_MASTER_KEY_HEX / M2PACK_SIGN_PUBKEY_HEX / M2PACK_KEY_ID).
SharedMemoryKeyDelivery is a documented stub — will be wired once the
client-side receiver lands.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
introduces an optional top-level "format" field on the signed manifest,
defaulting to legacy-json-blob when absent so existing installs keep
parsing unchanged. follow-up commits wire the release format factory
and the m2pack strategy against this value.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the flat light theme with a dark palette built for a fantasy MMO
feel. Banner uses a crimson linear gradient and wider letter-spacing on
the title, the body is #0F0F12 with a #17171B left column, the Play
button is a red gradient with hover state and a disabled rust tone.
Status foreground defaults to white and turns a soft red only on
signature failure. The news panel gains an empty-state placeholder with
a diamond glyph so a missing manifest doesn't look like a bug.
Localization strings for the empty state switch from 'Žádné novinky.' to
'Zatím žádné novinky' / 'No news yet' to read more like a UI state than
an error. The view model exposes IsNewsEmpty / IsNewsPresent so the
XAML can toggle between the placeholder and the real news body without
touching the flush timer.
Add Avalonia GUI to the launcher with a proper windowed flow, progress bar,
news panel, settings dialog, Czech/English localization, and a refactored
UpdateOrchestrator with progress events. Keeps the headless --nogui path
working. Fixes the Linux-native game launch path to spawn wine explicitly so
the child inherits WINEPREFIX.
The extracted orchestrator and the new GUI view model didn't call
Velopack's self-update path, leaving that behaviour only in the headless
--nogui branch. Wire the same TrySelfUpdateAsync helper into the GUI
orchestration so the launcher keeps itself current regardless of which
mode the user runs.
Program.Main now starts the Avalonia desktop lifetime by default and
falls back to a headless orchestrator run that logs progress and launches
the game when --nogui is passed. README documents both modes and the new
launcher-settings.json store.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the Avalonia App, MainWindow (900x560 fixed, dark-red banner, left
status/progress column, right news panel) and a modal SettingsDialog with
language + dev-mode override. MainWindowViewModel binds to the orchestrator
through IProgress, throttles UI updates to ~10 Hz via a DispatcherTimer
flush, drives state-specific status text and Play-button gating, fetches
previous-manifest notes best-effort, and forces a red status banner with
Play disabled on a SignatureFailed result.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lifts the manifest fetch / verify / diff / download / apply pipeline out
of Program.cs into a reusable UpdateOrchestrator that emits typed
LauncherState progress events through IProgress<UpdateProgress>. The
existing logic, error handling, and signature-failure semantics are
preserved verbatim; the orchestrator just drives a sink instead of the
console so the GUI and the headless --nogui path can share one pipeline.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds Avalonia 11 + CommunityToolkit.Mvvm package references (no UI code
yet), a flat-key Loc resource loader with embedded cs.json/en.json, the
LauncherSettings JSON store with dev-mode-gated manifest URL override,
and an optional IProgress<long> bytesProgress hook on BlobDownloader so
the upcoming GUI can drive a progress bar from per-blob byte counts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the MVP placeholder Ed25519 public key with the real one that
corresponds to ~/.config/metin/launcher-signing-key on the release
machine. Update README to document where the private half lives and
how rotation works. Private key is not committed anywhere; the only
code path that touches it is scripts/sign-manifest.py in m2dev-client.
Defence-in-depth against a buggy or crafted manifest that points
outside the client directory. Path resolution is centralised in
PathSafety.ResolveInside so the orchestration code cannot forget the
check; the applier still sees already-validated absolute paths.
Rejected cases: parent traversal, mixed traversal, absolute paths,
sibling-root lexical escapes. Required files that fail the check
abort the update; optional files are skipped and logged.
The previous flow threw InvalidOperationException on signature
failure, which was caught by the outer catch(Exception) and silently
fell through to the offline launch branch — the exact bypass the
design doc forbids ('server is lying' is more dangerous than 'server
is down'). Introduce ManifestSignatureException and handle it in a
dedicated catch above the generic fallback, exiting with code 2
without launching the game.