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:
SowinskiBraedenandClaude Opus 5 committed 2026-09-24 20:29:00 -07:00
commit 377f3a31b3
91 files changed
+24994

No files matched your search

+240
View File
@@ -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
```
+248
View File
@@ -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.
+84
View File
@@ -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.
+342
View File
@@ -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.
+200
View File
@@ -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.