Compare commits
17 Commits
e23f4e1e6e
...
ac0034fc51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac0034fc51 | ||
|
|
8f6f378a23 | ||
|
|
027786a79d | ||
|
|
d2775bcfd8 | ||
|
|
9ffae5c7d9 | ||
|
|
1790502b58 | ||
|
|
0e95171e50 | ||
|
|
6ad8e8db19 | ||
|
|
3d98ac4470 | ||
|
|
ee7edfd990 | ||
|
|
dcc2b0fc42 | ||
|
|
5edd0c5aea | ||
|
|
3d3129032a | ||
|
|
0526ac2ef9 | ||
|
|
3f8acfc597 | ||
|
|
d9d45d0010 | ||
|
|
ad78f8f139 |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -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
49
CHANGELOG.md
Normal 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.
|
||||
53
README.md
53
README.md
@@ -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
114
docs/m2pack-integration.md
Normal 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.
|
||||
466
docs/metin-release-cli-plan.md
Normal file
466
docs/metin-release-cli-plan.md
Normal 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
114
scripts/publish-launcher.sh
Executable 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."
|
||||
@@ -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";
|
||||
|
||||
|
||||
52
src/Metin2Launcher/Formats/IReleaseFormat.cs
Normal file
52
src/Metin2Launcher/Formats/IReleaseFormat.cs
Normal 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; }
|
||||
}
|
||||
27
src/Metin2Launcher/Formats/LegacyJsonBlobFormat.cs
Normal file
27
src/Metin2Launcher/Formats/LegacyJsonBlobFormat.cs
Normal 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/<pre-m2pack>) 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.
|
||||
}
|
||||
}
|
||||
59
src/Metin2Launcher/Formats/M2PackFormat.cs
Normal file
59
src/Metin2Launcher/Formats/M2PackFormat.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/Metin2Launcher/Formats/ReleaseFormatFactory.cs
Normal file
26
src/Metin2Launcher/Formats/ReleaseFormatFactory.cs
Normal 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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; } = "";
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
30
src/Metin2Launcher/Runtime/EnvVarKeyDelivery.cs
Normal file
30
src/Metin2Launcher/Runtime/EnvVarKeyDelivery.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/Metin2Launcher/Runtime/IRuntimeKeyDelivery.cs
Normal file
18
src/Metin2Launcher/Runtime/IRuntimeKeyDelivery.cs
Normal 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);
|
||||
}
|
||||
76
src/Metin2Launcher/Runtime/RuntimeKey.cs
Normal file
76
src/Metin2Launcher/Runtime/RuntimeKey.cs
Normal 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": "<64 hex chars>",
|
||||
/// "sign_pubkey_hex": "<64 hex chars>"
|
||||
/// }
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
39
src/Metin2Launcher/Runtime/SharedMemoryKeyDelivery.cs
Normal file
39
src/Metin2Launcher/Runtime/SharedMemoryKeyDelivery.cs
Normal 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-<guid></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.");
|
||||
}
|
||||
}
|
||||
107
src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs
Normal file
107
src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs
Normal 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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
124
tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs
Normal file
124
tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
68
tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs
Normal file
68
tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
64
tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs
Normal file
64
tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs
Normal 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); }
|
||||
}
|
||||
}
|
||||
131
tests/Metin2Launcher.Tests/M2PackFormatTests.cs
Normal file
131
tests/Metin2Launcher.Tests/M2PackFormatTests.cs
Normal 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); }
|
||||
}
|
||||
}
|
||||
86
tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs
Normal file
86
tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
83
tests/Metin2Launcher.Tests/RuntimeKeyTests.cs
Normal file
83
tests/Metin2Launcher.Tests/RuntimeKeyTests.cs
Normal 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")));
|
||||
}
|
||||
}
|
||||
107
tests/Metin2Launcher.Tests/TestHttpListener.cs
Normal file
107
tests/Metin2Launcher.Tests/TestHttpListener.cs
Normal 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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user