Compare commits

22 Commits

Author SHA1 Message Date
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
Jan Nedbal
1790502b58 docs: document m2pack launcher integration
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>
2026-04-14 21:12:08 +02:00
Jan Nedbal
0e95171e50 test: cover runtime key, release formats and telemetry
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>
2026-04-14 21:10:48 +02:00
Jan Nedbal
6ad8e8db19 orchestration: dispatch through release format factory
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>
2026-04-14 21:05:57 +02:00
Jan Nedbal
3d98ac4470 telemetry: add opt-in client-applied reporter
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>
2026-04-14 21:05:49 +02:00
Jan Nedbal
ee7edfd990 formats: add release format strategy interface and implementations
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>
2026-04-14 21:05:43 +02:00
Jan Nedbal
dcc2b0fc42 runtime: add runtime-key model and delivery strategies
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>
2026-04-14 21:05:37 +02:00
Jan Nedbal
5edd0c5aea manifest: add optional format field for release dispatch
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>
2026-04-14 21:05:30 +02:00
Jan Nedbal
3d3129032a launcher: switch main window to dark theme with gradient banner
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.
2026-04-14 20:59:06 +02:00
root
0526ac2ef9 docs: add metin release cli plan 2026-04-14 18:30:43 +02:00
Jan Nedbal
3f8acfc597 launcher: document publish-launcher.sh in readme 2026-04-14 13:57:22 +02:00
Jan Nedbal
d9d45d0010 launcher: add velopack publish script 2026-04-14 13:57:22 +02:00
Jan Nedbal
ad78f8f139 Merge branch 'avalonia-gui'
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.
2026-04-14 13:26:27 +02:00
489 changed files with 5902 additions and 194 deletions

49
CHANGELOG.md Normal file
View File

@@ -0,0 +1,49 @@
# Changelog
All notable changes to `metin-launcher` are tracked here. Format loosely
follows Keep a Changelog; dates are Europe/Prague.
## [Unreleased]
### Added
- Release format dispatch. The manifest now carries an optional top-level
`format` field (defaults to `legacy-json-blob`) and the orchestrator
resolves an `IReleaseFormat` strategy via `ReleaseFormatFactory`.
- `M2PackFormat`: new release format that lists `.m2p` pack archives plus a
`runtime-key.json` sidecar. The launcher never opens or decrypts `.m2p`
archives; it only places them next to the client root and loads the
runtime key after apply.
- `RuntimeKey` model + `IRuntimeKeyDelivery` strategy. `EnvVarKeyDelivery`
is the MVP implementation and forwards `M2PACK_MASTER_KEY_HEX`,
`M2PACK_SIGN_PUBKEY_HEX` and `M2PACK_KEY_ID` to the child process scoped
to `ProcessStartInfo.Environment` only. `SharedMemoryKeyDelivery` is
documented as a stub and throws until the Windows receiver lands.
- `GameProcess.BuildStartInfo` now accepts an optional `RuntimeKey?` and
forwards it through the env-var delivery.
- `ClientAppliedReporter`: opt-in best-effort telemetry ping that fires
once after a successful apply with a 5-second cap. Disabled by default
(`LauncherConfig.TelemetryUrlTemplate == ""`). Failures are always
swallowed and logged as warnings.
- `docs/m2pack-integration.md` documenting the signature boundary, runtime
key delivery, telemetry, backward compatibility and file layout.
- ~60 new tests across `RuntimeKeyTests`, `EnvVarDeliveryTests`,
`LegacyJsonBlobFormatTests`, `M2PackFormatTests`, `ReleaseFormatFactoryTests`,
`ClientAppliedReporterTests`, `UpdateOrchestratorFormatDispatchTests` and
extended `GameProcessTests`. Total suite is ~92 tests.
### Changed
- `UpdateOrchestrator` dispatches through `ReleaseFormatFactory` after
signature verification. The legacy flow is preserved byte-for-byte for
manifests without a `format` field; the only visible difference is that
the complete log line now reads `format=legacy-json-blob`.
- `UpdateOrchestrator.Result` gained `Format` and `RuntimeKey` slots so the
headless entry point can forward the runtime key into `GameProcess.Launch`.
### Security
- A signed manifest carrying an unknown `format` value is refused outright
rather than silently falling back to legacy, preventing a downgrade
attack vector in the event of a signing key compromise.
- Env vars produced by `EnvVarKeyDelivery` are scoped to the spawned
child's environment only. The launcher never mutates its own process
environment, so other processes on the machine and later code in the
launcher itself cannot read the key.

View File

@@ -34,6 +34,67 @@ 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
release of the launcher and pushing it to the
`https://updates.jakubkadlec.dev/launcher/` feed that
`LauncherConfig.VelopackFeedUrl` points at. It runs `dotnet publish` for
`win-x64` self-contained single-file, then drives `vpk pack` (Velopack CLI
global tool, auto-installed on first run) with `--packId Metin2Launcher
--channel win-x64 --mainExe Metin2Launcher.exe`, and finally rsyncs the
resulting `RELEASES-win-x64` + `*.nupkg` (full + delta) to the VPS.
```
scripts/publish-launcher.sh --version 0.1.0 --dry-run
scripts/publish-launcher.sh --version 0.1.0 --yes
```
Flags: `--version` (required), `--dry-run` (build + pack but skip rsync),
`--yes` (skip the interactive rsync confirmation), `--rsync-target` (override
the default VPS path).
**Known limitation (2026-04-14):** Velopack's `vpk pack` is platform-routed.
On a Linux host it only builds Linux `.AppImage` bundles and refuses to pack
the win-x64 publish output (it tries to ELF-parse `Metin2Launcher.exe` and
crashes with `Given stream is not a proper ELF file`). Building the win-x64
Velopack release therefore needs a Windows host. The script is correct —
it's the toolchain that's gated. Once we have a Windows CI runner this can
be wired into Gitea Actions; until then run the script on a Windows dev box.
The `dotnet publish` step on Linux still works and is a useful sanity check.
## Run
By default the launcher opens an Avalonia GUI window (900x560, fixed size)
@@ -63,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
@@ -96,6 +160,30 @@ tests/Metin2Launcher.Tests/
game assets (~4 GB) are handled by our own patcher code.
- **xunit / xunit.runner.visualstudio** — tests.
## Release formats
The launcher dispatches on a top-level `format` field in the signed manifest.
Two strategies exist today:
- `legacy-json-blob` (default when `format` is absent) — individual files
listed in `files[]`, downloaded by sha256 and atomically replaced inside
the client root.
- `m2pack` — the `files[]` entries are `.m2p` pack archives plus a
`runtime-key.json` sidecar. The launcher **never** opens or decrypts
`.m2p` archives; it just places them next to the client root and forwards
the runtime key to `Metin2.exe` via environment variables
(`M2PACK_MASTER_KEY_HEX`, `M2PACK_SIGN_PUBKEY_HEX`, `M2PACK_KEY_ID`).
A manifest whose `format` value is signed but unknown is refused outright —
the orchestrator falls back to offline mode rather than silently downgrading.
See `docs/m2pack-integration.md` for the full threat model and file layout.
An opt-in "client applied this release" telemetry ping is available via
`LauncherConfig.TelemetryUrlTemplate`. It is empty by default, which short-
circuits the reporter so no network call is made. When set, the reporter
fires a single bounded (5s) best-effort POST after a successful apply and
swallows any failure.
## Signing key
`LauncherConfig.PublicKeyHex` is the Ed25519 public key that every manifest

114
docs/m2pack-integration.md Normal file
View File

@@ -0,0 +1,114 @@
# m2pack integration
Notes on how `metin-launcher` handles the m2pack release format. Written when the
launcher-side support landed on `claude/m2pack-launcher`; the client-side receiver
for the runtime key is tracked separately.
## Scope
The launcher's job in an m2pack release is narrowly defined:
1. Fetch the signed `manifest.json` and its `.sig` exactly as before.
2. Verify Ed25519 signature against the compiled-in public key, exactly as before.
3. Read the new top-level `format` field (falling back to `legacy-json-blob`).
4. Dispatch through `ReleaseFormatFactory.Resolve(...)` to an `IReleaseFormat`.
5. Diff, download and atomically apply the file entries listed in the manifest.
For m2pack these are `.m2p` pack archives plus a `runtime-key.json` sidecar.
6. **Never** open, decrypt or decompress a `.m2p` archive. Integrity of the
archive contents is the client's problem, gated on the runtime key.
7. Load `runtime-key.json` from the client root and forward it to `Metin2.exe`
via environment variables (`M2PACK_MASTER_KEY_HEX`, `M2PACK_SIGN_PUBKEY_HEX`,
`M2PACK_KEY_ID`), scoped to the child process only.
Anything beyond step 7 — parsing the archive, selecting pack files, decrypting
blocks, validating per-file signatures — lives in the client. The launcher is
deliberately dumb about `.m2p`.
## Signature boundary
The Ed25519 signature stays on the top-level `manifest.json`. `.m2p` files are
each a single `files[]` entry with a `sha256`, exactly like any other blob —
that's enough to detect tampering or incomplete downloads, and tamper-resistance
of the content-addressed URL is inherited from the signed manifest.
The `runtime-key.json` sidecar is listed in the manifest in the same way, so
its integrity is also covered by the manifest signature. We do **not** sign the
runtime key independently.
## Runtime key delivery
Two delivery strategies are defined behind `IRuntimeKeyDelivery`:
- `EnvVarKeyDelivery` — MVP, used on every platform. Sets
`M2PACK_MASTER_KEY_HEX`, `M2PACK_SIGN_PUBKEY_HEX` and `M2PACK_KEY_ID` on the
child `ProcessStartInfo.Environment`. The launcher never sets these on its
own process environment, so unrelated processes can't snoop them and the
values don't leak via `Environment.GetEnvironmentVariable` calls elsewhere
in the launcher.
- `SharedMemoryKeyDelivery` — Windows stub. Not implemented. See the class
docs for the planned approach (named `MemoryMappedFile` with a random
suffix, write-then-zero lifecycle, child reads via mapping name passed in
env). Left as a stub because the client-side receiver hasn't landed.
Threat model note: env vars are readable by any process that shares the
parent's user on Linux (`/proc/$pid/environ`, gated by `ptrace_scope` and by
file mode). That's identical to the current legacy flow in terms of secret
handling — the legacy release also trusts the local user — and the shared
memory strategy is planned for a future hardening pass, not as an MVP
requirement.
## Telemetry
`ClientAppliedReporter` is wired but disabled by default: `LauncherConfig.TelemetryUrlTemplate`
is empty. When set to a non-empty URL template (supporting `{format}` and
`{version}` placeholders) the reporter fires a single bounded POST after a
successful apply, with a hard 5-second timeout and warning-level logging on
failure. Failures never block the launch flow and are never retried.
The payload is intentionally small: `format`, `version`, `timestamp`. Nothing
personally identifying.
## Backward compatibility
Existing installs pointing at a manifest without a `format` field continue to
parse exactly as before: `Manifest.EffectiveFormat` defaults to
`legacy-json-blob` and `LegacyJsonBlobFormat` is a near-verbatim lift of the
old inline behaviour. The only visible difference is that the orchestrator's
log line now reads `format=legacy-json-blob`.
Signed manifests that ship an unknown `format` value are refused outright —
`ReleaseFormatFactory.Resolve` throws and the orchestrator reports
`OfflineFallback`. This prevents a future attacker who compromises the signing
key from downgrading clients via a fabricated `format: "no-verify"` field.
## File layout on disk
```
client/
├── Metin2.exe
├── runtime-key.json ← written from the manifest, loaded by M2PackFormat
├── pack/
│ ├── item.m2p ← staged/placed by the launcher, never opened
│ ├── mob.m2p
│ └── ...
└── .updates/
├── launcher.log
├── current-manifest.json
└── staging/ ← per-file resume, same as legacy
```
## Testing
Run `dotnet test -c Release` — the relevant suites are:
- `RuntimeKeyTests` — happy path, validation, load-from-disk
- `EnvVarDeliveryTests` — scoped env mutation, no current-process leak
- `LegacyJsonBlobFormatTests` / `M2PackFormatTests` / `ReleaseFormatFactoryTests`
- `ClientAppliedReporterTests` — disabled-by-default, success, 500, timeout,
connection refused
- `UpdateOrchestratorFormatDispatchTests` — signature failure path, offline
fallback when manifest fetch fails, result carries `Format` + `RuntimeKey`
- Extended `GameProcessTests` asserting the runtime key gets forwarded through
`BuildStartInfo` and does NOT leak into the launcher's own environment.
Total suite after this change is ~92 tests.

View File

@@ -0,0 +1,466 @@
# metin-release-cli plan
Plan for a release orchestration toolchain that stays outside NewERP and lets
the ERP module act only as the control plane.
## Goal
Build two components:
- `metin-release-cli` as the real operator and automation entry point
- `metin-release-mcp` as a thin MCP wrapper over the CLI
The ERP module should not own manifest generation, signing, blob publishing, or
launcher packaging logic. It should call the CLI through a stable contract and
store release metadata, status, logs, and audit trail.
## Why this split
Current release concerns already live across multiple repos and runtimes:
- `metin-launcher` consumes signed `manifest.json` + blob storage
- `m2dev-client` already has `make-manifest.py` and `sign-manifest.py`
- `m2pack-secure` owns `.m2p` build, verify, diff, and runtime-key export
- launcher self-update currently depends on Velopack packaging, which is still
Windows-host constrained
Putting that logic into NewERP would duplicate the release path and make the ERP
module too heavy. The CLI should own the workflow. ERP should orchestrate it.
## Design principles
- CLI first. Every real operation must be runnable without MCP.
- JSON first. Every non-interactive CLI command must support machine-readable
output.
- Thin wrapper. MCP must call CLI commands, parse JSON, and return it. No
duplicated business logic.
- Explicit state. Releases move through named stages and emit durable logs.
- Secret isolation. Signing keys and content keys stay on the release machine or
in a secret store, never in ERP DB.
- Linux-first. Manifest build, signing, blob publish, and `.m2p` validation must
run on Linux. Windows-only launcher packaging remains a separate step.
## Scope
`metin-release-cli` should cover:
- create and validate a release workspace
- build or import a release manifest
- sign the manifest
- diff against the currently published release
- upload missing blobs to the update storage
- archive historical manifests
- promote a release to current
- verify the public endpoint after publish
- optionally record release metadata in ERP through HTTP API
- optionally manage `.m2p` release artifacts and runtime-key export
- optionally run launcher publish as a separate command
`metin-release-mcp` should cover:
- expose the same operations as MCP tools
- map tool input to CLI flags
- read CLI JSON output
- pass through logs, status, and errors in structured form
## Non-goals
- No manifest generation inside ERP PHP code
- No signing inside ERP
- No heavy artifact proxying through ERP uploads for multi-GB releases
- No duplicated publish logic in MCP
- No attempt to hide Windows constraints for Velopack launcher packaging
## Canonical release path
For the current launcher contract, the canonical asset release path is:
1. obtain a prepared client root on Linux
2. generate `manifest.json`
3. sign `manifest.json`
4. compute which content-addressed blobs are missing remotely
5. upload missing blobs
6. upload archived manifest under `manifests/<version>.json`
7. upload archived signature under `manifests/<version>.json.sig`
8. switch top-level `manifest.json` and `manifest.json.sig`
9. verify the public endpoint
10. report final release metadata to ERP
For launcher self-update, the path is separate:
1. build win-x64 launcher package on Windows-capable host
2. publish Velopack feed into `/launcher/`
3. optionally annotate the same release in ERP
## Proposed architecture
### 1. metin-release-cli
Suggested implementation language:
- Python is the pragmatic default because the current manifest tooling already
exists in Python
- shell scripts may wrap platform-specific steps, but the primary interface
should be one CLI executable with stable subcommands
Suggested internal modules:
- `workspace`
- resolve source roots, temp dirs, release output dirs
- `manifest`
- call or absorb `make-manifest.py`
- `signing`
- call or absorb `sign-manifest.py`
- `storage`
- remote existence checks
- blob upload
- manifest archive upload
- current manifest promotion
- `verify`
- verify local artifacts
- verify public HTTP endpoint
- `erp`
- create/update release records in ERP
- `m2pack`
- optional integration with `m2pack-secure`
- `launcher`
- optional launcher publish integration
### 2. metin-release-mcp
Suggested shape:
- one MCP server process
- each tool maps 1:1 to one CLI subcommand
- wrapper only:
- validates tool input
- spawns CLI
- parses JSON stdout
- returns structured result
- forwards stderr as diagnostic text
No release decision logic should live here.
## Release state model
The CLI should emit explicit states the ERP module can mirror:
- `draft`
- `scanning`
- `manifest_built`
- `signed`
- `uploading_blobs`
- `uploaded`
- `promoting`
- `published`
- `verifying`
- `verified`
- `failed`
- `rolled_back`
Each state transition should include:
- `release_id` if known in ERP
- `version`
- `started_at`
- `finished_at`
- `duration_ms`
- `step`
- `status`
- `message`
- `log_path` if available
## CLI command set
Suggested commands:
### Release lifecycle
- `metin-release release init`
- create local workspace metadata for a new version
- `metin-release release inspect`
- inspect source root, file counts, launcher presence, total bytes
- `metin-release release build-manifest`
- generate canonical `manifest.json`
- `metin-release release sign`
- sign `manifest.json`
- `metin-release release diff-remote`
- compare manifest blobs against remote storage
- `metin-release release upload-blobs`
- upload only missing blobs
- `metin-release release upload-manifest-archive`
- upload `manifests/<version>.json` and `.sig`
- `metin-release release promote`
- switch current `manifest.json` and `.sig`
- `metin-release release verify-public`
- fetch public manifest, signature, and optional blob samples
- `metin-release release publish`
- composite command executing the full asset publish flow
- `metin-release release rollback`
- promote a historical manifest back to current
### ERP sync
- `metin-release erp create-release`
- `metin-release erp update-release`
- `metin-release erp append-log`
- `metin-release erp mark-status`
### m2pack integration
- `metin-release m2pack build`
- `metin-release m2pack verify`
- `metin-release m2pack diff`
- `metin-release m2pack export-runtime-key`
### Launcher integration
- `metin-release launcher publish`
- separate path because of Windows packaging constraints
## JSON contract
Every automation-facing command should support:
```text
--json
```
Success output shape:
```json
{
"ok": true,
"command": "release publish",
"version": "2026.04.14-1",
"status": "published",
"artifacts": {
"manifest_path": "/abs/path/manifest.json",
"signature_path": "/abs/path/manifest.json.sig"
},
"stats": {
"file_count": 123,
"blob_count": 9,
"uploaded_blob_count": 3,
"uploaded_bytes": 1048576
},
"remote": {
"manifest_url": "https://updates.jakubkadlec.dev/manifest.json"
},
"erp": {
"release_id": 42
}
}
```
Failure output shape:
```json
{
"ok": false,
"command": "release publish",
"version": "2026.04.14-1",
"status": "failed",
"error": {
"code": "blob_upload_failed",
"message": "Failed to upload one or more blobs"
}
}
```
Exit code rules:
- `0` success
- `1` operator or validation error
- `2` remote or network error
- `3` signing or integrity error
- `4` ERP sync error
## ERP integration contract
The ERP module should integrate with the CLI at the orchestration layer only.
Recommended boundary:
- ERP creates a draft release record
- ERP stores operator intent, notes, target channel, and visibility
- ERP triggers the CLI through a worker, SSH command, queue job, or agent
- CLI performs release operations
- CLI pushes structured status updates back to ERP
- ERP renders the timeline, logs, artifacts, and rollback actions
Recommended ERP-owned data:
- release version
- notes
- previous release reference
- publish status
- operator identity
- started/finished timestamps
- manifest metadata
- remote verification status
- launcher publish status
- pointers to logs and artifacts
CLI-owned data:
- actual manifest generation
- signing
- file hashing
- blob deduplication
- upload semantics
- runtime-key export
- launcher packaging invocation
## Remote storage contract
For the current launcher path, the CLI should treat this as canonical:
- `/var/www/updates.jakubkadlec.dev/files/<hash-prefix>/<sha256>`
- `/var/www/updates.jakubkadlec.dev/manifests/<version>.json`
- `/var/www/updates.jakubkadlec.dev/manifests/<version>.json.sig`
- `/var/www/updates.jakubkadlec.dev/manifest.json`
- `/var/www/updates.jakubkadlec.dev/manifest.json.sig`
- `/var/www/updates.jakubkadlec.dev/launcher/*`
Promotion safety rule:
- upload immutable blobs first
- upload archived versioned manifest second
- replace current manifest last
## Secrets and security
- launcher manifest signing key remains outside ERP
- `.m2p` master keys remain outside ERP
- CLI reads secrets from:
- fixed secure file paths
- environment variables
- secret manager integration later
- CLI must redact secrets from logs and JSON output
Never return:
- raw private keys
- runtime master keys
- decrypted secret material
## Linux and Wine posture
Supported on Linux now:
- client tree scan
- manifest build
- manifest signing
- blob upload and promotion
- `.m2p` build and verification
- Wine-based smoke validation if needed
Not fully Linux-native yet:
- Velopack packaging for win-x64 launcher release
Implication:
- asset release commands should be Linux-first
- launcher publish should remain an explicit separate command and may require a
Windows runner or Windows host until the toolchain changes
## MCP tool set
Suggested MCP tools:
- `release_init`
- `release_inspect`
- `release_build_manifest`
- `release_sign`
- `release_diff_remote`
- `release_upload_blobs`
- `release_promote`
- `release_verify_public`
- `release_publish`
- `release_rollback`
- `erp_create_release`
- `erp_update_release`
- `m2pack_build`
- `m2pack_verify`
- `m2pack_diff`
- `m2pack_export_runtime_key`
- `launcher_publish`
Each tool should document the exact CLI command it runs.
## Recommended implementation phases
### Phase 1. Asset release CLI
Build first:
- `release inspect`
- `release build-manifest`
- `release sign`
- `release diff-remote`
- `release upload-blobs`
- `release promote`
- `release verify-public`
- `release publish`
This is the minimum path ERP needs for the current launcher contract.
### Phase 2. ERP sync
Build second:
- `erp create-release`
- `erp update-release`
- CLI status callbacks or polling contract
This lets the ERP module focus on UI, permissions, and audit trail.
### Phase 3. MCP wrapper
Build third:
- expose the Phase 1 and 2 CLI commands as MCP tools
- no new logic
### Phase 4. m2pack path
Build fourth:
- `m2pack build`
- `m2pack verify`
- `m2pack diff`
- `m2pack export-runtime-key`
Only after the signed-manifest release path is stable.
### Phase 5. Launcher release path
Build fifth:
- `launcher publish`
- Windows-capable execution environment
- optional ERP annotation
## Open decisions
- Whether the CLI should directly push status to ERP or ERP should poll CLI job
results
- Whether release workspaces are local-only or persisted on a shared release
host
- Whether `.m2p` artifacts become part of the same release object now or later
- Whether launcher release should be represented as:
- separate release type
- or a child job under the same release
## Recommendation
Start with:
- one Linux-first `metin-release-cli`
- one thin `metin-release-mcp`
- ERP module consuming only CLI results and statuses
Do not start by embedding release logic in ERP. That will create a second
source of truth and make later `.m2p` and launcher evolution harder.

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