# 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 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) 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 `/.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: 1. fetch `https://updates.jakubkadlec.dev/manifest.json` + `.sig` 2. verify the signature against the compiled-in public key 3. hash every file listed in the manifest, download anything that doesn't match into `.updates/staging/`, then atomically move each one into place 4. check for a Velopack launcher update against the feed at `https://updates.jakubkadlec.dev/launcher` (skipped cleanly on non-installed 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 `/.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 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 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`.