initial commit: reforgermods.net launcher 0.1.0
A desktop server browser for Arma Reforger, built on the reforgermods.net v2 API. Live server list with search, sort and filters; a detail inspector with population history and mod readiness; measured ICMP latency; favorites, recents and settings; and direct joining through Steam. Tauri 2 and Rust, with a Preact frontend that builds to 80 KB of JavaScript in one chunk. Logic lives in rfm-core, which has no Tauri dependency and is tested directly; src-tauri is the window, the command surface and the wiring. Packaged for the public beta: a per-user NSIS installer, a portable .exe and an Arch package, with signature-verified background updates on Windows and a release pipeline that builds from a tag. The artifacts are not yet Authenticode-signed — that is wired and waiting on an Azure Trusted Signing identity, and docs/distribution.md says so plainly rather than letting anyone discover it at the SmartScreen dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
91 files changed
+24994
No files matched your search
@@ -0,0 +1,240 @@
|
||||
# reforgermods.net API surface used by the launcher
|
||||
|
||||
Base URL: `https://api.reforgermods.net`, fixed. There is deliberately **no
|
||||
setting** for it: a user-editable origin could redirect every request — including
|
||||
the install identifier — at an arbitrary host. Development builds override it
|
||||
with the `RFM_API_BASE_URL` environment variable.
|
||||
|
||||
The launcher is an anonymous public client. It sends no API key and no
|
||||
credentials — only:
|
||||
|
||||
```http
|
||||
User-Agent: reforgermods.net-launcher/<version>
|
||||
X-Launcher-Install-Id: <random v4 UUID, generated on first run, user-resettable>
|
||||
```
|
||||
|
||||
The install id lets the API count distinct installs rather than only requests.
|
||||
It is a spoofable analytics hint, never authentication, and must not gate
|
||||
anything. Full spec and suggested API-side handling:
|
||||
[`client-identification.md`](client-identification.md).
|
||||
|
||||
Public rate limit at time of writing (`GET /v2/rate-limits`, unauthenticated):
|
||||
60 requests/minute, burst 20, 5 000/day, keyed by client IP.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /v2/servers`
|
||||
|
||||
The server browser. Parameters the launcher sends:
|
||||
|
||||
| Parameter | Values | Notes |
|
||||
| --- | --- | --- |
|
||||
| `page` | 1–10000 | |
|
||||
| `perPage` | 1–500 | Launcher default 100 |
|
||||
| `sort` | `players`, `name`, `newest`, `lastSeen` | Default `players` |
|
||||
| `search` | free text | Matches server names |
|
||||
| `region` | ping-site id | From `/v2/servers/ping-sites` |
|
||||
| `platform` | `pc`, `xbox`, `psn` | Client platform, canonicalised server-side |
|
||||
| `official` | `true`/`false` | Omitted entirely when unset |
|
||||
| `hasMods` | `true`/`false` | |
|
||||
| `battleye` | `true`/`false` | |
|
||||
| `locked` | `true`/`false` | Password-protected |
|
||||
| `includeOffline` | `true` | Omitted for online-only, the default |
|
||||
|
||||
Tri-state filters are omitted when unset, because `official=false` means
|
||||
"community only" and is not the same as "do not filter".
|
||||
|
||||
**Two filters do not exist server-side** and are applied client-side to the
|
||||
loaded page. Neither is ever sent, and neither varies the response cache key:
|
||||
|
||||
- `minPlayers` — no minimum-population parameter exists.
|
||||
- `scenario` — no scenario parameter exists. `search` *does* match scenario text
|
||||
as well as server names, but sending a scenario name there would also match
|
||||
server names and would collide with the user's own search term. The picker is
|
||||
populated from the distinct `scenarioName` values on the loaded page, collected
|
||||
before filtering so choosing one does not empty the picker that chose it.
|
||||
|
||||
Both are labelled as page-local in the UI, and the launcher reports how many rows
|
||||
they removed rather than silently showing fewer.
|
||||
|
||||
Response carries `meta` (pagination), `dataset` (snapshot freshness) and `data`.
|
||||
|
||||
### `GET /v2/servers/{id}`
|
||||
|
||||
Detail for one server, keyed by the Reforger lobby room UUID. Adds `present`,
|
||||
`joinable`, `fps`, `activity` (24 h / 7 d population aggregates), `modSummary`
|
||||
and the full `mods` array with per-mod `version` and `size`.
|
||||
|
||||
The launcher reads `fps` as *server simulation FPS*, which is what the API
|
||||
reports. It is not a client ping, and is not labelled as one.
|
||||
|
||||
### `GET /v2/servers/{id}/history`
|
||||
|
||||
Population history for the inspector's chart.
|
||||
|
||||
| Parameter | Values |
|
||||
| --- | --- |
|
||||
| `range` | `6h`, `24h`, `72h`, `7d`, `30d`, `90d`, `1y`, `all` |
|
||||
|
||||
Anything else is rejected with `INVALID_RANGE`, so the launcher models this as an
|
||||
enum rather than a free string. Omitting `range` defaults to `24h`.
|
||||
|
||||
The response names the bucket width it chose, which the launcher displays rather
|
||||
than assuming:
|
||||
|
||||
| Range | Points | Bucket |
|
||||
| --- | ---: | --- |
|
||||
| `6h` | 72 | `5m` |
|
||||
| `24h` | 288 | `5m` |
|
||||
| `7d` | 167 | `hour` |
|
||||
| `30d` | 636 | `hour` |
|
||||
|
||||
Points are `{t, avg, min, max, cap}` with `t` in unix seconds, ascending. A server
|
||||
with no recorded history returns an empty `points` array — a normal state, not an
|
||||
error, and rendered as "no recorded history for this range".
|
||||
|
||||
The launcher offers `6h`/`24h`/`7d`/`30d`. `90d`, `1y` and `all` are supported by
|
||||
the API but are not useful in a compact panel.
|
||||
|
||||
### `GET /v2/servers/ping-sites`
|
||||
|
||||
Region ids and human labels, used to populate the region filter. Cached for an
|
||||
hour; the launcher falls back to humanising the raw id if the call fails.
|
||||
|
||||
### `GET /v2/health`
|
||||
|
||||
Liveness probe, used only as the status fallback described below.
|
||||
|
||||
## Snapshot freshness, and `/v2/status`
|
||||
|
||||
**`GET /v2/status` is not deployed on `api.reforgermods.net`.** Verified
|
||||
2026-09-24: it answers `404 NOT_FOUND`. The launcher still calls it, treats a 404
|
||||
on that fixed route as `EndpointUnavailable` (distinct from "server not found"),
|
||||
and degrades rather than showing an error.
|
||||
|
||||
The header does not need it in the common case anyway. Every server response
|
||||
already carries the indexer's snapshot freshness:
|
||||
|
||||
```json
|
||||
"dataset": {
|
||||
"warming": false,
|
||||
"stale": false,
|
||||
"snapshotAgeSeconds": 8.27,
|
||||
"lastCollectionAt": "2026-09-24T06:54:58Z",
|
||||
"onlineServerCount": 4999
|
||||
}
|
||||
```
|
||||
|
||||
So "Server data updated 18s ago" is rendered from a request the launcher was
|
||||
making regardless — no extra polling, and no duplication of backend health logic.
|
||||
The resolution order is:
|
||||
|
||||
1. `/v2/status` — if it answers, its `status` word and `dataset` win.
|
||||
2. `/v2/health` plus the most recent `dataset` seen on a server response.
|
||||
3. Neither answers → an unobtrusive "API unreachable" state.
|
||||
|
||||
When `/v2/status` ships, `StatusReport` in `crates/rfm-core/src/api/models.rs`
|
||||
should be tightened to its real shape; it is intentionally all-optional today so
|
||||
an unknown response body cannot break the header.
|
||||
|
||||
## Server identifiers
|
||||
|
||||
Two shapes, both joinable:
|
||||
|
||||
| Kind | Example | Count |
|
||||
| --- | --- | --- |
|
||||
| Community | `73e8fb7f-8789-4d88-8aa8-ea69b9aa092f` | the majority |
|
||||
| Official | `nitrado_17516010` | 364 at time of writing |
|
||||
|
||||
Sampled across 600 servers, every non-UUID id was official and every official id
|
||||
was `nitrado_` followed by digits. `ServerId::parse` accepts both; treating ids
|
||||
as UUID-only silently made every official server unjoinable, and the failure
|
||||
only showed when someone pressed Join.
|
||||
|
||||
The API publishes `joinUrl` for community servers but **not** for official ones,
|
||||
so the official join URI is built locally using the same payload shape with the
|
||||
provider id in place of the UUID. That is consistent with the verified community
|
||||
mechanism but is **not yet confirmed against a live client** for official
|
||||
servers specifically.
|
||||
|
||||
## Fleet population
|
||||
|
||||
`GET /v2/servers/history?range=6h` returns a time series plus a `now` block:
|
||||
|
||||
```json
|
||||
"now": {"onlineServers": 5089, "players": 16352, "capacity": 224986, "queue": 694}
|
||||
```
|
||||
|
||||
`now.players` is the only fleet-wide player total the API exposes — the
|
||||
`dataset` object on other responses counts servers, not players — so the header
|
||||
total comes from here. The launcher reads only `now` and ignores the series.
|
||||
|
||||
## Scenario imagery
|
||||
|
||||
`scenarioImageUrl` and `scenarioThumbnailUrl` are absent for roughly half of
|
||||
servers. Across 300 sampled servers, every URL that *was* present used `https`
|
||||
on exactly one host:
|
||||
|
||||
```text
|
||||
https://ar-gcp-cdn.bistudio.com/...
|
||||
```
|
||||
|
||||
The launcher validates against that exact host before rendering
|
||||
(`safeImageUrl` in `src/lib/format.ts`), and the page CSP `img-src` enforces the
|
||||
same allowlist independently. Anything else renders as no image.
|
||||
|
||||
## Server addresses and ping
|
||||
|
||||
`GET /v2/servers/{id}` returns `host` and `port`. Measured across 30 live
|
||||
servers: `host` was present on 20 and was a **literal IP address in all 20** —
|
||||
never a hostname.
|
||||
|
||||
The launcher uses `host` for direct ICMP latency measurement, and validates it
|
||||
first: literal public IPs only, with loopback, private, link-local, CGNAT,
|
||||
multicast and documentation ranges refused. Remote data must not be able to
|
||||
choose a DNS lookup or aim probes at the user's own network.
|
||||
|
||||
`port` is parsed but nothing connects to it. TCP connect timing was evaluated as
|
||||
a ping method and **rejected**: see `README.md`.
|
||||
|
||||
## Field names worth noting
|
||||
|
||||
- `battlEye` — capital E, mid-word. Mis-spelling the rename silently reads
|
||||
`false` for every server, so there is a test asserting the mapping against a
|
||||
captured response.
|
||||
- `platform` is the **host OS** (`LINUX`/`WINDOWS`). `platforms` is the array of
|
||||
**client** platforms (`pc`, `xbox`, `psn`). They are unrelated.
|
||||
- Workshop mod ids are 16 uppercase hex characters; server ids are lowercase
|
||||
UUIDs. Both are validated before use.
|
||||
|
||||
## Deliberately unused
|
||||
|
||||
- `/v2/servers/{id}/mods` — the detail endpoint already returns the mod list
|
||||
*with* resolved sizes; this one skips size enrichment by default.
|
||||
- `/v2/mods/*`, `/v2/tools/*` — mod browsing and planning are not in scope yet.
|
||||
- `activity` on the server detail — superseded in the inspector by the history
|
||||
chart, which shows the same shape with more resolution. Still parsed.
|
||||
- `analytics` on each server — returned and parsed, but not displayed yet.
|
||||
|
||||
## Test fixtures
|
||||
|
||||
`crates/rfm-core/tests/fixtures/` holds real captured responses, and the model
|
||||
tests parse them. Refresh them with:
|
||||
|
||||
```sh
|
||||
curl -s 'https://api.reforgermods.net/v2/servers?perPage=3&sort=players' \
|
||||
-o crates/rfm-core/tests/fixtures/servers_page.json
|
||||
curl -s 'https://api.reforgermods.net/v2/servers/73e8fb7f-8789-4d88-8aa8-ea69b9aa092f' \
|
||||
-o crates/rfm-core/tests/fixtures/server_detail.json
|
||||
curl -s 'https://api.reforgermods.net/v2/servers/ping-sites' \
|
||||
-o crates/rfm-core/tests/fixtures/ping_sites.json
|
||||
curl -s 'https://api.reforgermods.net/v2/servers/73e8fb7f-8789-4d88-8aa8-ea69b9aa092f/history?range=24h' \
|
||||
-o crates/rfm-core/tests/fixtures/server_history.json
|
||||
```
|
||||
|
||||
`crates/rfm-core/tests/live_api.rs` checks the contract against the live API and
|
||||
is ignored by default:
|
||||
|
||||
```sh
|
||||
cargo test -p rfm-core --test live_api -- --ignored
|
||||
```
|
||||
Reference in new issue
Block a user