metin-launcher
Self-updating launcher for the Metin2 client.
This is the single entry point the player runs. It fetches a signed manifest from
updates.jakubkadlec.dev, verifies an Ed25519 signature against a compiled-in
public key, reconciles the local client against the manifest by sha256, downloads
anything that differs from a content-addressed blob store (with HTTP Range resume),
applies updates atomically, hands launcher self-update off to Velopack, and then
starts Metin2.exe. If the update server is unreachable the launcher falls back
to launching whatever the player already has.
Architecture and failure modes live in the client repo under
docs/update-manager.md and docs/update-manifest.md. This project implements
those specs in C# .NET 8; it does not own the spec.
Build
dotnet build Launcher.sln -c Release
dotnet test
Tests are xUnit and run on Linux. A local HttpListener stands in for the blob
server in BlobDownloaderTests.
Publish (Windows single-file)
dotnet publish src/Metin2Launcher/Metin2Launcher.csproj \
-c Release -r win-x64 --self-contained \
-p:PublishSingleFile=true
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 raw standalone Metin2Launcher.exe. The Wine bundle now uses the same
portable Velopack launcher layout as the Windows installer/portable zip, but it
still has to live separately from the mutable client install dir so the updater
never patches over its own runtime files.
Use:
scripts/build-wine-bundle.sh
This produces:
release-wine/
Metin2Launcher-wine/
start-launcher.sh
README.txt
launcher/
.portable
Metin2Launcher.exe
Update.exe
current/
Metin2Launcher.exe
sq.version
client/
Metin2Launcher-wine.tar.gz
Metin2Launcher-wine.tgz
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. On Linux it runs inside a short-
lived mcr.microsoft.com/dotnet/sdk:8.0 container, publishes the launcher as
win-x64 self-contained single-file, loads Velopack.Packaging.Windows.dll
directly from the vpk tool nupkg, emits the launcher feed, repacks the
Windows onboarding zip into the portable Velopack layout, rebuilds the Wine
bundle from that same portable launcher, and finally rsyncs the result 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).
Required host tools: docker, curl, unzip, python3. rsync is only
needed for the upload step.
Outputs:
release-velopack/RELEASES-win-x64release-velopack/releases.win-x64.jsonrelease-velopack/assets.win-x64.jsonrelease-velopack/*.nupkgrelease-velopack/Metin2Launcher.exerelease-velopack/Metin2Launcher-win-x64-Portable.ziprelease-velopack/Metin2Launcher-win-x64-Setup.exerelease-velopack/Metin2Launcher-win-x64-bundle.ziprelease-wine/Metin2Launcher-wine.tar.gzrelease-wine/Metin2Launcher-wine.tgz
The Windows bundle zip and Wine tarballs are derived from the portable Velopack launcher layout, so launcher self-update stays on the same runtime path regardless of which onboarding artifact the player downloaded.
Run
By default the launcher opens an Avalonia GUI window (900x560, fixed size)
showing a banner, status / progress on the left and a news panel on the right.
Pass --nogui to run the legacy headless flow that just logs progress and
launches the game when done — useful for CI or unattended boxes.
Metin2Launcher.exe # GUI (default)
Metin2Launcher.exe --nogui # headless, log + launch
User settings (locale, dev-mode override) live at
<client>/.updates/launcher-settings.json. The manifest URL override is only
honoured when DevMode is true so a tampered local file alone cannot redirect
updates.
Drop Metin2Launcher.exe next to Metin2.exe in the client install directory
and run it. On first launch it will:
- fetch
https://updates.jakubkadlec.dev/manifest.json+.sig - verify the signature against the compiled-in public key
- hash every file listed in the manifest, download anything that doesn't match
into
.updates/staging/, then atomically move each one into place - check for a Velopack launcher update against the feed at
https://updates.jakubkadlec.dev/launcher(skipped cleanly on non-installed dev builds; active for Setup.exe, Portable.zip, the Windows bundle zip, and the Wine bundle) - 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
$XDG_DATA_HOME/Metin2Launcher/launcher.log on Linux.
Project layout
src/Metin2Launcher/
Program.cs entry: Velopack bootstrap, --nogui switch, Avalonia host
Config/LauncherConfig.cs hardcoded URLs, timeouts, public key
Config/LauncherSettings.cs user settings (locale, dev-mode override) JSON
Orchestration/ update flow extracted from CLI, IProgress driven
Manifest/ DTOs, HTTP loader, Ed25519 verifier
Transfer/ streaming sha256, Range-resume blob downloader
Apply/ atomic staging -> final move
GameLaunch/ CreateProcess wrapper
Logging/ hand-rolled file + stdout logger
Localization/ cs.json + en.json embedded resources, Loc loader
UI/ Avalonia App, MainWindow, SettingsDialog
UI/ViewModels/ MVVM view models
tests/Metin2Launcher.Tests/
Dependencies
- Avalonia 11 — cross-platform GUI (Linux + Windows from one csproj).
- CommunityToolkit.Mvvm —
ObservableObject+[RelayCommand]source generators. - NSec.Cryptography — libsodium-backed Ed25519 (.NET 8 BCL does not have it).
- Velopack — launcher self-update. Only used for the launcher binary itself; 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 whenformatis absent) — individual files listed infiles[], downloaded by sha256 and atomically replaced inside the client root.m2pack— thefiles[]entries are.m2ppack archives plus aruntime-key.jsonsidecar. The launcher never opens or decrypts.m2parchives; it just places them next to the client root and forwards the runtime key toMetin2.exevia 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.
Telemetry and ERP boundary
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 (5 s) best-effort POST after a successful
apply and swallows any failure.
Security boundary — read this before wiring telemetry:
The launcher is a public binary distributed to every player. It must
never contain or transmit shared secrets (ERP ingest tokens, API keys,
X-Metin-Release-Token, etc.). Concretely:
- The NewERP control-plane endpoints (
POST /api/metin-release/...) are protected byX-Metin-Release-Tokenbearer auth. Those endpoints are for server-side release orchestration (themetin-releaseCLI running on the publish machine), not for direct calls from player launcher instances. TelemetryUrlTemplateis not meant to point at the protected ERP ingest route. If player-side telemetry is desired, the target must be a separate public collector/proxy endpoint that:- accepts unauthenticated pings
- rate-limits per client IP
- forwards sanitised events to the ERP on the server side
- Until such a collector exists,
TelemetryUrlTemplatestays empty and the reporter is a no-op.
The canonical wire payload (ClientAppliedPayload) uses explicit
[JsonPropertyName] attributes, not implicit C# serializer naming:
{
"format": "m2pack",
"success": true,
"appliedAt": "2026-04-15T12:00:00Z",
"errorCode": null,
"errorMessage": null,
"clientId": null
}
The ERP currently has a compatibility bridge that also accepts the older
Format / Timestamp shape, but the canonical payload above is the
target contract on both sides.
Signing key
LauncherConfig.PublicKeyHex is the Ed25519 public key that every manifest
must verify against:
1d2b63751ea0e0354d28e7eb4ec175919a01518b0bcf5878f0a3aa8e7c6ce2bc
The matching private key lives only on the release machine at
~/.config/metin/launcher-signing-key with chmod 600, is never committed
to any repo, and is used by scripts/sign-manifest.py in the m2dev-client
repo to sign manifest.json before upload. Rotation is done by shipping a
new launcher binary that embeds the new public key; the rotation flow is
documented in m2dev-client/docs/update-manager.md.