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
2026-04-14 11:02:30 +02:00

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

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.MvvmObservableObject + [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.

Description
Self-updating launcher for the Metin2 client (dev repo)
Readme MIT 92 MiB
Languages
C# 96.3%
Shell 3.7%