# 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.

## 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
`<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:

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`

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 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`.
