Compare commits

...

17 Commits

Author SHA1 Message Date
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
33 changed files with 2450 additions and 75 deletions

14
.gitignore vendored
View File

@@ -4,3 +4,17 @@ obj/
*.suo
.vs/
publish/
# Runtime client data accidentally left when launcher is run from its bin dir
# (CWD becomes the source tree). These should never be committed.
src/Metin2Launcher/.updates/
src/Metin2Launcher/bgm/
src/Metin2Launcher/config/
src/Metin2Launcher/mark/
src/Metin2Launcher/pack/
src/Metin2Launcher/log/
src/Metin2Launcher/Metin2.exe
src/Metin2Launcher/*.dll
src/Metin2Launcher/*.pyd
src/Metin2Launcher/python314*
src/Metin2Launcher/runtime-key.json

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,35 @@ dotnet publish src/Metin2Launcher/Metin2Launcher.csproj \
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)
@@ -96,6 +125,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.

114
scripts/publish-launcher.sh Executable file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# publish-launcher.sh — publish a Velopack release of the Metin2 launcher.
#
# Drives `dotnet publish` + `vpk pack` to produce a win-x64 Velopack release
# and (optionally) rsyncs it to updates.jakubkadlec.dev/launcher/ so the
# launcher's Velopack self-update flow can pick it up on the next run.
#
# See README.md -> "Publishing a release" for the operator runbook.
#
# Usage:
# scripts/publish-launcher.sh --version 0.1.0 [--dry-run] [--yes] \
# [--rsync-target <user@host:/path>]
#
# Known limitation (2026-04-14):
# Velopack's `vpk pack` command is platform-routed. Running on Linux only
# produces a .AppImage bundle; building a win-x64 NuGet release requires a
# Windows host. Until we have a Windows CI runner this script is only
# usable on a Windows machine. Running --dry-run on Linux will hit the
# error "Required command was not provided" or "Unrecognized command or
# argument 'windows'" from vpk itself. The dotnet publish step still works
# on Linux and is a useful sanity check.
set -euo pipefail
VERSION=""
DRY_RUN=0
YES=0
RSYNC_TARGET="mt2.jakubkadlec.dev@mt2.jakubkadlec.dev:/var/www/updates.jakubkadlec.dev/launcher/"
die() { echo "error: $*" >&2; exit 1; }
say() { echo "[publish-launcher] $*"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--version) VERSION="$2"; shift 2 ;;
--rsync-target) RSYNC_TARGET="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--yes) YES=1; shift ;;
-h|--help) sed -n '1,25p' "$0"; exit 0 ;;
*) die "unknown arg: $1" ;;
esac
done
[[ -n "$VERSION" ]] || die "--version is required"
REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
CSPROJ="$REPO_ROOT/src/Metin2Launcher/Metin2Launcher.csproj"
PUBLISH_DIR="$REPO_ROOT/src/Metin2Launcher/bin/Release/net8.0/win-x64/publish"
OUTPUT_DIR="$REPO_ROOT/release-velopack"
[[ -f "$CSPROJ" ]] || die "csproj not found: $CSPROJ"
# Ensure vpk is on PATH, install if missing.
if ! command -v vpk >/dev/null 2>&1; then
if ! dotnet tool list --global 2>/dev/null | awk '{print $1}' | grep -qx 'vpk'; then
say "installing vpk global tool..."
dotnet tool install --global vpk
fi
export PATH="$HOME/.dotnet/tools:$PATH"
fi
command -v vpk >/dev/null 2>&1 || die "vpk still not on PATH after install"
say "version: $VERSION"
say "repo: $REPO_ROOT"
# ---- [1/3] dotnet publish ----
say "[1/3] dotnet publish -c Release -r win-x64 --self-contained"
dotnet publish "$CSPROJ" \
-c Release \
-r win-x64 \
--self-contained \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true
[[ -d "$PUBLISH_DIR" ]] || die "publish output dir missing: $PUBLISH_DIR"
[[ -f "$PUBLISH_DIR/Metin2Launcher.exe" ]] || die "Metin2Launcher.exe not in publish output"
# ---- [2/3] vpk pack ----
say "[2/3] vpk pack --packId Metin2Launcher --packVersion $VERSION --channel win-x64"
mkdir -p "$OUTPUT_DIR"
vpk pack \
--packId Metin2Launcher \
--packVersion "$VERSION" \
--packDir "$PUBLISH_DIR" \
--outputDir "$OUTPUT_DIR" \
--channel win-x64 \
--mainExe Metin2Launcher.exe
say " output: $OUTPUT_DIR"
ls -la "$OUTPUT_DIR" || true
# Sanity check: expect a RELEASES manifest and at least one .nupkg.
if ! ls "$OUTPUT_DIR"/RELEASES* >/dev/null 2>&1; then
die "vpk pack produced no RELEASES file in $OUTPUT_DIR"
fi
if ! ls "$OUTPUT_DIR"/*.nupkg >/dev/null 2>&1; then
die "vpk pack produced no .nupkg in $OUTPUT_DIR"
fi
# ---- [3/3] rsync ----
if [[ "$DRY_RUN" -eq 1 ]]; then
say "[3/3] --dry-run set, skipping rsync. target would be: $RSYNC_TARGET"
exit 0
fi
say "[3/3] rsync target: $RSYNC_TARGET"
if [[ "$YES" -ne 1 ]]; then
read -r -p "continue? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || die "aborted by user"
fi
rsync -av --checksum --omit-dir-times --no-perms \
"$OUTPUT_DIR"/ "$RSYNC_TARGET"
say "done."

View File

@@ -30,6 +30,14 @@ public static class LauncherConfig
public const int MaxBlobRetries = 3;
/// <summary>
/// Opt-in telemetry endpoint for the "client applied release" ping. Empty
/// by default — when empty the reporter short-circuits and no network call
/// is made. Supports <c>{format}</c> and <c>{version}</c> placeholders,
/// both URL-escaped at send time.
/// </summary>
public const string TelemetryUrlTemplate = "";
/// <summary>Name of the directory under the client root that holds launcher state.</summary>
public const string StateDirName = ".updates";

View File

@@ -0,0 +1,52 @@
using Metin2Launcher.Manifest;
namespace Metin2Launcher.Formats;
/// <summary>
/// Strategy that knows how to interpret the <c>files</c> array of a
/// signature-verified manifest. Added for the m2pack migration:
///
/// - <c>legacy-json-blob</c> ships individual files that replace their
/// counterparts inside the client root one-for-one.
/// - <c>m2pack</c> ships <c>.m2p</c> pack archives and a <c>runtime-key.json</c>
/// sidecar. The archives are placed next to the client root and the
/// runtime key is forwarded to the game process via env vars — the
/// launcher NEVER opens, decrypts or decompresses an <c>.m2p</c> archive.
///
/// Both implementations share the same download/apply plumbing in
/// <see cref="Orchestration.UpdateOrchestrator"/>. This interface only
/// captures the pieces that actually differ between the two formats.
/// </summary>
public interface IReleaseFormat
{
/// <summary>Identifier matching <see cref="Manifest.EffectiveFormat"/>.</summary>
string Name { get; }
/// <summary>
/// Filters the manifest file list down to entries that should be
/// downloaded on the given platform. Both formats currently delegate
/// to <see cref="ManifestFile.AppliesTo(string)"/>; the indirection
/// exists so a future format can special-case (e.g. exclude the
/// runtime key from platform filtering, or reject unknown kinds).
/// </summary>
IReadOnlyList<ManifestFile> FilterApplicable(Manifest.Manifest manifest, string platform);
/// <summary>
/// Runs after the apply phase succeeds. The legacy format is a no-op;
/// the m2pack format loads the runtime key from the client root and
/// stores it on <paramref name="outcome"/> so the caller can forward it
/// to the game process.
/// </summary>
void OnApplied(string clientRoot, LoadedManifest manifest, ReleaseOutcome outcome);
}
/// <summary>
/// Mutable bag of results emitted by <see cref="IReleaseFormat.OnApplied"/>.
/// Kept separate from the update orchestrator's own <c>Result</c> record so
/// format-specific data doesn't leak into the generic update flow.
/// </summary>
public sealed class ReleaseOutcome
{
/// <summary>Populated by the m2pack format; null for legacy releases.</summary>
public Runtime.RuntimeKey? RuntimeKey { get; set; }
}

View File

@@ -0,0 +1,27 @@
using Metin2Launcher.Manifest;
namespace Metin2Launcher.Formats;
/// <summary>
/// The original release format: the manifest's <c>files</c> array lists
/// individual files (exe, dlls, packs, locale/&lt;pre-m2pack&gt;) that the
/// launcher downloads by sha256 and atomically replaces inside the client
/// root. Preserved unchanged so an install that still points at an old
/// manifest endpoint works identically.
/// </summary>
public sealed class LegacyJsonBlobFormat : IReleaseFormat
{
public const string FormatName = "legacy-json-blob";
public string Name => FormatName;
public IReadOnlyList<ManifestFile> FilterApplicable(Manifest.Manifest manifest, string platform)
{
return manifest.Files.Where(f => f.AppliesTo(platform)).ToList();
}
public void OnApplied(string clientRoot, LoadedManifest manifest, ReleaseOutcome outcome)
{
// Nothing format-specific happens after apply for the legacy flow.
}
}

View File

@@ -0,0 +1,59 @@
using Metin2Launcher.Logging;
using Metin2Launcher.Manifest;
using Metin2Launcher.Runtime;
namespace Metin2Launcher.Formats;
/// <summary>
/// m2pack release format.
///
/// The manifest still has a flat <c>files</c> list, but the entries are now
/// content-addressed <c>.m2p</c> archives plus a <c>runtime-key.json</c>
/// sidecar. Signing continues to happen on the top-level <c>manifest.json</c>
/// — the <c>.m2p</c> files are each a single sha256 entry, and the launcher
/// NEVER opens them. Decryption, decompression and integrity of the contents
/// is the game client's problem, gated on the runtime key this class loads.
///
/// The runtime-key.json sidecar is listed in the manifest like any other file
/// and goes through the same sha256 round trip, so tamper resistance of the
/// key itself comes from the Ed25519 signature over the enclosing manifest.
/// </summary>
public sealed class M2PackFormat : IReleaseFormat
{
public const string FormatName = "m2pack";
/// <summary>Conventional filename of the runtime key sidecar inside the client root.</summary>
public const string RuntimeKeyFileName = "runtime-key.json";
public string Name => FormatName;
public IReadOnlyList<ManifestFile> FilterApplicable(Manifest.Manifest manifest, string platform)
{
// Same platform filter as legacy; runtime-key.json is platform=all by convention.
return manifest.Files.Where(f => f.AppliesTo(platform)).ToList();
}
public void OnApplied(string clientRoot, LoadedManifest manifest, ReleaseOutcome outcome)
{
var keyPath = Path.Combine(clientRoot, RuntimeKeyFileName);
if (!File.Exists(keyPath))
{
// An m2pack manifest without a runtime-key.json is a release-tool bug,
// but we don't hard-fail here: the game will refuse to start without the
// env vars and surface the real error to the user with better context.
Log.Warn($"m2pack: runtime key file not found at {keyPath}");
return;
}
try
{
outcome.RuntimeKey = RuntimeKey.Load(keyPath);
Log.Info($"m2pack: loaded runtime key {outcome.RuntimeKey.KeyId}");
}
catch (Exception ex)
{
Log.Error($"m2pack: failed to parse runtime key {keyPath}", ex);
throw;
}
}
}

View File

@@ -0,0 +1,26 @@
namespace Metin2Launcher.Formats;
/// <summary>
/// Resolves an <see cref="IReleaseFormat"/> for a given manifest. Lives
/// behind a factory so the <see cref="Orchestration.UpdateOrchestrator"/>
/// doesn't grow a switch statement and so tests can inject fakes.
/// </summary>
public static class ReleaseFormatFactory
{
/// <summary>
/// Returns the strategy for the given manifest format identifier.
/// Unknown identifiers throw — we deliberately do NOT silently fall back
/// to the legacy format, because that would turn a signed, malicious
/// <c>format: "evil"</c> value into a downgrade attack vector.
/// </summary>
public static IReleaseFormat Resolve(string effectiveFormat)
{
return effectiveFormat switch
{
LegacyJsonBlobFormat.FormatName => new LegacyJsonBlobFormat(),
M2PackFormat.FormatName => new M2PackFormat(),
_ => throw new NotSupportedException(
$"manifest release format '{effectiveFormat}' is not supported by this launcher"),
};
}
}

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using Metin2Launcher.Logging;
using Metin2Launcher.Runtime;
namespace Metin2Launcher.GameLaunch;
@@ -17,7 +18,7 @@ namespace Metin2Launcher.GameLaunch;
/// </summary>
public static class GameProcess
{
public static bool Launch(string clientRoot, string executableName)
public static bool Launch(string clientRoot, string executableName, RuntimeKey? runtimeKey = null)
{
var exePath = Path.Combine(clientRoot, executableName);
if (!File.Exists(exePath))
@@ -28,7 +29,7 @@ public static class GameProcess
try
{
var psi = BuildStartInfo(exePath, clientRoot);
var psi = BuildStartInfo(exePath, clientRoot, runtimeKey);
var p = Process.Start(psi);
if (p is null)
{
@@ -51,25 +52,56 @@ public static class GameProcess
/// through <c>wine</c>) or is itself a Windows binary (which can exec directly).
/// Public so tests can assert the platform branch without actually launching.
/// </summary>
public static ProcessStartInfo BuildStartInfo(string exePath, string workingDirectory)
public static ProcessStartInfo BuildStartInfo(
string exePath,
string workingDirectory,
RuntimeKey? runtimeKey = null)
{
ProcessStartInfo psi;
if (OperatingSystem.IsLinux())
{
var psi = new ProcessStartInfo
psi = new ProcessStartInfo
{
FileName = "wine",
WorkingDirectory = workingDirectory,
UseShellExecute = false,
};
psi.ArgumentList.Add(exePath);
return psi;
// The default ~/.wine prefix lacks tahoma + d3dx9 + vcrun, which
// makes the client run with invisible fonts and missing renderer
// DLLs. If the caller already set WINEPREFIX, honor it; otherwise
// fall back to the metin-specific prefix prepared by
// m2dev-client/scripts/setup-wine-prefix.sh.
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WINEPREFIX")))
{
var home = Environment.GetEnvironmentVariable("HOME");
if (!string.IsNullOrEmpty(home))
{
var metinPrefix = Path.Combine(home, "metin", "wine-metin");
if (Directory.Exists(metinPrefix))
psi.Environment["WINEPREFIX"] = metinPrefix;
}
}
}
else
{
psi = new ProcessStartInfo
{
FileName = exePath,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
};
}
return new ProcessStartInfo
// Forward the m2pack runtime key via env vars scoped to the child.
// MVP uses env var delivery only; the shared-memory delivery is stubbed
// behind IRuntimeKeyDelivery and wired in later.
if (runtimeKey is not null)
{
FileName = exePath,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
};
var delivery = new EnvVarKeyDelivery();
delivery.Apply(psi, runtimeKey);
}
return psi;
}
}

View File

@@ -20,7 +20,7 @@
"status.launching": "Spouštění Metin2…",
"detail.fileProgress": "{0} ({1} z {2})",
"news.title": "Novinky",
"news.empty": "Žádné novinky.",
"news.empty": "Zatím žádné novinky",
"news.previousHeader": "Předchozí verze {0}",
"settings.title": "Nastavení",
"settings.language": "Jazyk",

View File

@@ -20,7 +20,7 @@
"status.launching": "Starting Metin2…",
"detail.fileProgress": "{0} ({1} of {2})",
"news.title": "News",
"news.empty": "No news.",
"news.empty": "No news yet",
"news.previousHeader": "Previous version {0}",
"settings.title": "Settings",
"settings.language": "Language",

View File

@@ -10,6 +10,17 @@ namespace Metin2Launcher.Manifest;
/// </summary>
public sealed class Manifest
{
[JsonPropertyName("format")]
public string? Format { get; set; }
/// <summary>
/// Effective release format. Defaults to <c>legacy-json-blob</c> when the
/// field is absent, which preserves backward compatibility with manifests
/// produced before the m2pack migration.
/// </summary>
public string EffectiveFormat =>
string.IsNullOrWhiteSpace(Format) ? "legacy-json-blob" : Format;
[JsonPropertyName("version")]
public string Version { get; set; } = "";

View File

@@ -1,7 +1,10 @@
using Metin2Launcher.Apply;
using Metin2Launcher.Config;
using Metin2Launcher.Formats;
using Metin2Launcher.Logging;
using Metin2Launcher.Manifest;
using Metin2Launcher.Runtime;
using Metin2Launcher.Telemetry;
using Metin2Launcher.Transfer;
namespace Metin2Launcher.Orchestration;
@@ -35,19 +38,30 @@ public sealed class UpdateProgress
/// </summary>
public sealed class UpdateOrchestrator
{
public sealed record Result(bool Success, LauncherState FinalState, LoadedManifest? Manifest);
public sealed record Result(
bool Success,
LauncherState FinalState,
LoadedManifest? Manifest,
IReleaseFormat? Format = null,
RuntimeKey? RuntimeKey = null);
private readonly string _clientRoot;
private readonly string _stagingDir;
private readonly LauncherSettings _settings;
private readonly HttpClient _http;
private readonly ClientAppliedReporter? _reporter;
public UpdateOrchestrator(string clientRoot, LauncherSettings settings, HttpClient http)
public UpdateOrchestrator(
string clientRoot,
LauncherSettings settings,
HttpClient http,
ClientAppliedReporter? reporter = null)
{
_clientRoot = clientRoot;
_stagingDir = Path.Combine(clientRoot, LauncherConfig.StateDirName, LauncherConfig.StagingDirName);
_settings = settings;
_http = http;
_reporter = reporter;
}
public async Task<Result> RunAsync(IProgress<UpdateProgress> progress, CancellationToken ct)
@@ -84,13 +98,31 @@ public sealed class UpdateOrchestrator
}
var manifest = loaded.Manifest;
Log.Info($"manifest {manifest.Version} verified, {manifest.Files.Count} files");
Log.Info($"manifest {manifest.Version} verified, format={manifest.EffectiveFormat}, {manifest.Files.Count} files");
// 2b. dispatch on release format
IReleaseFormat format;
try
{
format = ReleaseFormatFactory.Resolve(manifest.EffectiveFormat);
}
catch (NotSupportedException ex)
{
Log.Error("unsupported release format, falling back offline: " + ex.Message);
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded);
}
// 3. diff
progress.Report(new UpdateProgress { State = LauncherState.Diffing, Manifest = loaded });
var platform = OperatingSystem.IsWindows() ? "windows" : "linux";
var applicable = manifest.Files.Where(f => f.AppliesTo(platform)).ToList();
// The client is always a Windows PE binary launched through Wine on
// Linux hosts, so the manifest "platform" we apply is always "windows"
// regardless of host OS. Using host OS would drop Metin2.exe and the
// python314 / openssl / mingw runtime DLLs whenever the launcher runs
// on Linux, leaving an unbootable install dir.
const string platform = "windows";
var applicable = format.FilterApplicable(manifest, platform);
var needed = new List<(ManifestFile File, string FinalPath, string StagingPath)>();
long totalBytes = 0;
@@ -109,7 +141,7 @@ public sealed class UpdateOrchestrator
{
Log.Error($"required file rejected by path safety: {ex.Message}");
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded);
return new Result(false, LauncherState.OfflineFallback, loaded, format);
}
Log.Warn($"optional file rejected by path safety: {ex.Message}");
continue;
@@ -128,8 +160,17 @@ public sealed class UpdateOrchestrator
Log.Info("client already up to date");
var currentManifestPath = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json");
try { File.WriteAllBytes(currentManifestPath, loaded.RawBytes); } catch { }
// Even on no-op we still need to re-load the runtime key so the
// game process can receive it on this launch.
var noopOutcome = new ReleaseOutcome();
try { format.OnApplied(_clientRoot, loaded, noopOutcome); }
catch (Exception ex) { Log.Warn("format OnApplied (no-op path) failed: " + ex.Message); }
PruneStaleFiles(manifest);
progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
return new Result(true, LauncherState.UpToDate, loaded);
return new Result(true, LauncherState.UpToDate, loaded, format, noopOutcome.RuntimeKey);
}
// 4. download
@@ -172,7 +213,7 @@ public sealed class UpdateOrchestrator
{
Log.Error($"required file {file.Path} failed to download", ex);
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded);
return new Result(false, LauncherState.OfflineFallback, loaded, format);
}
Log.Warn($"optional file {file.Path} skipped: {ex.Message}");
// make sure the bytes from this file don't leave the bar stuck low
@@ -194,14 +235,81 @@ public sealed class UpdateOrchestrator
{
Log.Error("apply failed", ex);
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded);
return new Result(false, LauncherState.OfflineFallback, loaded, format);
}
var current = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json");
try { File.WriteAllBytes(current, loaded.RawBytes); } catch { }
Log.Info($"update complete: {toApply.Count} files applied");
// 6. format-specific post-apply hook (e.g. load the m2pack runtime key)
var outcome = new ReleaseOutcome();
try
{
format.OnApplied(_clientRoot, loaded, outcome);
}
catch (Exception ex)
{
Log.Error("format OnApplied hook failed", ex);
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded, format);
}
// 7. opt-in telemetry ping (bounded, best-effort, never blocks)
if (_reporter is not null && _reporter.IsEnabled)
{
try
{
await _reporter.ReportAsync(format.Name, manifest.Version, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Warn("telemetry reporter threw: " + ex.Message);
}
}
PruneStaleFiles(manifest);
Log.Info($"update complete: {toApply.Count} files applied (format={format.Name})");
progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
return new Result(true, LauncherState.UpToDate, loaded);
return new Result(true, LauncherState.UpToDate, loaded, format, outcome.RuntimeKey);
}
// Walk clientRoot and delete any file not in the manifest. Skips the
// .updates state dir. Called after both the normal apply path and the
// already-up-to-date short-circuit so switching to a leaner release
// does not leave orphaned files on disk.
//
// The keep set is files {manifest.launcher.path}. Per the manifest
// spec (m2dev-client/docs/update-manifest.md), the top-level launcher
// entry is privileged and is never listed in files; it must survive
// prune so a correctly-authored manifest does not delete the updater.
private void PruneStaleFiles(Manifest.Manifest manifest)
{
var keepRel = new HashSet<string>(
manifest.Files.Select(f => f.Path.Replace('/', Path.DirectorySeparatorChar)),
StringComparer.OrdinalIgnoreCase);
if (manifest.Launcher?.Path is { Length: > 0 } launcherPath)
{
keepRel.Add(launcherPath.Replace('/', Path.DirectorySeparatorChar));
}
int pruned = 0;
try
{
var rootFull = Path.GetFullPath(_clientRoot);
var stateDirFull = Path.GetFullPath(Path.Combine(_clientRoot, LauncherConfig.StateDirName));
foreach (var path in Directory.EnumerateFiles(rootFull, "*", SearchOption.AllDirectories))
{
if (path.StartsWith(stateDirFull, StringComparison.Ordinal)) continue;
var rel = Path.GetRelativePath(rootFull, path);
if (keepRel.Contains(rel)) continue;
try { File.Delete(path); pruned++; }
catch (Exception ex) { Log.Warn($"failed to prune {rel}: {ex.Message}"); }
}
}
catch (Exception ex)
{
Log.Warn("prune walk failed: " + ex.Message);
}
if (pruned > 0) Log.Info($"pruned {pruned} stale files not in manifest");
}
}

View File

@@ -5,6 +5,8 @@ using Metin2Launcher.GameLaunch;
using Metin2Launcher.Logging;
using Metin2Launcher.Manifest;
using Metin2Launcher.Orchestration;
using Metin2Launcher.Runtime;
using Metin2Launcher.Telemetry;
using Metin2Launcher.UI;
using Metin2Launcher.UI.ViewModels;
using Velopack;
@@ -58,7 +60,8 @@ public static class Program
var settingsPath = Path.Combine(clientRoot, LauncherConfig.StateDirName, "launcher-settings.json");
var settings = LauncherSettings.Load(settingsPath);
var orchestrator = new UpdateOrchestrator(clientRoot, settings, http);
var reporter = new ClientAppliedReporter(http, LauncherConfig.TelemetryUrlTemplate);
var orchestrator = new UpdateOrchestrator(clientRoot, settings, http, reporter);
var progress = new Progress<UpdateProgress>(p =>
{
var pct = p.Percent.HasValue ? $" {(int)p.Percent.Value}%" : "";
@@ -67,10 +70,12 @@ public static class Program
});
var updatedOk = false;
RuntimeKey? runtimeKey = null;
try
{
var result = await orchestrator.RunAsync(progress, cts.Token).ConfigureAwait(false);
updatedOk = result.Success;
runtimeKey = result.RuntimeKey;
}
catch (ManifestSignatureException ex)
{
@@ -92,7 +97,7 @@ public static class Program
if (!updatedOk)
Log.Warn("proceeding with current local client (offline fallback)");
var launched = GameProcess.Launch(clientRoot, LauncherConfig.GameExecutable);
var launched = GameProcess.Launch(clientRoot, LauncherConfig.GameExecutable, runtimeKey);
return launched ? 0 : 1;
}

View File

@@ -0,0 +1,30 @@
using System.Diagnostics;
namespace Metin2Launcher.Runtime;
/// <summary>
/// Delivers the runtime key to the game process via environment variables.
/// This is the MVP mechanism — every platform supports it, no native interop,
/// no timing windows. The variables are scoped to the child's environment only
/// (we mutate <see cref="ProcessStartInfo.Environment"/>, not the launcher's
/// global process environment), so other processes on the machine never see them.
/// </summary>
public sealed class EnvVarKeyDelivery : IRuntimeKeyDelivery
{
public const string MasterKeyVar = "M2PACK_MASTER_KEY_HEX";
public const string SignPubkeyVar = "M2PACK_SIGN_PUBKEY_HEX";
public const string KeyIdVar = "M2PACK_KEY_ID";
public string Name => "env-var";
public void Apply(ProcessStartInfo psi, RuntimeKey key)
{
if (psi is null) throw new ArgumentNullException(nameof(psi));
if (key is null) throw new ArgumentNullException(nameof(key));
// Never set these on the launcher process itself — scope them to the child only.
psi.Environment[MasterKeyVar] = key.MasterKeyHex;
psi.Environment[SignPubkeyVar] = key.SignPubkeyHex;
psi.Environment[KeyIdVar] = key.KeyId;
}
}

View File

@@ -0,0 +1,18 @@
using System.Diagnostics;
namespace Metin2Launcher.Runtime;
/// <summary>
/// Strategy for handing a <see cref="RuntimeKey"/> to the game process. The
/// MVP implements a single strategy (<see cref="EnvVarKeyDelivery"/>) — the
/// Windows shared-memory strategy is stubbed in <c>SharedMemoryKeyDelivery</c>
/// and will be wired once the client-side receiver lands.
/// </summary>
public interface IRuntimeKeyDelivery
{
/// <summary>Name of the delivery mechanism, used for logs and tests.</summary>
string Name { get; }
/// <summary>Mutates <paramref name="psi"/> so the spawned process can pick up the key.</summary>
void Apply(ProcessStartInfo psi, RuntimeKey key);
}

View File

@@ -0,0 +1,76 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Metin2Launcher.Runtime;
/// <summary>
/// In-memory representation of a runtime key bundle delivered alongside an
/// m2pack release. The launcher never decrypts or opens .m2p archives itself;
/// it merely parses the key bundle produced by the release tool and passes it
/// through to the game executable via one of the <see cref="IRuntimeKeyDelivery"/>
/// mechanisms.
///
/// File layout (runtime-key.json, placed next to the manifest by the release
/// tool, never signed independently — its integrity comes from the signed
/// manifest that references it):
/// <code>
/// {
/// "key_id": "2026.04.14-1",
/// "master_key_hex": "&lt;64 hex chars&gt;",
/// "sign_pubkey_hex": "&lt;64 hex chars&gt;"
/// }
/// </code>
/// </summary>
public sealed class RuntimeKey
{
[JsonPropertyName("key_id")]
public string KeyId { get; set; } = "";
[JsonPropertyName("master_key_hex")]
public string MasterKeyHex { get; set; } = "";
[JsonPropertyName("sign_pubkey_hex")]
public string SignPubkeyHex { get; set; } = "";
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = false,
};
/// <summary>Parses a runtime-key.json blob. Throws on malformed or incomplete data.</summary>
public static RuntimeKey Parse(ReadOnlySpan<byte> raw)
{
var rk = JsonSerializer.Deserialize<RuntimeKey>(raw, _opts)
?? throw new InvalidDataException("runtime-key deserialized to null");
Validate(rk);
return rk;
}
public static RuntimeKey Load(string path)
{
var bytes = File.ReadAllBytes(path);
return Parse(bytes);
}
private static void Validate(RuntimeKey rk)
{
if (string.IsNullOrWhiteSpace(rk.KeyId))
throw new InvalidDataException("runtime-key missing 'key_id'");
if (!IsHex(rk.MasterKeyHex, 64))
throw new InvalidDataException("runtime-key 'master_key_hex' must be 64 hex chars");
if (!IsHex(rk.SignPubkeyHex, 64))
throw new InvalidDataException("runtime-key 'sign_pubkey_hex' must be 64 hex chars");
}
private static bool IsHex(string s, int expectedLen)
{
if (s is null || s.Length != expectedLen) return false;
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!ok) return false;
}
return true;
}
}

View File

@@ -0,0 +1,39 @@
using System.Diagnostics;
namespace Metin2Launcher.Runtime;
/// <summary>
/// STUB. Windows shared-memory delivery for the runtime key is not implemented
/// yet — the receiver on the client side has not landed. This type exists so
/// the factory wiring in <see cref="Program"/> and the docs have a concrete
/// name to reference, and so a future change can fill in the body without
/// churning callers.
///
/// Planned implementation:
/// 1. Create a named, write-only <c>MemoryMappedFile</c> with a random suffix
/// (e.g. <c>m2pack-key-&lt;guid&gt;</c>) and a <c>NamedPipeServerStream</c>-style
/// ACL that only grants access to the child PID once it has been spawned.
/// 2. Write a short binary header (magic, key_id length, key_id, master_key,
/// sign_pubkey) into the mapping.
/// 3. Set an env var on the child with the mapping name so the receiver can
/// <c>OpenExisting</c> and read the key before zeroing the source.
/// 4. Zero and close the mapping from the launcher side once the child
/// signals it has consumed the key (via <c>ClientAppliedReporter</c>).
///
/// Until then, calling <see cref="Apply"/> throws — the factory must never
/// select this delivery on a non-Windows host, and on Windows the code path
/// is guarded behind a feature flag that is off by default.
/// </summary>
public sealed class SharedMemoryKeyDelivery : IRuntimeKeyDelivery
{
public string Name => "shared-memory";
public void Apply(ProcessStartInfo psi, RuntimeKey key)
{
// TODO(m2pack): implement Windows shared-memory delivery once the
// client-side receiver is in place. Tracked in docs/m2pack-integration.md.
throw new NotSupportedException(
"shared-memory runtime key delivery is not implemented yet; " +
"use EnvVarKeyDelivery for the MVP.");
}
}

View File

@@ -0,0 +1,107 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using Metin2Launcher.Logging;
namespace Metin2Launcher.Telemetry;
/// <summary>
/// Opt-in "client applied this release" ping. Fires at most once per update
/// run, after the apply phase succeeds, and only if a non-empty template URL
/// is configured on the launcher settings. The caller passes in an
/// <see cref="HttpClient"/> that the reporter does NOT own.
///
/// Design rules:
/// - timeout is strictly bounded (5 seconds) — telemetry never blocks the
/// launch flow
/// - failures are always swallowed and logged as warnings
/// - nothing personally identifying is sent: just format, version and a
/// best-effort machine identifier derived from the env
/// - no retries, no queueing, no persistent buffer
/// </summary>
public sealed class ClientAppliedReporter
{
public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
private readonly HttpClient _http;
private readonly string? _urlTemplate;
public ClientAppliedReporter(HttpClient http, string? urlTemplate)
{
_http = http;
_urlTemplate = urlTemplate;
}
/// <summary>True when the reporter is actually wired to a destination.</summary>
public bool IsEnabled => !string.IsNullOrWhiteSpace(_urlTemplate);
/// <summary>
/// Sends a single POST to the configured URL. Returns <c>true</c> if the
/// server accepted it (2xx), <c>false</c> on any other outcome. Never throws.
/// </summary>
public async Task<bool> ReportAsync(string format, string version, CancellationToken ct)
{
if (!IsEnabled)
return false;
try
{
var url = Expand(_urlTemplate!, format, version);
var body = JsonSerializer.SerializeToUtf8Bytes(new Payload
{
Format = format,
Version = version,
Timestamp = DateTime.UtcNow.ToString("o"),
});
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(Timeout);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new ByteArrayContent(body),
};
req.Content.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
using var resp = await _http.SendAsync(req, linked.Token).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
Log.Warn($"telemetry: client-applied POST returned {(int)resp.StatusCode}");
return false;
}
Log.Info($"telemetry: reported client-applied format={format} version={version}");
return true;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
Log.Warn($"telemetry: client-applied POST timed out after {Timeout.TotalSeconds}s");
return false;
}
catch (Exception ex)
{
Log.Warn("telemetry: client-applied POST failed: " + ex.Message);
return false;
}
}
/// <summary>
/// Expands <c>{format}</c> and <c>{version}</c> placeholders in the URL
/// template. Case-sensitive, by design — we want obvious failures if the
/// template is wrong.
/// </summary>
public static string Expand(string template, string format, string version)
{
var sb = new StringBuilder(template);
sb.Replace("{format}", Uri.EscapeDataString(format));
sb.Replace("{version}", Uri.EscapeDataString(version));
return sb.ToString();
}
private sealed class Payload
{
public string Format { get; set; } = "";
public string Version { get; set; } = "";
public string Timestamp { get; set; } = "";
}
}

View File

@@ -5,34 +5,126 @@
CanResize="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="Full"
Background="#0F0F12"
Title="{Binding WindowTitle}">
<Grid RowDefinitions="100,*">
<Window.Resources>
<!-- Banner gradient: deep crimson with darker edges, gives the header depth -->
<LinearGradientBrush x:Key="BannerGradient" StartPoint="0%,0%" EndPoint="100%,100%">
<GradientStop Offset="0.0" Color="#3D0000"/>
<GradientStop Offset="0.5" Color="#7A0000"/>
<GradientStop Offset="1.0" Color="#2A0000"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="PlayGradient" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0.0" Color="#C41515"/>
<GradientStop Offset="1.0" Color="#7A0000"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="PlayGradientHover" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0.0" Color="#E52020"/>
<GradientStop Offset="1.0" Color="#8B0000"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="LeftColumnBrush" Color="#17171B"/>
<SolidColorBrush x:Key="RightColumnBrush" Color="#0F0F12"/>
<SolidColorBrush x:Key="AccentBrush" Color="#C41515"/>
<SolidColorBrush x:Key="MutedTextBrush" Color="#888892"/>
<SolidColorBrush x:Key="BodyTextBrush" Color="#E0E0E4"/>
</Window.Resources>
<Window.Styles>
<Style Selector="Button.play">
<Setter Property="Background" Value="{StaticResource PlayGradient}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderBrush" Value="#E85050"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style Selector="Button.play:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource PlayGradientHover}"/>
<Setter Property="BorderBrush" Value="#FF6060"/>
</Style>
<Style Selector="Button.play:disabled /template/ ContentPresenter">
<Setter Property="Background" Value="#3A1515"/>
<Setter Property="BorderBrush" Value="#502020"/>
<Setter Property="Foreground" Value="#886060"/>
</Style>
<Style Selector="Button.exit">
<Setter Property="Background" Value="#24242A"/>
<Setter Property="Foreground" Value="#C0C0C8"/>
<Setter Property="BorderBrush" Value="#3A3A42"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style Selector="Button.exit:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#2E2E36"/>
<Setter Property="BorderBrush" Value="#50505A"/>
</Style>
<Style Selector="Button.icon">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#EAEAEA"/>
<Setter Property="BorderBrush" Value="#FFFFFF33"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="3"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style Selector="Button.icon:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#FFFFFF1A"/>
<Setter Property="BorderBrush" Value="#FFFFFF66"/>
</Style>
<Style Selector="ProgressBar">
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Setter Property="Background" Value="#24242A"/>
</Style>
</Window.Styles>
<Grid RowDefinitions="110,*">
<!-- Banner -->
<Border Grid.Row="0" Background="#8B0000">
<Border Grid.Row="0" Background="{StaticResource BannerGradient}">
<Grid>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<!-- Thin accent borders top and bottom of the banner for definition -->
<Border VerticalAlignment="Top" Height="2" Background="#C41515" Opacity="0.6"/>
<Border VerticalAlignment="Bottom" Height="2" Background="#C41515" Opacity="0.6"/>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="4">
<TextBlock Text="{Binding BannerTitle}"
FontSize="32" FontWeight="Bold"
FontSize="34"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center"/>
HorizontalAlignment="Center"
LetterSpacing="6"/>
<TextBlock Text="{Binding BannerVersion}"
FontSize="12"
Foreground="#FFD7D7"
HorizontalAlignment="Center"/>
FontSize="11"
FontWeight="Light"
Foreground="#FFB0B0"
HorizontalAlignment="Center"
LetterSpacing="3"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,8,12,0"
Spacing="6">
<Button Content="⚙" Width="32" Height="32"
Foreground="White" Background="Transparent"
BorderBrush="#FFFFFF44" BorderThickness="1"
Margin="0,10,14,0"
Spacing="8">
<Button Classes="icon"
Content="⚙"
Width="34" Height="34"
FontSize="16"
ToolTip.Tip="{Binding SettingsTooltip}"
Command="{Binding OpenSettingsCommand}"/>
<Button Content="✕" Width="32" Height="32"
Foreground="White" Background="Transparent"
BorderBrush="#FFFFFF44" BorderThickness="1"
<Button Classes="icon"
Content="✕"
Width="34" Height="34"
FontSize="14"
ToolTip.Tip="{Binding CloseTooltip}"
Command="{Binding ExitCommand}"/>
</StackPanel>
@@ -40,62 +132,101 @@
</Border>
<!-- Body -->
<Grid Grid.Row="1" ColumnDefinitions="300,*">
<!-- Left -->
<Border Grid.Column="0" Padding="20" Background="#F4F4F4">
<Grid Grid.Row="1" ColumnDefinitions="320,*">
<!-- Left column: status + progress + buttons -->
<Border Grid.Column="0"
Background="{StaticResource LeftColumnBrush}"
BorderBrush="#2A2A32"
BorderThickness="0,0,1,0"
Padding="24,28,24,24">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<TextBlock Grid.Row="0"
Text="{Binding StatusText}"
FontSize="18" FontWeight="SemiBold"
FontSize="18"
FontWeight="SemiBold"
TextWrapping="Wrap"
LineHeight="24"
Foreground="{Binding StatusForeground}"/>
<ProgressBar Grid.Row="1"
Margin="0,16,0,4"
Height="18"
Margin="0,20,0,6"
Height="8"
CornerRadius="4"
Minimum="0" Maximum="100"
Value="{Binding ProgressValue}"
IsIndeterminate="{Binding IsIndeterminate}"/>
<TextBlock Grid.Row="2"
Text="{Binding ProgressPercentText}"
FontSize="12"
Foreground="#555"/>
FontSize="11"
FontWeight="Medium"
Foreground="{StaticResource MutedTextBrush}"/>
<TextBlock Grid.Row="3"
Margin="0,12,0,0"
Margin="0,16,0,0"
Text="{Binding DetailText}"
FontSize="12"
TextWrapping="Wrap"
Foreground="#444"/>
Foreground="{StaticResource MutedTextBrush}"/>
<StackPanel Grid.Row="4"
Orientation="Horizontal"
Spacing="10"
Spacing="12"
HorizontalAlignment="Left">
<Button Content="{Binding PlayLabel}"
Width="110" Height="38"
FontSize="16" FontWeight="SemiBold"
Background="#8B0000" Foreground="White"
<Button Classes="play"
Content="{Binding PlayLabel}"
Width="130" Height="44"
IsEnabled="{Binding CanPlay}"
Command="{Binding PlayCommand}"/>
<Button Content="{Binding ExitLabel}"
Width="110" Height="38"
FontSize="14"
<Button Classes="exit"
Content="{Binding ExitLabel}"
Width="100" Height="44"
Command="{Binding ExitCommand}"/>
</StackPanel>
</Grid>
</Border>
<!-- Right -->
<Border Grid.Column="1" Padding="20" Background="White">
<!-- Right column: news / changelog with empty-state placeholder -->
<Border Grid.Column="1"
Background="{StaticResource RightColumnBrush}"
Padding="28,28,28,24">
<DockPanel>
<TextBlock DockPanel.Dock="Top"
Text="{Binding NewsTitle}"
FontSize="16" FontWeight="SemiBold"
Margin="0,0,0,8"/>
<StackPanel DockPanel.Dock="Top" Spacing="2" Margin="0,0,0,14">
<TextBlock Text="{Binding NewsTitle}"
FontSize="18"
FontWeight="SemiBold"
Foreground="{StaticResource BodyTextBrush}"
LetterSpacing="1"/>
<Border Height="1"
Background="#2A2A32"
HorizontalAlignment="Stretch"
Margin="0,8,0,0"/>
</StackPanel>
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<TextBlock Text="{Binding NewsText}"
TextWrapping="Wrap"
FontSize="13"
Foreground="#222"/>
<Grid>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,60,0,0"
Spacing="10"
IsVisible="{Binding IsNewsEmpty}">
<TextBlock Text="◇"
FontSize="46"
Foreground="#3A3A42"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding NewsEmptyLabel}"
FontSize="13"
Foreground="{StaticResource MutedTextBrush}"
HorizontalAlignment="Center"/>
</StackPanel>
<TextBlock Text="{Binding NewsText}"
TextWrapping="Wrap"
FontSize="13"
LineHeight="20"
Foreground="{StaticResource BodyTextBrush}"
IsVisible="{Binding IsNewsPresent}"/>
</Grid>
</ScrollViewer>
</DockPanel>
</Border>

View File

@@ -14,6 +14,7 @@ using Metin2Launcher.Localization;
using Metin2Launcher.Logging;
using Metin2Launcher.Manifest;
using Metin2Launcher.Orchestration;
using Metin2Launcher.Runtime;
using Velopack;
using Velopack.Sources;
@@ -33,13 +34,19 @@ public sealed partial class MainWindowViewModel : ObservableObject
private readonly object _progressLock = new();
private DispatcherTimer? _flushTimer;
[ObservableProperty] private string _statusText = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsNewsEmpty), nameof(IsNewsPresent))]
private string _statusText = "";
[ObservableProperty] private string _detailText = "";
[ObservableProperty] private string _newsText = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsNewsEmpty), nameof(IsNewsPresent))]
private string _newsText = "";
[ObservableProperty] private double _progressValue;
[ObservableProperty] private bool _isIndeterminate;
[ObservableProperty] private bool _canPlay;
[ObservableProperty] private IBrush _statusForeground = Brushes.Black;
[ObservableProperty] private IBrush _statusForeground = Brushes.White;
[ObservableProperty] private string _progressPercentText = "";
public string WindowTitle => Loc.Get("window.title");
@@ -50,9 +57,14 @@ public sealed partial class MainWindowViewModel : ObservableObject
public string SettingsTooltip => Loc.Get("button.settings");
public string CloseTooltip => Loc.Get("button.close");
public string NewsTitle => Loc.Get("news.title");
public string NewsEmptyLabel => Loc.Get("news.empty");
public bool IsNewsEmpty => string.IsNullOrWhiteSpace(NewsText);
public bool IsNewsPresent => !IsNewsEmpty;
private LauncherState _state = LauncherState.Idle;
private LoadedManifest? _lastManifest;
private RuntimeKey? _lastRuntimeKey;
private bool _signatureBlocked;
public MainWindowViewModel()
@@ -84,6 +96,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
OnPropertyChanged(nameof(SettingsTooltip));
OnPropertyChanged(nameof(CloseTooltip));
OnPropertyChanged(nameof(NewsTitle));
OnPropertyChanged(nameof(NewsEmptyLabel));
// Refresh status text from current state
ApplyState(_state);
}
@@ -125,6 +138,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
if (result is not null)
{
_lastManifest = result.Manifest;
_lastRuntimeKey = result.RuntimeKey;
ApplyState(result.FinalState);
await TryFetchNewsAsync(result.Manifest);
}
@@ -206,7 +220,10 @@ public sealed partial class MainWindowViewModel : ObservableObject
_ => "",
};
StatusForeground = s == LauncherState.SignatureFailed ? Brushes.DarkRed : Brushes.Black;
// Dark theme: red on critical failure, white on everything else.
StatusForeground = s == LauncherState.SignatureFailed
? new SolidColorBrush(Color.FromRgb(0xFF, 0x50, 0x50))
: Brushes.White;
var indeterminate = s is LauncherState.FetchingManifest
or LauncherState.VerifyingSignature
@@ -228,7 +245,8 @@ public sealed partial class MainWindowViewModel : ObservableObject
{
if (loaded is null)
{
NewsText = Loc.Get("news.empty");
// Leave NewsText empty so the XAML empty-state placeholder renders.
NewsText = "";
return;
}
@@ -278,7 +296,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
{
if (!CanPlay) return;
ApplyState(LauncherState.Launching);
var ok = GameProcess.Launch(_clientRoot, LauncherConfig.GameExecutable);
var ok = GameProcess.Launch(_clientRoot, LauncherConfig.GameExecutable, _lastRuntimeKey);
if (ok)
{
if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d)

View File

@@ -0,0 +1,124 @@
using System.Net;
using System.Net.Http;
using Metin2Launcher.Telemetry;
using Xunit;
namespace Metin2Launcher.Tests;
public class ClientAppliedReporterTests
{
[Fact]
public void Disabled_when_template_is_empty()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, "");
Assert.False(r.IsEnabled);
}
[Fact]
public void Disabled_when_template_is_null()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, null);
Assert.False(r.IsEnabled);
}
[Fact]
public void Disabled_when_template_is_whitespace()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, " ");
Assert.False(r.IsEnabled);
}
[Fact]
public async Task ReportAsync_noop_when_disabled()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, "");
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
Assert.False(ok);
}
[Fact]
public void Expand_substitutes_placeholders()
{
var s = ClientAppliedReporter.Expand("https://x/{format}/{version}", "m2pack", "2026.04.14-1");
Assert.Equal("https://x/m2pack/2026.04.14-1", s);
}
[Fact]
public void Expand_uri_escapes_values()
{
var s = ClientAppliedReporter.Expand("https://x/{version}", "m2pack", "v 1+beta");
Assert.Contains("v%201%2Bbeta", s);
}
[Fact]
public void Expand_leaves_unknown_placeholders()
{
var s = ClientAppliedReporter.Expand("https://x/{other}", "m2pack", "v1");
Assert.Equal("https://x/{other}", s);
}
[Fact]
public async Task ReportAsync_posts_json_on_success()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondWith(HttpStatusCode.OK);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var r = new ClientAppliedReporter(http, url + "?format={format}&version={version}");
var ok = await r.ReportAsync("m2pack", "2026.04.14-1", CancellationToken.None);
Assert.True(ok);
var req = listener.LastRequest!;
Assert.Equal("POST", req.Method);
Assert.Contains("format=m2pack", req.RawUrl);
Assert.Contains("\"Format\":\"m2pack\"", req.Body);
Assert.Contains("\"Version\":\"2026.04.14-1\"", req.Body);
}
[Fact]
public async Task ReportAsync_returns_false_on_500()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondWith(HttpStatusCode.InternalServerError);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var r = new ClientAppliedReporter(http, url);
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
Assert.False(ok);
}
[Fact]
public async Task ReportAsync_swallows_connection_refused()
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
// pick a port unlikely to be listening
var r = new ClientAppliedReporter(http, "http://127.0.0.1:1/");
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
Assert.False(ok);
}
[Fact]
public async Task ReportAsync_times_out_after_five_seconds()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondAfter(TimeSpan.FromSeconds(10), HttpStatusCode.OK);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
var r = new ClientAppliedReporter(http, url);
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
sw.Stop();
Assert.False(ok);
// reporter caps at 5s internally — allow slack for CI schedulers
Assert.InRange(sw.Elapsed.TotalSeconds, 3.0, 9.0);
}
}

View File

@@ -0,0 +1,68 @@
using System.Diagnostics;
using Metin2Launcher.Runtime;
using Xunit;
namespace Metin2Launcher.Tests;
public class EnvVarDeliveryTests
{
private static RuntimeKey SampleKey() => new()
{
KeyId = "2026.04.14-1",
MasterKeyHex = new string('a', 64),
SignPubkeyHex = new string('b', 64),
};
[Fact]
public void Apply_sets_expected_env_vars()
{
var psi = new ProcessStartInfo { FileName = "nothing" };
new EnvVarKeyDelivery().Apply(psi, SampleKey());
Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]);
Assert.Equal(new string('a', 64), psi.Environment[EnvVarKeyDelivery.MasterKeyVar]);
Assert.Equal(new string('b', 64), psi.Environment[EnvVarKeyDelivery.SignPubkeyVar]);
}
[Fact]
public void Apply_does_not_leak_into_current_process()
{
var before = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
var psi = new ProcessStartInfo { FileName = "nothing" };
new EnvVarKeyDelivery().Apply(psi, SampleKey());
var after = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
Assert.Equal(before, after); // still unset (or whatever it was)
}
[Fact]
public void Apply_overwrites_existing_values_on_psi()
{
var psi = new ProcessStartInfo { FileName = "nothing" };
psi.Environment[EnvVarKeyDelivery.KeyIdVar] = "stale";
new EnvVarKeyDelivery().Apply(psi, SampleKey());
Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]);
}
[Fact]
public void Apply_rejects_null_args()
{
var d = new EnvVarKeyDelivery();
Assert.Throws<ArgumentNullException>(() => d.Apply(null!, SampleKey()));
Assert.Throws<ArgumentNullException>(() => d.Apply(new ProcessStartInfo(), null!));
}
[Fact]
public void SharedMemoryDelivery_is_stubbed()
{
var d = new SharedMemoryKeyDelivery();
Assert.Equal("shared-memory", d.Name);
Assert.Throws<NotSupportedException>(() =>
d.Apply(new ProcessStartInfo(), SampleKey()));
}
[Fact]
public void Delivery_name_is_env_var()
{
Assert.Equal("env-var", new EnvVarKeyDelivery().Name);
}
}

View File

@@ -1,4 +1,5 @@
using Metin2Launcher.GameLaunch;
using Metin2Launcher.Runtime;
using Xunit;
namespace Metin2Launcher.Tests;
@@ -29,4 +30,46 @@ public class GameProcessTests
Assert.Empty(psi.ArgumentList);
}
}
[Fact]
public void BuildStartInfo_without_runtime_key_does_not_set_m2pack_env()
{
var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe");
var psi = GameProcess.BuildStartInfo(exe, Path.GetTempPath(), runtimeKey: null);
Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.MasterKeyVar));
Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.SignPubkeyVar));
Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.KeyIdVar));
}
[Fact]
public void BuildStartInfo_with_runtime_key_forwards_env_vars()
{
var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe");
var rk = new RuntimeKey
{
KeyId = "2026.04.14-1",
MasterKeyHex = new string('a', 64),
SignPubkeyHex = new string('b', 64),
};
var psi = GameProcess.BuildStartInfo(exe, Path.GetTempPath(), rk);
Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]);
Assert.Equal(new string('a', 64), psi.Environment[EnvVarKeyDelivery.MasterKeyVar]);
Assert.Equal(new string('b', 64), psi.Environment[EnvVarKeyDelivery.SignPubkeyVar]);
}
[Fact]
public void BuildStartInfo_runtime_key_does_not_pollute_current_process()
{
var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe");
var before = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
var rk = new RuntimeKey
{
KeyId = "k",
MasterKeyHex = new string('c', 64),
SignPubkeyHex = new string('d', 64),
};
GameProcess.BuildStartInfo(exe, Path.GetTempPath(), rk);
var after = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
Assert.Equal(before, after);
}
}

View File

@@ -0,0 +1,64 @@
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
public class LegacyJsonBlobFormatTests
{
private static ManifestDto SampleManifest() => new()
{
Version = "v1",
Launcher = new ManifestLauncherEntry { Path = "Metin2Launcher.exe", Sha256 = "x", Size = 1 },
Files =
{
new ManifestFile { Path = "a.pck", Sha256 = "h1", Size = 10 },
new ManifestFile { Path = "Metin2.exe", Sha256 = "h2", Size = 20, Platform = "windows" },
new ManifestFile { Path = "linux-bin", Sha256 = "h3", Size = 30, Platform = "linux" },
},
};
[Fact]
public void Name_is_legacy_json_blob()
{
Assert.Equal("legacy-json-blob", new LegacyJsonBlobFormat().Name);
}
[Fact]
public void FilterApplicable_windows_platform()
{
var format = new LegacyJsonBlobFormat();
var filtered = format.FilterApplicable(SampleManifest(), "windows");
Assert.Equal(2, filtered.Count);
Assert.Contains(filtered, f => f.Path == "a.pck");
Assert.Contains(filtered, f => f.Path == "Metin2.exe");
}
[Fact]
public void FilterApplicable_linux_platform()
{
var format = new LegacyJsonBlobFormat();
var filtered = format.FilterApplicable(SampleManifest(), "linux");
Assert.Equal(2, filtered.Count);
Assert.Contains(filtered, f => f.Path == "a.pck");
Assert.Contains(filtered, f => f.Path == "linux-bin");
}
[Fact]
public void OnApplied_is_noop()
{
var outcome = new ReleaseOutcome();
var tmp = Path.Combine(Path.GetTempPath(), "lg-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
new LegacyJsonBlobFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.Null(outcome.RuntimeKey);
}
finally { Directory.Delete(tmp, true); }
}
}

View File

@@ -0,0 +1,131 @@
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Metin2Launcher.Runtime;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
public class M2PackFormatTests
{
private const string ValidHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
private static ManifestDto SampleManifest() => new()
{
Version = "2026.04.14-1",
Format = "m2pack",
Launcher = new ManifestLauncherEntry { Path = "Metin2Launcher.exe", Sha256 = "x", Size = 1 },
Files =
{
new ManifestFile { Path = "pack/item.m2p", Sha256 = "h1", Size = 1000 },
new ManifestFile { Path = "runtime-key.json", Sha256 = "h2", Size = 200 },
},
};
private static string WriteRuntimeKey(string dir, string keyId = "2026.04.14-1")
{
var path = Path.Combine(dir, M2PackFormat.RuntimeKeyFileName);
File.WriteAllText(path, $$"""
{
"key_id": "{{keyId}}",
"master_key_hex": "{{ValidHex}}",
"sign_pubkey_hex": "{{ValidHex}}"
}
""");
return path;
}
[Fact]
public void Name_is_m2pack()
{
Assert.Equal("m2pack", new M2PackFormat().Name);
}
[Fact]
public void FilterApplicable_includes_m2p_and_runtime_key()
{
var filtered = new M2PackFormat().FilterApplicable(SampleManifest(), "windows");
Assert.Equal(2, filtered.Count);
Assert.Contains(filtered, f => f.Path.EndsWith(".m2p"));
Assert.Contains(filtered, f => f.Path == "runtime-key.json");
}
[Fact]
public void OnApplied_loads_runtime_key_when_present()
{
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
WriteRuntimeKey(tmp);
var outcome = new ReleaseOutcome();
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.NotNull(outcome.RuntimeKey);
Assert.Equal("2026.04.14-1", outcome.RuntimeKey!.KeyId);
Assert.Equal(ValidHex, outcome.RuntimeKey.MasterKeyHex);
}
finally { Directory.Delete(tmp, true); }
}
[Fact]
public void OnApplied_tolerates_missing_runtime_key()
{
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
var outcome = new ReleaseOutcome();
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.Null(outcome.RuntimeKey); // warn-logged, not thrown
}
finally { Directory.Delete(tmp, true); }
}
[Fact]
public void OnApplied_throws_on_malformed_runtime_key()
{
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
File.WriteAllText(Path.Combine(tmp, M2PackFormat.RuntimeKeyFileName), "{ not valid }");
var outcome = new ReleaseOutcome();
Assert.ThrowsAny<Exception>(() =>
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome));
}
finally { Directory.Delete(tmp, true); }
}
[Fact]
public void Launcher_never_opens_m2p_archive()
{
// Guard test: the format must not touch the .m2p file itself. We assert
// by constructing a manifest whose .m2p entry points at a file that
// does not exist on disk and would throw if opened — OnApplied must
// ignore it entirely and only touch runtime-key.json.
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
WriteRuntimeKey(tmp);
// Intentionally no pack/item.m2p on disk.
var outcome = new ReleaseOutcome();
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.NotNull(outcome.RuntimeKey);
}
finally { Directory.Delete(tmp, true); }
}
}

View File

@@ -0,0 +1,86 @@
using System.Text;
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
public class ReleaseFormatFactoryTests
{
[Fact]
public void Resolves_legacy_by_default()
{
var f = ReleaseFormatFactory.Resolve("legacy-json-blob");
Assert.IsType<LegacyJsonBlobFormat>(f);
}
[Fact]
public void Resolves_m2pack()
{
var f = ReleaseFormatFactory.Resolve("m2pack");
Assert.IsType<M2PackFormat>(f);
}
[Fact]
public void Throws_on_unknown_format()
{
Assert.Throws<NotSupportedException>(() =>
ReleaseFormatFactory.Resolve("evil-format"));
}
[Fact]
public void Manifest_without_format_defaults_to_legacy()
{
var m = new ManifestDto { Version = "v1" };
Assert.Equal("legacy-json-blob", m.EffectiveFormat);
}
[Fact]
public void Manifest_with_blank_format_defaults_to_legacy()
{
var m = new ManifestDto { Version = "v1", Format = " " };
Assert.Equal("legacy-json-blob", m.EffectiveFormat);
}
[Fact]
public void Manifest_with_explicit_format_is_honoured()
{
var m = new ManifestDto { Version = "v1", Format = "m2pack" };
Assert.Equal("m2pack", m.EffectiveFormat);
}
[Fact]
public void Loader_tolerates_unknown_top_level_field_next_to_format()
{
const string json = """
{
"format": "m2pack",
"version": "2026.04.14-1",
"created_at": "2026-04-14T14:00:00Z",
"future_field": {"nested": 1},
"launcher": {"path": "Metin2Launcher.exe", "sha256": "abcd", "size": 1},
"files": []
}
""";
var m = ManifestLoader.Parse(Encoding.UTF8.GetBytes(json));
Assert.Equal("m2pack", m.EffectiveFormat);
Assert.Equal("2026.04.14-1", m.Version);
}
[Fact]
public void Loader_parses_manifest_without_format_as_legacy()
{
const string json = """
{
"version": "2026.04.14-1",
"created_at": "2026-04-14T14:00:00Z",
"launcher": {"path": "Metin2Launcher.exe", "sha256": "abcd", "size": 1},
"files": []
}
""";
var m = ManifestLoader.Parse(Encoding.UTF8.GetBytes(json));
Assert.Null(m.Format);
Assert.Equal("legacy-json-blob", m.EffectiveFormat);
}
}

View File

@@ -0,0 +1,83 @@
using System.Text;
using Metin2Launcher.Runtime;
using Xunit;
namespace Metin2Launcher.Tests;
public class RuntimeKeyTests
{
private const string ValidHex64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
private static string SampleJson(
string keyId = "2026.04.14-1",
string master = ValidHex64,
string pub = ValidHex64)
=> $$"""
{
"key_id": "{{keyId}}",
"master_key_hex": "{{master}}",
"sign_pubkey_hex": "{{pub}}"
}
""";
[Fact]
public void Parse_happy_path()
{
var rk = RuntimeKey.Parse(Encoding.UTF8.GetBytes(SampleJson()));
Assert.Equal("2026.04.14-1", rk.KeyId);
Assert.Equal(ValidHex64, rk.MasterKeyHex);
Assert.Equal(ValidHex64, rk.SignPubkeyHex);
}
[Fact]
public void Parse_rejects_missing_key_id()
{
var json = SampleJson(keyId: "");
Assert.Throws<InvalidDataException>(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)));
}
[Theory]
[InlineData("")]
[InlineData("tooshort")]
[InlineData("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")] // right len, wrong chars
public void Parse_rejects_bad_master_key(string master)
{
var json = SampleJson(master: master);
Assert.Throws<InvalidDataException>(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)));
}
[Fact]
public void Parse_rejects_bad_pubkey()
{
var json = SampleJson(pub: new string('g', 64));
Assert.Throws<InvalidDataException>(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)));
}
[Fact]
public void Parse_accepts_uppercase_hex()
{
var upper = ValidHex64.ToUpperInvariant();
var rk = RuntimeKey.Parse(Encoding.UTF8.GetBytes(SampleJson(master: upper, pub: upper)));
Assert.Equal(upper, rk.MasterKeyHex);
}
[Fact]
public void Load_reads_from_disk()
{
var tmp = Path.Combine(Path.GetTempPath(), "runtime-key-test-" + Guid.NewGuid() + ".json");
File.WriteAllText(tmp, SampleJson());
try
{
var rk = RuntimeKey.Load(tmp);
Assert.Equal("2026.04.14-1", rk.KeyId);
}
finally { File.Delete(tmp); }
}
[Fact]
public void Parse_rejects_malformed_json()
{
Assert.Throws<System.Text.Json.JsonException>(() =>
RuntimeKey.Parse(Encoding.UTF8.GetBytes("not json")));
}
}

View File

@@ -0,0 +1,107 @@
using System.Net;
using System.Text;
namespace Metin2Launcher.Tests;
/// <summary>
/// Minimal in-process HTTP server backed by <see cref="HttpListener"/> for
/// telemetry / orchestrator tests. Dependency-free — no TestServer, no
/// WireMock, no new NuGet package.
/// </summary>
public sealed class TestHttpListener : IDisposable
{
private readonly HttpListener _listener = new();
private CancellationTokenSource _cts = new();
private Task? _loop;
private HttpStatusCode _status = HttpStatusCode.OK;
private TimeSpan _delay = TimeSpan.Zero;
private Func<HttpListenerRequest, byte[]>? _bodyFactory;
public CapturedRequest? LastRequest { get; private set; }
public string Start()
{
int port = GetFreeTcpPort();
var prefix = $"http://127.0.0.1:{port}/";
_listener.Prefixes.Add(prefix);
_listener.Start();
_loop = Task.Run(LoopAsync);
return prefix;
}
public void RespondWith(HttpStatusCode status) => _status = status;
public void RespondAfter(TimeSpan delay, HttpStatusCode status)
{
_delay = delay;
_status = status;
}
public void RespondBody(Func<HttpListenerRequest, byte[]> factory) => _bodyFactory = factory;
private async Task LoopAsync()
{
while (!_cts.IsCancellationRequested)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync().ConfigureAwait(false); }
catch { return; }
try
{
string body;
using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8))
body = await r.ReadToEndAsync().ConfigureAwait(false);
LastRequest = new CapturedRequest
{
Method = ctx.Request.HttpMethod,
RawUrl = ctx.Request.RawUrl ?? "",
Body = body,
};
if (_delay > TimeSpan.Zero)
{
try { await Task.Delay(_delay, _cts.Token).ConfigureAwait(false); }
catch (OperationCanceledException) { }
}
ctx.Response.StatusCode = (int)_status;
if (_bodyFactory != null)
{
var payload = _bodyFactory(ctx.Request);
await ctx.Response.OutputStream.WriteAsync(payload, _cts.Token).ConfigureAwait(false);
}
ctx.Response.Close();
}
catch
{
try { ctx.Response.Abort(); } catch { }
}
}
}
private static int GetFreeTcpPort()
{
var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
l.Start();
var port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
public void Dispose()
{
try { _cts.Cancel(); } catch { }
try { _listener.Stop(); _listener.Close(); } catch { }
try { _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
_cts.Dispose();
}
public sealed class CapturedRequest
{
public string Method { get; set; } = "";
public string RawUrl { get; set; } = "";
public string Body { get; set; } = "";
}
}

View File

@@ -0,0 +1,112 @@
using System.Text;
using System.Text.Json;
using Metin2Launcher.Config;
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Metin2Launcher.Orchestration;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
/// <summary>
/// Exercises the <see cref="UpdateOrchestrator"/>'s format-dispatch wiring.
///
/// The orchestrator short-circuits to a signature exception when the fetched
/// manifest isn't signed by the hardcoded <see cref="LauncherConfig.PublicKeyHex"/>.
/// We can't realistically override that constant in a test without widening
/// the surface area, so these tests focus on:
///
/// 1. The unit-level pieces the orchestrator relies on (factory, effective
/// format, file filtering, post-apply hook) — covered in sibling test
/// classes and asserted here for regression safety.
/// 2. End-to-end "manifest fetched but signature mismatches" showing the
/// orchestrator really reaches the verify stage against a synthetic
/// HTTP endpoint served by <see cref="TestHttpListener"/>.
/// </summary>
public class UpdateOrchestratorFormatDispatchTests
{
[Fact]
public async Task Bad_signature_throws_before_format_dispatch()
{
using var listener = new TestHttpListener();
var url = listener.Start();
var manifest = new ManifestDto
{
Format = "m2pack",
Version = "v1",
CreatedAt = "2026-04-14T00:00:00Z",
Launcher = new ManifestLauncherEntry { Path = "L.exe", Sha256 = "dead", Size = 1 },
};
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(manifest));
var badSig = new byte[64]; // deliberately wrong signature
listener.RespondBody(req => req.RawUrl!.EndsWith(".sig") ? badSig : body);
var clientRoot = Path.Combine(Path.GetTempPath(), "orch-" + Guid.NewGuid());
Directory.CreateDirectory(clientRoot);
try
{
using var http = new HttpClient();
var settings = new LauncherSettings
{
DevMode = true,
ManifestUrlOverride = url + "manifest.json",
};
var orchestrator = new UpdateOrchestrator(clientRoot, settings, http);
await Assert.ThrowsAsync<ManifestSignatureException>(async () =>
await orchestrator.RunAsync(new Progress<UpdateProgress>(_ => { }), CancellationToken.None));
}
finally { Directory.Delete(clientRoot, true); }
}
[Fact]
public async Task Manifest_fetch_failure_returns_offline_fallback()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondWith(System.Net.HttpStatusCode.NotFound);
var clientRoot = Path.Combine(Path.GetTempPath(), "orch-" + Guid.NewGuid());
Directory.CreateDirectory(clientRoot);
try
{
using var http = new HttpClient();
var settings = new LauncherSettings
{
DevMode = true,
ManifestUrlOverride = url + "manifest.json",
};
var orchestrator = new UpdateOrchestrator(clientRoot, settings, http);
var result = await orchestrator.RunAsync(
new Progress<UpdateProgress>(_ => { }),
CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(LauncherState.OfflineFallback, result.FinalState);
Assert.Null(result.Format);
Assert.Null(result.RuntimeKey);
}
finally { Directory.Delete(clientRoot, true); }
}
[Fact]
public void Result_carries_format_and_key_slots()
{
// Contract test: the orchestrator Result exposes Format and RuntimeKey
// so the launcher can thread the runtime key into GameProcess.Launch.
var r = new UpdateOrchestrator.Result(true, LauncherState.UpToDate, null);
Assert.Null(r.Format);
Assert.Null(r.RuntimeKey);
}
[Fact]
public void Factory_falls_back_through_effective_format_on_empty_string()
{
// A manifest with format="" should resolve via EffectiveFormat -> legacy.
var m = new ManifestDto { Version = "v1", Format = "" };
var f = ReleaseFormatFactory.Resolve(m.EffectiveFormat);
Assert.IsType<LegacyJsonBlobFormat>(f);
}
}