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
|
||||
```
|
||||
@@ -0,0 +1,248 @@
|
||||
# Launcher client identification — API implementation notes
|
||||
|
||||
**Audience:** whoever maintains `api.reforgermods.net` (ReforgerWorkshopAPI).
|
||||
|
||||
**Status: implemented on both sides.** The launcher sends the header described
|
||||
below, and the API records it as a derived key. The sections headed *Suggested
|
||||
API-side work* are kept as the rationale for what was built; what actually
|
||||
shipped is summarised in [What the API does](#what-the-api-does).
|
||||
|
||||
The header remains additive: an API that ignores it behaves exactly as before,
|
||||
and the launcher does not care whether it is read.
|
||||
|
||||
## The problem
|
||||
|
||||
Today the API can see "50 000 requests from the launcher" but not whether that
|
||||
is 5 000 installs making 10 requests each, or 50 installs making 1 000 each.
|
||||
`User-Agent` identifies the *application*; client IP is a poor proxy for a
|
||||
*person* (CGNAT, shared households, mobile, VPNs) and is also the thing you least
|
||||
want to retain.
|
||||
|
||||
## What the launcher sends
|
||||
|
||||
Every request carries both of:
|
||||
|
||||
```http
|
||||
User-Agent: reforgermods.net-launcher/0.1.0
|
||||
X-Launcher-Install-Id: 8f14e45f-ceea-467a-9c2b-1f2a3b4c5d6e
|
||||
```
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| Header | `X-Launcher-Install-Id` |
|
||||
| Format | Lowercase canonical UUID, `8-4-4-4-12` hex. Always exactly 36 chars. UUID version 8 (custom derivation) where machine-derived, version 4 where random. |
|
||||
| Generation | `SHA-256(application salt ‖ machine value)`, first 16 bytes, formatted as a UUID. |
|
||||
| Lifetime | Survives uninstall and reinstall, because it is derived rather than stored. Changes when the OS is reinstalled. |
|
||||
| Fallback | A random v4 UUID persisted in the launcher's state file, used only where no machine value is readable. That one does *not* survive a reinstall. |
|
||||
| Reset | None. There is no reset action, by design — see below. |
|
||||
| Absent when | The stored value fails validation and no machine value is readable. The launcher sends **no header at all** rather than a malformed one. |
|
||||
|
||||
### Machine value by platform
|
||||
|
||||
| Platform | Source |
|
||||
| --- | --- |
|
||||
| Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` |
|
||||
| Linux | `/etc/machine-id`, else `/var/lib/dbus/machine-id` |
|
||||
| Other / unreadable | random fallback |
|
||||
|
||||
These are per-OS-installation values. They are not device serials, not MAC
|
||||
addresses, not tied to a user account, and they change when the OS is
|
||||
reinstalled — which is the intended granularity.
|
||||
|
||||
**The machine value never leaves the process.** Only the salted digest is sent,
|
||||
so the API cannot reverse the identifier into a machine id, and the same machine
|
||||
running different software derives a different value. An all-zero or blank
|
||||
machine value is rejected rather than used, since it would collapse every
|
||||
affected machine onto one identifier.
|
||||
|
||||
**Why not IP.** An address changes with DHCP, VPNs, tethering and moving
|
||||
networks, so it would split one install into many, while merging everyone behind
|
||||
a shared NAT into one. It is also the one genuinely personal field in the
|
||||
request, and the API already sees it.
|
||||
|
||||
Client-side references: `INSTALL_ID_HEADER` in
|
||||
`crates/rfm-core/src/api/client.rs`, `stable_install_id` in
|
||||
`crates/rfm-core/src/identity.rs`.
|
||||
|
||||
## What it is not
|
||||
|
||||
**It is not authentication, and it must not gate anything.** The launcher is a
|
||||
public, untrusted client. Anyone can send any value, rotate it per request, or
|
||||
copy someone else's. Treat it exactly as what it is: a voluntary, spoofable
|
||||
analytics hint.
|
||||
|
||||
Concretely:
|
||||
|
||||
- **Do not** rate limit on it alone. Keep the existing IP-based limiting as the
|
||||
enforcement mechanism. A caller that wants to evade a per-install limit only
|
||||
has to send a new UUID each time, so an install-keyed limit would penalise
|
||||
honest clients and stop nobody.
|
||||
- **Do not** use it for entitlement, quota assignment, or moderation.
|
||||
- **Do** use it for counting, cohorting, and retention analysis.
|
||||
|
||||
If per-client enforcement is ever needed, that requires real launcher
|
||||
authentication, which is a separate design.
|
||||
|
||||
## What the API does
|
||||
|
||||
Implemented in `ReforgerWorkshopAPI`, matching the design below except where
|
||||
noted:
|
||||
|
||||
| Concern | Where |
|
||||
| --- | --- |
|
||||
| Header constant | `telemetry.InstallIDHeader` |
|
||||
| Validation | `telemetry.ValidInstallID` — canonical lowercase UUID, any version; the nil UUID is rejected |
|
||||
| Derived key | `Anonymizer.InstallKey` — HMAC-SHA256(secret, id), truncated to 128 bits, domain-separated from `NetworkID` |
|
||||
| Storage | `request_events.install_key`, with a partial index; added by the existing `ensureColumn` migration |
|
||||
| Classification | `launcher` client kind and source; counts as activity |
|
||||
| Version | split out of the User-Agent into `client_version` |
|
||||
| Redaction | the raw header is in the middleware's redacted list, so it never reaches the stored header snapshot |
|
||||
|
||||
Two deliberate departures from the design below:
|
||||
|
||||
- **The key is stored as hex `TEXT`, not `BLOB(16)`**, for consistency with
|
||||
`network_id` and `quota_subject_hash`. The value is the same 128 bits.
|
||||
- **The key is not rotated per time window**, unlike `NetworkID`. A key that
|
||||
changes underneath the data every month cannot answer "how many distinct
|
||||
installs, and do they come back", which is the only reason the field exists.
|
||||
Correlation is bounded by rotating the *secret* instead, which re-partitions
|
||||
the whole keyspace from that point on. This is the lever referred to in
|
||||
[Note for the API side](#note-for-the-api-side), and it matters more given the
|
||||
client value is now stable and derived.
|
||||
|
||||
The five assertions under [Testing the API side](#testing-the-api-side) are
|
||||
covered by tests: `telemetry/install_id_test.go` for validation and keying, and
|
||||
`TestLauncher*` in `api/telemetry_middleware_test.go` end to end, including that
|
||||
a caller rotating the id per request gets no more requests than one sending none.
|
||||
|
||||
## Suggested API-side work
|
||||
|
||||
### 1. Accept and validate the header
|
||||
|
||||
Reject-by-ignoring, never by erroring. Suggested handling:
|
||||
|
||||
```text
|
||||
raw := r.Header.Get("X-Launcher-Install-Id")
|
||||
if raw is not exactly a canonical lowercase v4-shaped UUID -> treat as absent
|
||||
```
|
||||
|
||||
Never log the raw value unvalidated, and never echo it back in a response body
|
||||
or error envelope.
|
||||
|
||||
### 2. Store a salted hash, not the value
|
||||
|
||||
The launcher generates the id, but the API does not need to retain it verbatim
|
||||
to count distinct installs:
|
||||
|
||||
```text
|
||||
install_key = truncate(HMAC-SHA256(server_secret, install_id), 128 bits)
|
||||
```
|
||||
|
||||
With a server-side secret this keeps the stored value useless if the analytics
|
||||
store leaks, and it prevents anyone who guesses or harvests an id from querying
|
||||
for that install's activity. Rotating the secret on a schedule (say quarterly)
|
||||
caps how far back any single key can be correlated.
|
||||
|
||||
### 3. Record it on the existing telemetry path
|
||||
|
||||
The API already classifies routes in `telemetry/classify.go` and records request
|
||||
telemetry in `api/telemetry_middleware.go`. The natural shape is one extra
|
||||
nullable column on whatever already records a request, plus a rollup:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `install_key` | `BLOB(16)` nullable | Hashed per above. Null for non-launcher traffic. |
|
||||
| `client_kind` | `TEXT` | `launcher` / `web` / `other`, parsed from User-Agent. |
|
||||
| `client_version` | `TEXT` | From the launcher's User-Agent suffix. |
|
||||
|
||||
Daily rollup suitable for the question that prompted this:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
date(ts) AS day,
|
||||
client_version,
|
||||
COUNT(*) AS requests,
|
||||
COUNT(DISTINCT install_key) AS installs,
|
||||
COUNT(*) * 1.0 /
|
||||
NULLIF(COUNT(DISTINCT install_key), 0) AS requests_per_install
|
||||
FROM request_telemetry
|
||||
WHERE client_kind = 'launcher'
|
||||
GROUP BY day, client_version;
|
||||
```
|
||||
|
||||
That answers "50 000 requests, 5 000 installs, 10 each" directly, and splits it
|
||||
by launcher version so a regression in request volume is attributable to a
|
||||
release.
|
||||
|
||||
### 4. Retention
|
||||
|
||||
The install key is only needed at daily/weekly granularity. Recommend keeping
|
||||
per-request rows for the existing telemetry window, and rolling up to
|
||||
`(day, client_version, install_key)` presence rows beyond that — or to pure
|
||||
counts once the cohort window has passed. There is no product reason to retain
|
||||
per-install request logs indefinitely.
|
||||
|
||||
### 5. Do not add it to the public rate-limit response
|
||||
|
||||
`GET /v2/rate-limits` reports the caller's limits. Since the install id does not
|
||||
affect limits (see above), it should not appear there — surfacing it would imply
|
||||
it does something it does not.
|
||||
|
||||
## Privacy position
|
||||
|
||||
Worth stating plainly, because this changed: the identifier is now **derived
|
||||
from the machine rather than randomly generated**, and there is **no reset**.
|
||||
That is deliberate — a resettable random value made reinstalls look like new
|
||||
installs, which was the exact number the metric exists to get right — but it is
|
||||
a real trade, and the honest framing is:
|
||||
|
||||
- The identifier is a salted hash of a per-OS-installation value. It carries no
|
||||
personal data and cannot be reversed into a machine identity.
|
||||
- It is stable across reinstalls of the launcher, and changes when the OS is
|
||||
reinstalled.
|
||||
- It is not linked to a reforgermods.net account, because the launcher has none.
|
||||
- Its sole purpose is aggregate usage measurement.
|
||||
- It is not shown in the UI. It appears only when the launcher is started with
|
||||
`RFM_DEBUG=1`, which is a developer affordance, not a user setting.
|
||||
|
||||
Anyone wanting genuinely uncorrelatable requests has to block the header, which
|
||||
is a thing a user cannot do from inside the launcher. If that matters for the
|
||||
project's public posture, the mitigation is disclosure — a line in the
|
||||
launcher's description or privacy text — rather than an in-app toggle, because a
|
||||
toggle would reintroduce exactly the undercount the derivation fixes.
|
||||
|
||||
## Note for the API side
|
||||
|
||||
Because the client value is now stable and derived, the server-side hashing in
|
||||
step 2 matters more, not less: it is what stops the analytics store from holding
|
||||
a long-lived per-machine key. Rotating the server secret on a schedule also caps
|
||||
how far back any single key can be correlated, which is the main lever left for
|
||||
limiting retention.
|
||||
|
||||
## Testing the API side
|
||||
|
||||
Once implemented, these should hold:
|
||||
|
||||
1. A request with no header is accepted and recorded with a null `install_key`.
|
||||
2. A request with a malformed header (`not-a-uuid`, 10 KB of text, CRLF
|
||||
injection attempt) is accepted and recorded as if absent — never 4xx, never
|
||||
logged raw.
|
||||
3. Two requests with the same id produce the same `install_key`; with different
|
||||
ids, different keys.
|
||||
4. The same id sent to two different deployments with different secrets produces
|
||||
different keys.
|
||||
5. Rate limiting behaviour is unchanged by the presence or value of the header.
|
||||
|
||||
## Open question for the API owner
|
||||
|
||||
Should the launcher also send a coarse platform token (`windows` / `linux`) so
|
||||
the rollup can distinguish "5 000 Windows installs" from a Proton cohort? That
|
||||
would be one more low-cardinality header and would answer the Linux-support
|
||||
question from real data rather than guesswork. The launcher does **not** send one
|
||||
today; say the word and it is a two-line change.
|
||||
|
||||
**Still open, and now worth deciding.** The beta publishes an Arch package, so
|
||||
there is a Linux cohort to measure for the first time. Note that the question is
|
||||
partly answerable without a new header: the User-Agent is recorded, and the
|
||||
Windows and Linux builds could carry distinguishable version suffixes. A separate
|
||||
low-cardinality header is cleaner, and neither is built.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Discord Rich Presence — setup
|
||||
|
||||
**Status: live.** Application id `1552757014474784898` is set in
|
||||
`src-tauri/src/presence.rs`.
|
||||
|
||||
The launcher appears on a user's Discord profile the way DZSA Launcher does for
|
||||
DayZ — "Playing reforgermods.net launcher", with artwork, the server being
|
||||
viewed, and a button linking to the site.
|
||||
|
||||
The application id supplies the *name* Discord displays, so the Discord
|
||||
application must be named exactly `reforgermods.net launcher`. Artwork still has
|
||||
to be uploaded for the large image to appear; see step 3.
|
||||
|
||||
## What it looks like
|
||||
|
||||
```text
|
||||
┌────┐ reforgermods.net launcher
|
||||
│ RM │ Viewing [OGR2] Old Guard Revival 2
|
||||
└────┘ 12/128 players
|
||||
[ Get the launcher ]
|
||||
```
|
||||
|
||||
- **Application name** — from the Discord application. This is the headline and
|
||||
the reason the application must be created with exactly the right name.
|
||||
- **Details / state** — from `rfm_core::presence`.
|
||||
- **Large image** — the asset keyed `logo` in the application's Rich Presence art.
|
||||
- **Button** — "Get the launcher" → `https://reforgermods.net`.
|
||||
|
||||
## Remaining setup
|
||||
|
||||
1. ~~Create the application~~ — done, id `1552757014474784898`.
|
||||
2. **Confirm the application name is exactly `reforgermods.net launcher`.** This
|
||||
string is what every user's friends list shows; it cannot be changed
|
||||
per-client, and it is the whole promotional value of the feature.
|
||||
3. **Upload the artwork.** Under **Rich Presence → Art Assets**, add
|
||||
`brand/reforger-mods-app-icon.png` with the key **`logo`**, matching
|
||||
`LARGE_IMAGE_KEY` in `src-tauri/src/presence.rs`. Until this exists the text
|
||||
and button still show, but the card has no image.
|
||||
|
||||
The application id is a public identifier, not a secret — it ships in every
|
||||
Discord game integration and is visible in any client's traffic. It is safe in
|
||||
the repository. Do **not** put the application's *client secret* or bot token
|
||||
anywhere near this project; Rich Presence needs neither.
|
||||
|
||||
### Trying it without editing the source
|
||||
|
||||
```sh
|
||||
RFM_DISCORD_APP_ID=123456789012345678 ./reforgermods-launcher
|
||||
```
|
||||
|
||||
## Behaviour
|
||||
|
||||
| Situation | Result |
|
||||
| --- | --- |
|
||||
| No application id configured | Presence is disabled; no thread, no socket. |
|
||||
| Discord not running | Connection fails quietly, retried at most every 30s. |
|
||||
| Discord restarts | Next update reconnects and republishes the current state. |
|
||||
| User turns it off in Settings | Presence is cleared immediately. |
|
||||
|
||||
Presence runs on its own thread and is never awaited from a command, so Discord
|
||||
being slow, absent or broken cannot stall the UI.
|
||||
|
||||
## Privacy
|
||||
|
||||
Presence is **on by default**, since being visible is the point, but it is a
|
||||
visible toggle in Settings because it broadcasts the name of the server being
|
||||
viewed to the user's Discord friends. Nothing else is published: no account, no
|
||||
install identifier, no IP, no mod list.
|
||||
|
||||
## Why the text rules are tested
|
||||
|
||||
`crates/rfm-core/src/presence.rs` builds the strings and is unit tested, because
|
||||
server names are untrusted text from the API and Discord is unforgiving:
|
||||
|
||||
- `details` and `state` cap at 128 characters, and Discord **silently drops** a
|
||||
field under two characters rather than reporting an error.
|
||||
- Truncation is by character. Cutting a multi-byte glyph in half yields invalid
|
||||
UTF-8 and Discord rejects the entire payload — so a server named in Japanese
|
||||
or full of flag emoji would blank the presence rather than shorten it.
|
||||
- Control characters are stripped and whitespace collapsed, so a server name
|
||||
containing newlines cannot reformat the presence card.
|
||||
|
||||
The "reforgermods.net launcher" line is ours and is never taken from remote
|
||||
data, so a server cannot name itself something that impersonates the launcher.
|
||||
@@ -0,0 +1,342 @@
|
||||
# Distributing the launcher from reforgermods.net
|
||||
|
||||
**Implemented, with one thing outstanding.** The decisions below are made, the
|
||||
pipeline exists (`.github/workflows/release.yml`) and 0.1.0 is built. The gap is
|
||||
Authenticode: the release job is wired for Azure Trusted Signing but the
|
||||
credentials do not exist yet, so the first beta artifacts are **unsigned** and
|
||||
will show the SmartScreen warning. Everything else — NSIS per-user installer,
|
||||
signed auto-updates, R2 hosting, the download page — is in place.
|
||||
|
||||
See [Release procedure](#release-procedure) for how to cut one, and
|
||||
[What is still outstanding](#what-is-still-outstanding) for the gap.
|
||||
|
||||
## The shape, in one line
|
||||
|
||||
An **NSIS per-user installer** as the primary download, built on **Windows in
|
||||
CI**, signed with **Azure Trusted Signing**, hosted on **Cloudflare R2** at
|
||||
`dl.reforgermods.net`, with the **Tauri updater** wired so nobody ends up running
|
||||
a stale API client.
|
||||
|
||||
## 1. Installer format
|
||||
|
||||
Tauri can produce three Windows artifacts. They are not equivalent:
|
||||
|
||||
| Format | Admin needed | Notes |
|
||||
| --- | --- | --- |
|
||||
| **NSIS** (`.exe`) | No, with `installMode: currentUser` | Start Menu entry, uninstaller, updater support, smallest |
|
||||
| **MSI** (WiX) | Yes | Enterprise deployment, Group Policy. Irrelevant for a game tool |
|
||||
| Portable `.exe` | No | No Start Menu, no uninstaller, SmartScreen prompt every time |
|
||||
|
||||
**NSIS, per-user.** It is already configured. Per-user matters: a UAC prompt on
|
||||
a game utility loses people, and there is no reason to write outside the user's
|
||||
profile — the launcher already stores everything under the per-user config
|
||||
directory.
|
||||
|
||||
**Done:** `targets` is `["nsis"]`. MSI was removed — it doubled release size and
|
||||
build time for an audience that will not use it.
|
||||
|
||||
The **portable `.exe` is published as a secondary download** for people who will
|
||||
not run installers. It costs nothing: it is the same binary the installer wraps,
|
||||
and the release job copies it straight out of the build.
|
||||
|
||||
### WebView2
|
||||
|
||||
The installer's `webviewInstallMode` decides what happens when the runtime is
|
||||
missing. `downloadBootstrapper` (the default) adds ~2 MB and fetches the runtime
|
||||
only when absent. Windows 11 and current Windows 10 already ship it, so most
|
||||
users never notice. `embedBootstrapper` or `offlineInstaller` only make sense if
|
||||
offline installs matter.
|
||||
|
||||
## 2. Code signing — the decision that actually matters
|
||||
|
||||
Unsigned, every download shows **"Windows protected your PC"** and requires
|
||||
More info → Run anyway. For a launcher whose point is promotion, that is the
|
||||
single biggest conversion loss in the whole pipeline.
|
||||
|
||||
**What changed:** EV certificates used to grant immediate SmartScreen
|
||||
reputation. **Microsoft removed that in 2024.** An EV certificate now builds
|
||||
reputation the same way an OV one does, so paying EV prices purely to skip
|
||||
SmartScreen is no longer justified.
|
||||
|
||||
| Option | Cost | Verdict |
|
||||
| --- | --- | --- |
|
||||
| Nothing | £0 | SmartScreen warning on every download. Not viable for a promoted tool |
|
||||
| OV certificate | ~£200–400/yr | Signs fine; reputation still has to build |
|
||||
| EV certificate | ~£300–600/yr + token/HSM | No longer buys instant reputation. Hard to justify |
|
||||
| **Azure Trusted Signing** | **$9.99/mo** (5,000 signatures) | Cheapest credible route; no hardware token; fits CI cleanly |
|
||||
|
||||
**Recommended: Azure Trusted Signing** (renamed Azure Artifact Signing).
|
||||
Eligibility is verified US / Canada / EU / UK businesses and self-employed
|
||||
individuals — `cedarline.digital` should qualify, and the organisation identity
|
||||
is what appears in the UAC and SmartScreen dialogs.
|
||||
|
||||
Be realistic about reputation: signing removes the *unknown publisher* framing,
|
||||
but SmartScreen reputation still accrues with download volume. Expect some
|
||||
friction on the first releases regardless of route. The mitigation is to keep
|
||||
one stable signing identity rather than rotating certificates, so reputation
|
||||
accumulates against one publisher.
|
||||
|
||||
**State:** chosen, wired, not yet active. The release job signs through
|
||||
`bundle.windows.signCommand`, supplied by `src-tauri/tauri.signing.conf.json` and
|
||||
run by `packaging/windows/sign.cmd`, which shells out to `trusted-signing-cli`.
|
||||
|
||||
Signing has to happen *during* bundling rather than after it, which is why it is
|
||||
a `signCommand` and not a later CI step: the updater artifact is the installer
|
||||
plus a detached signature over it. Sign the installer afterwards and the
|
||||
signature describes a file nobody will download, and every client rejects the
|
||||
update.
|
||||
|
||||
The job checks for `AZURE_CLIENT_ID` and `RFM_SIGN_ENDPOINT` and, when they are
|
||||
absent, builds unsigned and prints a warning to the run summary rather than
|
||||
failing. That is deliberate: it keeps the beta shippable while the Azure identity
|
||||
is sorted out, and it makes the unsigned state loud instead of silent. See
|
||||
[What is still outstanding](#what-is-still-outstanding).
|
||||
|
||||
## 3. Build and release pipeline
|
||||
|
||||
Cross-compiling from Linux works for the bare `.exe` (`scripts/build-windows.sh`)
|
||||
and is fine for development, but the release build should run on Windows:
|
||||
|
||||
- NSIS bundling and signing both want Windows tooling.
|
||||
- The signed artifact should be produced by CI, not a laptop, so releases are
|
||||
reproducible and the signing credential lives in one place.
|
||||
|
||||
**Done:** `.github/workflows/release.yml`. A `windows-latest` job on a `v*` tag
|
||||
runs the full check suite, builds, signs when it can, and uploads the artifacts;
|
||||
a second job generates the manifests and publishes to R2. It is stored under
|
||||
`.github/workflows` so both GitHub Actions and Gitea Actions pick it up, matching
|
||||
the other two repositories.
|
||||
|
||||
The job refuses to build unless the tag and every version in the tree already
|
||||
agree (`scripts/version.sh check "$tag"`), so a mistagged release fails in the
|
||||
first thirty seconds rather than after producing artifacts.
|
||||
|
||||
**Hosting: Cloudflare R2**, served at `dl.reforgermods.net`. It sits on the
|
||||
account that already runs the site, keeps the project free of a GitHub
|
||||
dependency, and downloads never touch the API's budget. The trade is that the
|
||||
bandwidth is ours; for an installer of about 2 MB that is not a real cost at beta
|
||||
volumes. See [R2 setup](#r2-setup) for the one-time configuration.
|
||||
|
||||
Versioned paths are immutable and only two small files move — `/releases.json`
|
||||
and `/updates/latest.json` — which is what makes a rollback a matter of
|
||||
re-publishing one file rather than moving binaries around.
|
||||
|
||||
## 4. Auto-update
|
||||
|
||||
`tauri-plugin-updater` checks a JSON manifest, verifies a signature against a
|
||||
public key baked into the build, and applies the update.
|
||||
|
||||
This matters more than usual here: the launcher is an API client. When
|
||||
`/v2/servers` changes shape, users on an old build get a broken browser and
|
||||
blame the site. An updater turns that from a support problem into a non-event.
|
||||
|
||||
Needs a keypair (`tauri signer generate`) — the private key is a CI secret, the
|
||||
public key ships in `tauri.conf.json`. Distinct from code signing; both are
|
||||
needed and they solve different problems.
|
||||
|
||||
**Done**, in `src-tauri/src/update.rs`. The keypair exists and its public half is
|
||||
in `tauri.conf.json`; the private key is **not** in this repository and has to be
|
||||
added to CI as `TAURI_SIGNING_PRIVATE_KEY` (see
|
||||
[Release procedure](#release-procedure)).
|
||||
|
||||
The behaviour is exactly as proposed, and it is three steps rather than
|
||||
`download_and_install` for a reason:
|
||||
|
||||
1. check and download in the background, so nothing blocks startup;
|
||||
2. hold the downloaded installer until the session ends;
|
||||
3. run it as the app exits, with `restart_after_install(false)`.
|
||||
|
||||
Nobody is interrupted mid-session and nobody is asked to make a decision about a
|
||||
patch release; the update is simply there next time. `Update::install` calls
|
||||
`std::process::exit(0)` on Windows after launching the installer, which is
|
||||
harmless at exit and would be a hard kill at any other moment — which is the
|
||||
other reason the install is pinned to that exact point.
|
||||
|
||||
Every failure path is silent: no endpoint, no network, a malformed manifest or a
|
||||
bad signature all leave the app as it was. `RFM_DEBUG=1` is how it becomes
|
||||
visible.
|
||||
|
||||
**Windows only.** The dependency is scoped to Windows in `Cargo.toml`, so the
|
||||
module does not exist in a Linux build. The Arch package belongs to pacman, and
|
||||
an app that rewrites its own files under `/usr/bin` fights the package manager.
|
||||
|
||||
## 5. Download page
|
||||
|
||||
**Done**, in the `reforgermods-web` repository: `/launcher/` with
|
||||
`public/static/launcher.js` and `public/static/launcher/releases.json`.
|
||||
|
||||
- Detects the visitor's platform and offers the matching build as the primary
|
||||
action; an unrecognised platform gets the full table rather than a guess.
|
||||
- Lists every download with its size and SHA-256.
|
||||
- States the WebView2 requirement as a footnote — nearly nobody needs it.
|
||||
- The page ships a working pre-release state in its HTML and the script only ever
|
||||
upgrades it, so a failed fetch leaves an honest panel rather than a dead button.
|
||||
|
||||
The page reads **its own copy** of `releases.json`, served from the site origin
|
||||
rather than from R2. That avoids a cross-origin dependency on the download host
|
||||
for a page that must render, but it means a release is not visible until that
|
||||
file is updated and the site is deployed. The release job says so in its summary.
|
||||
|
||||
## 6. Versioning
|
||||
|
||||
**Done:** `scripts/version.sh`.
|
||||
|
||||
scripts/version.sh show print the canonical version
|
||||
scripts/version.sh check [tag] fail if any copy disagrees
|
||||
scripts/version.sh set 0.2.0 rewrite every copy
|
||||
|
||||
The canonical value is `[workspace.package] version` in the root `Cargo.toml`,
|
||||
because that is the one the running binary reports: `rfm-core` builds its
|
||||
User-Agent from `CARGO_PKG_VERSION`, so the API's per-version rollups are keyed
|
||||
off it whatever the other files say.
|
||||
|
||||
It still has to be repeated in four places — the workspace manifest,
|
||||
`package.json`, `tauri.conf.json` and the PKGBUILD — because no two of those
|
||||
tools read each other's format. `check` is what makes the repetition safe, and
|
||||
the release job runs it against the tag before building anything. An updater
|
||||
manifest that disagrees with the installed version either loops forever or never
|
||||
fires, and neither failure is obvious from the outside.
|
||||
|
||||
## 7. Linux
|
||||
|
||||
Not a distribution channel yet — but the app does build and run there, and
|
||||
`scripts/build-arch.sh` produces a native Arch package
|
||||
(`target/arch/*.pkg.tar.zst`) for handing to a tester. It builds inside an
|
||||
`archlinux:base-devel` container so the binary links Arch's WebKit and glibc
|
||||
rather than the build host's; `packaging/arch/PKGBUILD` holds the recipe and
|
||||
README covers the two makepkg workarounds it needs.
|
||||
|
||||
Verified end to end: installed into a clean `archlinux:base` container with
|
||||
`pacman -U`, every dependency resolved from the declared `depends`, and the app
|
||||
started under Xvfb, loaded the live server list and resolved pings over
|
||||
unprivileged ICMP.
|
||||
|
||||
**Changed for the beta:** the Arch package is now published alongside the Windows
|
||||
builds rather than handed over per tester. It appears on the download page for
|
||||
visitors the page detects as Arch, and in the table for everyone else.
|
||||
|
||||
That is one distribution, not a Linux strategy. `.deb`, AppImage and an AUR
|
||||
package are all still unbuilt, and `public/static/launcher.js` already knows
|
||||
those platform ids so adding them is a packaging job, not a web one. The updater
|
||||
deliberately does not cover any of them: a distro package is updated by the
|
||||
distro's tooling, not by the app rewriting its own files in `/usr/bin`.
|
||||
|
||||
## Release procedure
|
||||
|
||||
One-time setup, then four steps per release.
|
||||
|
||||
### One-time
|
||||
|
||||
1. **Updater key.** Generated already; the public half is in `tauri.conf.json`.
|
||||
The private key is at `~/.config/reforgermods-launcher/updater.key` on the
|
||||
machine it was generated on and **is not in this repository**. Add it to CI:
|
||||
|
||||
- `TAURI_SIGNING_PRIVATE_KEY` — the file's contents
|
||||
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — empty; the key was generated without
|
||||
one, on the grounds that the key is already a secret in the same store and a
|
||||
password beside it adds a second secret rather than a second factor.
|
||||
Regenerate with a password if you disagree; both have to change together.
|
||||
|
||||
Back it up. Losing it means every installed launcher stops accepting updates,
|
||||
and the only fix is a new key in a new release that everyone installs by hand.
|
||||
|
||||
2. **R2.** See [R2 setup](#r2-setup).
|
||||
|
||||
3. **Signing.** See [What is still outstanding](#what-is-still-outstanding).
|
||||
|
||||
### Per release
|
||||
|
||||
```sh
|
||||
scripts/version.sh set 0.1.1 # rewrites all four version copies
|
||||
# update the changelog in reforgermods-web: src/app/launcher/changelog/page.tsx
|
||||
git commit -am "release 0.1.1" && git tag v0.1.1 && git push --follow-tags
|
||||
```
|
||||
|
||||
The tag starts the pipeline. When it finishes, take `releases.json` from the run
|
||||
artifacts, drop it into `public/static/launcher/releases.json` in
|
||||
`reforgermods-web`, and deploy — until then the page still offers the previous
|
||||
release.
|
||||
|
||||
### Building artifacts by hand
|
||||
|
||||
The release job is the supported path; these are for testing what it will
|
||||
produce. Neither can Authenticode-sign, because that needs a Windows host.
|
||||
|
||||
```sh
|
||||
scripts/build-windows.sh --release # the bare .exe
|
||||
scripts/build-arch.sh # target/arch/*.pkg.tar.zst
|
||||
```
|
||||
|
||||
A full Windows bundle can be cross-built on Linux, but Tauri calls this
|
||||
experimental and it needs NSIS present:
|
||||
|
||||
```sh
|
||||
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.config/reforgermods-launcher/updater.key)"
|
||||
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
|
||||
npm run tauri -- build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
|
||||
```
|
||||
|
||||
Debian's `makensis` has `/usr/share/nsis` compiled in as its data directory and
|
||||
the bundler does not pass `NSISDIR` through, so a rootless install needs a
|
||||
wrapper on `PATH` that exports it before exec'ing the real binary. 0.1.0 was
|
||||
built this way and its updater signature verifies against the shipped public key.
|
||||
|
||||
Then assemble and describe the result:
|
||||
|
||||
```sh
|
||||
python3 scripts/release-manifest.py --version "$(scripts/version.sh show)" \
|
||||
--artifacts dist/release --base-url https://dl.reforgermods.net --out dist/release
|
||||
```
|
||||
|
||||
It writes both manifests from one scan of one directory, so the version, the URLs
|
||||
and the filenames cannot disagree between the download page and the updater. An
|
||||
artifact with no sibling `.sig` is never offered to the updater: the client would
|
||||
reject it, and every launch would check, fail and retry.
|
||||
|
||||
## R2 setup
|
||||
|
||||
One bucket, public over a custom domain:
|
||||
|
||||
```sh
|
||||
npx wrangler r2 bucket create reforgermods-launcher-downloads
|
||||
```
|
||||
|
||||
Then, in the Cloudflare dashboard, attach `dl.reforgermods.net` to the bucket
|
||||
(R2, the bucket, Settings, Public access, Custom domain). The release job needs
|
||||
`CLOUDFLARE_API_TOKEN` (Workers R2 Storage: Edit) and `CLOUDFLARE_ACCOUNT_ID` as
|
||||
secrets on the `release` environment.
|
||||
|
||||
Layout:
|
||||
|
||||
```text
|
||||
dl.reforgermods.net/
|
||||
0.1.0/…-setup.exe installer, and the updater artifact
|
||||
0.1.0/…-setup.exe.sig detached updater signature
|
||||
0.1.0/…-0.1.0.exe portable
|
||||
0.1.0/…-x86_64.pkg.tar.zst Arch package
|
||||
0.1.0/SHA256SUMS
|
||||
updates/latest.json what the updater polls
|
||||
releases.json published for reference; the site reads its own copy
|
||||
```
|
||||
|
||||
Everything under a version prefix is immutable. Only the two manifests move.
|
||||
|
||||
## What is still outstanding
|
||||
|
||||
**Authenticode signing.** The pipeline is wired and inert. To turn it on:
|
||||
|
||||
1. Get a **verified Trusted Signing identity** for `cedarline.digital` and an
|
||||
Azure subscription that owns it. This is the blocking item — it is an identity
|
||||
verification, not a purchase, and it takes as long as it takes.
|
||||
2. Create a Trusted Signing account and certificate profile.
|
||||
3. Add to the `release` environment — secrets `AZURE_CLIENT_ID`,
|
||||
`AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`; variables `RFM_SIGN_ENDPOINT`,
|
||||
`RFM_SIGN_ACCOUNT`, `RFM_SIGN_PROFILE`.
|
||||
|
||||
The job picks it up on the next tag with no code change. `packaging/windows/sign.cmd`
|
||||
has never run on a Windows host, so budget one throwaway tag to shake it out
|
||||
rather than discovering it on a release you meant to publish.
|
||||
|
||||
Until then the beta is unsigned and every download shows **"Windows protected
|
||||
your PC"**. The download page says so plainly rather than letting people discover
|
||||
it at the SmartScreen dialog — an honest note costs less trust than a surprise.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Mod readiness: investigation findings
|
||||
|
||||
**Status: investigated, implemented as reporting only. No action is attached, and none should be until a supported install mechanism exists.**
|
||||
|
||||
The launcher mockup shows an installed / outdated / missing mod breakdown, a
|
||||
download size, and a "Prepare mods & join" action. This document records what of
|
||||
that is actually achievable today. Measurements were taken on 2026-09-24 against
|
||||
a real Arma Reforger installation (game build `24903726`, client `1.8.0.13`) and
|
||||
the live reforgermods.net API.
|
||||
|
||||
## Summary
|
||||
|
||||
| Question | Answer |
|
||||
| --- | --- |
|
||||
| Can installed Workshop mods be detected locally? | **Yes, reliably.** |
|
||||
| Can installed *versions* be detected? | **Yes**, and they match the API exactly. |
|
||||
| Does the API expose the versions a server requires? | **Yes**, with sizes. |
|
||||
| Can missing-mod installation be triggered from outside the game? | **Not verified. No supported mechanism found.** |
|
||||
| Does the game handle missing mods during the Steam join flow? | **Yes** — this is what makes the launcher useful without the above. |
|
||||
|
||||
The first three make a readiness *indicator* feasible, and it now ships. The
|
||||
fourth is what "Prepare mods & join" would need, and it is the one that does not
|
||||
hold up — so the launcher reports and does not offer to act.
|
||||
|
||||
## Where Reforger stores Workshop content
|
||||
|
||||
Downloaded addons live in the user's profile directory, **not** in the Steam
|
||||
application directory and **not** in Steam Workshop storage (Reforger uses
|
||||
Bohemia's own Workshop CDN, so `steamapps/workshop` is empty for app 1874880):
|
||||
|
||||
```text
|
||||
%USERPROFILE%\Documents\My Games\ArmaReforger\
|
||||
addons\<AddonName>_<16-HEX-MOD-ID>\
|
||||
ServerData.json id, name, installed revision version
|
||||
meta full Workshop metadata: versions, dependencies, sizes
|
||||
addon.gproj GUID and dependency GUIDs
|
||||
data.pak content
|
||||
resourceDatabase.rdb
|
||||
logs\
|
||||
profile\
|
||||
```
|
||||
|
||||
`Documents` is commonly redirected to OneDrive, so the implementation asks the
|
||||
shell folder registry (`HKCU\...\Explorer\Shell Folders`, `Personal`) first —
|
||||
which follows a redirection to any path — and keeps
|
||||
`%USERPROFILE%\Documents`, `%USERPROFILE%\OneDrive\Documents` and
|
||||
`%OneDrive%\Documents` as fallbacks.
|
||||
|
||||
### On Linux
|
||||
|
||||
There is no native Linux client: Reforger runs under Proton, so the same profile
|
||||
directory lives inside the Wine prefix Steam keeps per app, in whichever library
|
||||
folder the game was installed into:
|
||||
|
||||
```text
|
||||
<library>/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents/
|
||||
My Games/ArmaReforger/addons/
|
||||
```
|
||||
|
||||
Everything below this point — the naming rule, the BOM, `ServerData.json`, the
|
||||
version comparison — is identical, because it is the same Windows game writing
|
||||
the same files; only the path to them differs.
|
||||
|
||||
The scanner probes every known Steam root (`~/.steam/steam`, `~/.steam/root`,
|
||||
`~/.local/share/Steam`, and the Flatpak
|
||||
`~/.var/app/com.valvesoftware.Steam/.local/share/Steam`) and reads each one's
|
||||
`steamapps/libraryfolders.vdf`, because the prefix lives in the library the game
|
||||
was installed into — routinely not the one Steam itself lives in. `steamuser` is
|
||||
Proton's fixed prefix account name, not the user's.
|
||||
|
||||
## Reliability of local detection
|
||||
|
||||
Measured against a real store of **238 directory entries**:
|
||||
|
||||
- 235 were addon directories; all 235 contained both `ServerData.json` and `meta`.
|
||||
- 3 were not addons (a `saves` directory and two stray preview JPEGs) and are
|
||||
excluded by the `<Name>_<16 hex>` naming rule.
|
||||
- Coverage of `ServerData.json`: **235 / 235**.
|
||||
|
||||
`ServerData.json` is the cheapest source — roughly 250 bytes — and contains
|
||||
exactly what is needed:
|
||||
|
||||
```json
|
||||
{"id":"62CCD69DD17E4F2F","name":"AKI_Core","revision":{"version":"8.1.1", ...}}
|
||||
```
|
||||
|
||||
Two details that matter in practice:
|
||||
|
||||
- Both `ServerData.json` and `meta` are written **UTF-8 with a byte-order mark**.
|
||||
`serde_json` rejects a leading BOM, so it must be stripped before parsing.
|
||||
- The mod id is also recoverable from the directory name. Addon names contain
|
||||
underscores, so the id is the *last* `_`-separated group, and it must be
|
||||
validated as 16 hex characters rather than simply taken.
|
||||
|
||||
## Version agreement with the API
|
||||
|
||||
`GET /v2/servers/{id}` returns each required mod with an exact `version` and,
|
||||
where resolved, a `size` in bytes. Comparing that to the local store:
|
||||
|
||||
| Server | Required | Up to date | Outdated | Missing | Missing download |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| `[OGR2] Old Guard Revival 2` | 122 | **122** | 0 | 0 | 0 |
|
||||
| `[NA2] TWS UHC Ukraine v Russia` | 173 | 26 | 1 | 146 | 14.42 GiB |
|
||||
| `LA FEDERACIÓN: ESTADO FALLIDO` | 91 | 15 | 0 | 76 | 15.59 GiB |
|
||||
| `[BnB1xCOC] Beers an Bipods` | 136 | 39 | 1 | 96 | 8.17 GiB |
|
||||
| `[EU1] SlavicWar` | 197 | 36 | 1 | 160 | 12.85 GiB |
|
||||
| `UNSC Battlegroup Athens` | 104 | 12 | 1 | 91 | 19.09 GiB |
|
||||
|
||||
The first row is a server the machine's owner plays: **122 of 122 mods matched
|
||||
on both id and exact version string**, with zero false negatives. Version strings
|
||||
are byte-identical between `ServerData.json` and the API — no normalisation was
|
||||
needed. The remaining rows confirm the missing and outdated paths produce
|
||||
sensible figures rather than degenerate ones.
|
||||
|
||||
One caveat on sizes: `modSummary.unresolvedCount` is often non-zero on
|
||||
`/v2/servers/{id}/mods` (that endpoint skips size enrichment unless asked), while
|
||||
the detail endpoint resolves them. Any download figure must be reported as a
|
||||
floor — "at least *N*, plus *k* unknown" — not a total.
|
||||
|
||||
## Triggering installation: not solved
|
||||
|
||||
This is where the feature stops.
|
||||
|
||||
- Reforger mods are **not** Steam Workshop items. `steam://` has no verb that can
|
||||
install them, and `steamapps/workshop` holds nothing for app 1874880.
|
||||
- No documented command-line switch or URI on the Reforger client was found that
|
||||
installs a mod set without entering the game.
|
||||
- The `meta` file is written *by* the game; treating it as an input — fabricating
|
||||
entries to make the client fetch content — is unsupported, undocumented, and
|
||||
would risk corrupting a user's addon store.
|
||||
|
||||
What does work is the flow the launcher already uses: `steam://run/1874880//<payload>/`
|
||||
starts the game with a join request, and **the game itself resolves the missing
|
||||
dependencies**, prompting for and downloading them before connecting. A user with
|
||||
none of a server's 173 mods can still press "Join server"; the game handles it.
|
||||
|
||||
That is why milestone 1 ships the direct join and no readiness panel.
|
||||
|
||||
## What is implemented
|
||||
|
||||
`crates/rfm-core/src/modscan.rs` — detection:
|
||||
|
||||
- `AddonInventory` — the trait, so readiness logic can be tested against a fixed
|
||||
inventory and a different platform layout can be added without touching callers.
|
||||
- `FilesystemInventory` — scans the addon store; `discover()` locates it.
|
||||
- `parse_server_data` / `addon_id_from_dir_name` — the two parsing rules above.
|
||||
- `readiness(required, installed)` — up to date / outdated / missing, plus
|
||||
`missing_bytes` and `unresolved_mods`.
|
||||
|
||||
`crates/rfm-core/src/readiness.rs` — reporting. Bridges the API mod list to the
|
||||
local inventory and produces a `ReadinessReport` for the UI. Two rules are
|
||||
enforced here rather than in the UI, so they cannot be lost in a redesign:
|
||||
|
||||
- A scan that did not run reports `available: false` with every count zeroed.
|
||||
`is_ready()` is false in that state: **not knowing is not being ready.**
|
||||
- A mod whose size the API could not resolve is counted in `unresolved_mods`
|
||||
rather than contributing zero bytes, so `missing_bytes` is always a floor that
|
||||
the UI can honestly label "at least".
|
||||
|
||||
`src/lib/format.ts` — `readinessSummary()` builds the sentence, and is unit
|
||||
tested against the exact expected string. `src/components/ReadinessNote.tsx`
|
||||
renders it as a line of text and a count strip. There is no button.
|
||||
|
||||
## The copy
|
||||
|
||||
```text
|
||||
96 of 136 mods missing · at least 8.17 GiB · Arma Reforger will download these when you join
|
||||
1 of 136 mods out of date · Arma Reforger will update these when you join
|
||||
All 136 mods installed and up to date.
|
||||
No local Arma Reforger addon folder found, so installed mods could not be checked.
|
||||
```
|
||||
|
||||
"at least" appears whenever any required mod's size is unresolved. The closing
|
||||
clause names the actor — the game, not the launcher — because that is what
|
||||
actually performs the download.
|
||||
|
||||
## Verified end to end
|
||||
|
||||
`cargo test -p rfm-core --test live_api -- --ignored readiness_against_a_real`
|
||||
runs the real scanner against a real addon store (`RFM_ADDONS_DIR`) and the live
|
||||
API. Against the 235-addon store described above:
|
||||
|
||||
```text
|
||||
local store: 235 addons
|
||||
[AU] FTA #1 | From The Ashes req=123 ok= 59 old= 6 miss= 58 >= 1.65 GiB
|
||||
[AU] FTA #2 | From The Ashes req=127 ok= 61 old= 6 miss= 60 >= 2.84 GiB
|
||||
[NA6] W.C.S. Realism NATO/RUS Belleau Wood req= 83 ok= 64 old= 4 miss= 15 >= 1.30 GiB
|
||||
[OG9] Old Guys 9 | Fallujah Iraq req=114 ok= 85 old=29 miss= 0 >= 0.00 GiB
|
||||
[EU] LuckyGames Hardcore | Bakhmut req=106 ok= 35 old= 9 miss= 62 >= 5.10 GiB
|
||||
[EU2] W.C.S. Realism NATO/RUS Serhiivka req= 80 ok= 62 old= 4 miss= 14 >= 0.86 GiB
|
||||
```
|
||||
|
||||
The test asserts the three buckets partition the required list exactly. The OG9
|
||||
row — 0 missing but 29 outdated — is the case that exercises the "update these"
|
||||
wording rather than "download these".
|
||||
|
||||
## Still not solved
|
||||
|
||||
Triggering installation. Nothing above changes that, and the "Prepare mods &
|
||||
join" action stays out until a supported mechanism exists.
|
||||
Reference in new issue
Block a user