Files
SowinskiBraedenandClaude Opus 5 377f3a31b3 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>
2026-09-24 20:29:00 -07:00

379 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# reforgermods.net launcher
A fast, lightweight desktop companion for **Arma Reforger**, built on the
[reforgermods.net](https://reforgermods.net) API. It is a live server browser
that can put you straight into a server through Steam, without going through the
in-game lobby.
Unofficial. Not affiliated with or endorsed by Bohemia Interactive.
## Status
**0.1.0 — public beta.** The launcher browses and filters the live server list,
shows full detail for a selected server, joins directly through Steam, and keeps
favorites, recents and settings across restarts. Milestone 2 added population
history charts, a scenario filter, and mod readiness as reporting only; 0.1.0
adds packaging, signed auto-updates and a download page.
Beta artifacts are a per-user NSIS installer, a portable `.exe` and an Arch
package, published to `dl.reforgermods.net` and offered from
[reforgermods.net/launcher/](https://reforgermods.net/launcher/). They are **not
Authenticode-signed yet**, so Windows shows a SmartScreen warning on first run —
the pipeline is wired for Azure Trusted Signing and waiting on the identity.
See [`docs/distribution.md`](docs/distribution.md).
What works today:
- Live server browser over `GET /v2/servers`, with search, sort, region,
platform, official / modded / BattlEye / password / offline filters, and
page-local scenario and minimum-player filters
- **Sortable columns.** Clicking a heading sorts by it. `players` and `name` are
ordered by the API across every matching server; queue, ping, mods, region and
any reversed direction are orderings the API does not offer, so they reorder
the loaded page — marked with a hollow arrow and named in the footer, never
presented as a search of the whole fleet
- Selected-server inspector: scenario, population, queue, region, server FPS,
and the full required-mod list with versions and sizes
- **Population history chart** over 6h / 24h / 7d / 30d, drawn as inline SVG with
no charting library
- **Mod readiness, reporting only**: how many required mods are installed,
outdated or missing, and at least how much is left to download
- **Measured ping** to the selected server by ICMP — a real round trip, not a
region estimate
- Scenario imagery from the Bohemia CDN, host-allowlisted and CSP-enforced
- **Region latency in the browser**, sortable — eight ICMP probes cover a whole page
- Per-platform marks (PC / Xbox / PlayStation) matching the site, not a crossplay flag
- Fleet-wide player total in the title bar
- **Join Discord** where a server advertises an invite in its name
- **Discord Rich Presence**, showing the launcher as the running application
(needs a Discord application; see [`docs/discord-presence.md`](docs/discord-presence.md))
- **Direct join**: `steam://run/1874880//<base64 payload>/`
- Favorites, recent servers and launch history, persisted locally
- Settings, and an anonymous, user-resettable install identifier
- **Background updates on Windows**, signature-verified, downloaded quietly and
applied as the app closes — never mid-session. Linux installs are the distro's
to update
- Short-lived response caching, and loading / empty / error / offline states
- Compact API status in the header, with dataset freshness
Not implemented, on purpose:
- **"Prepare mods & join".** Readiness is *reported*, never acted on. There is no
verified way to install Reforger Workshop content from outside the game, so the
launcher says what the game will do instead of offering a button it cannot
honour. See [`docs/mod-readiness.md`](docs/mod-readiness.md).
- Accounts, uptime figures, mod browsing, background tray, auto-start.
## Stack
| Layer | Choice | Why |
| --- | --- | --- |
| Shell | Tauri 2 | Native WebView2 on Windows; no bundled browser runtime |
| Native | Rust | Steam launching, HTTP, persistence, local filesystem inspection |
| UI | Preact + `@preact/signals` + TypeScript | ~4 KB runtime, JSX in plain `.tsx`, and per-component signal subscriptions so the one-second freshness clock re-renders the status pill and not the 500-row table |
| Build | Vite 7 | |
The production bundle is **51 KB of JavaScript (17 KB gzipped)** and **14 KB of
CSS**, in one chunk. The history chart is hand-rolled inline SVG — a charting
library would have cost more than the rest of the UI combined.
### Layout
```text
crates/rfm-core/ Pure Rust. No Tauri dependency, so it is unit-testable directly.
api/ HTTP client, typed models, query builder, error taxonomy
launch.rs Server id validation -> join payload -> Steam URI
store.rs Settings, favorites, recents, launch history
cache.rs Bounded TTL cache of response bodies
modscan.rs Local Workshop addon detection
readiness.rs Required mods vs installed; reporting only, never acts
ping.rs ICMP latency; address validation is the security boundary
presence.rs Discord Rich Presence text, within Discord's limits
src-tauri/ The application shell. Window, commands, wiring. Thin.
src/commands.rs The entire IPC surface
src/state.rs Shared state
src/update.rs Background updates, Windows only; applied as the app exits
capabilities/main.json
tauri.signing.conf.json Adds only signCommand; merged over the config in CI
src/ UI
lib/ types, IPC wrappers, signals, pure formatters, chart geometry
components/ title bar, status pill, sidebar, filter bar, server table,
inspector, history chart, readiness note, platform badges
views/ browse, favorites, recent, settings
docs/api-contract.md Endpoints used, and what is deliberately not used
docs/mod-readiness.md The mod-readiness investigation and its findings
docs/client-identification.md Install-identity spec and API-side implementation notes
docs/discord-presence.md Discord Rich Presence setup and behaviour
docs/distribution.md Installer, code signing, release procedure, R2 layout
packaging/arch/ PKGBUILD and desktop entry for the Arch package
packaging/windows/sign.cmd Authenticode signing, called by Tauri during bundling
scripts/version.sh The version, in one place: show / check / set
scripts/release-manifest.py releases.json (the site) and latest.json (the updater)
scripts/build-windows.sh Cross-compile the bare .exe from Linux
scripts/build-arch.sh Native Arch package, built in a container
.github/workflows/release.yml Tag -> build, sign, publish to R2
```
## Prerequisites
- **Rust** stable (1.82+) and **Node.js** 20+
- **Windows** (the primary target): the WebView2 runtime, which ships with
Windows 11 and current Windows 10, plus the MSVC build tools
- **Linux** (development, and Arch packaging): the Tauri system dependencies —
`libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libayatana-appindicator3-dev`,
`librsvg2-dev`, `libsoup-3.0-dev`, `build-essential`
The TLS backend is chosen per target: schannel on Windows (no bundled C crypto
library, and the machine's own trust store), rustls elsewhere.
Installed-mod detection finds Reforger's addon store on both platforms: the
(often redirected) `Documents` directory on Windows, read from the shell folder
registry, and the Proton prefix inside each Steam library on Linux — there is no
native Linux client, so the game's `Documents` is
`<library>/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents`.
`RFM_ADDONS_DIR` overrides the search for an install neither layout describes.
## Development
```sh
npm install
npm run tauri dev # run the app
```
Frontend alone, in a browser (IPC calls will fail, but layout iterates fast):
```sh
npm run dev
```
## Checks
```sh
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test -p rfm-core # 168 unit tests, no network
npm run check # tsc --noEmit
npm test # 82 tests, including a jsdom shell smoke test
scripts/version.sh check # the four version copies agree
```
Contract checks against the live API, ignored by default:
```sh
# Serially: the public rate limit is 60 requests/minute, and these tests
# fetch server detail in loops.
cargo test -p rfm-core --test live_api -- --ignored --test-threads=1
```
To exercise the mod scanner against a real Arma Reforger addon store:
```sh
RFM_ADDONS_DIR="$HOME/Documents/My Games/ArmaReforger/addons" \
cargo test -p rfm-core --test live_api -- --ignored readiness_against_a_real
```
The ping tests send ICMP echo requests to live game servers; they are part of the
same ignored-by-default suite.
## Build
```sh
npm run tauri build
```
Produces a per-user NSIS installer under `target/release/bundle/nsis/`, plus a
detached `.sig` when an updater signing key is present in the environment. MSI is
deliberately not built: it needs admin rights and doubles the release for an
audience a game utility does not have.
Releases are cut from a `v*` tag by `.github/workflows/release.yml`, which builds
on Windows, signs, and publishes to `dl.reforgermods.net`. The version lives in
one place and is propagated by `scripts/version.sh`:
```sh
scripts/version.sh show # the canonical version
scripts/version.sh set 0.1.1 # rewrite all four copies
```
The full procedure, the signing state and the R2 layout are in
[`docs/distribution.md`](docs/distribution.md).
### Cross-building a Windows binary from Linux
No Visual Studio, and no root:
```sh
scripts/build-windows.sh # debug, shows a console with diagnostics
scripts/build-windows.sh --release # optimised, no console
```
`cargo-xwin` fetches the MSVC CRT and Windows SDK from Microsoft into
`~/.cache/cargo-xwin`, and LLVM's `clang-cl`/`lld-link` replace `cl.exe` and
`link.exe`; the script unpacks those from Debian packages into a local prefix.
The result is a standalone `.exe` — the frontend is embedded at compile time —
that needs only the WebView2 runtime, which ships with current Windows.
### Building an Arch Linux package
```sh
scripts/build-arch.sh # target/arch/*.pkg.tar.zst
scripts/build-arch.sh --no-check # skip the rfm-core test suite
```
The build runs inside an `archlinux:base-devel` container, so the binary links
Arch's own glibc and WebKit rather than the build machine's. This is not
fussiness: a Tauri binary built on Debian links `libwebkit2gtk-4.1` against
Debian's ABI, and handing that to an Arch user produces a blank window or a
missing-symbol crash. Docker is only being used as a clean Arch userspace —
nothing is deployed.
The tester installs it with `sudo pacman -U <file>`; pacman pulls
`webkit2gtk-4.1` and the rest from the declared dependencies.
Two things the PKGBUILD has to work around, both of which fail late and
confusingly if forgotten:
- `options=('!lto')` — makepkg puts `-flto=auto` in `CFLAGS`/`LDFLAGS` by
default, which makes `aws-lc-sys` (the C crypto behind rustls) compile to GCC
LTO bitcode that the linker cannot resolve. The build dies at the final link
with a page of undefined `aws_lc_*` symbols. Rust-level LTO is unaffected; it
comes from `[profile.release]`.
- `cmake` in `makedepends` — `aws-lc-sys` needs it, and `base-devel` does not
include it.
`build()` asserts the result is a production build before packaging, for the
same reason `build-windows.sh` does: a dev build installs perfectly and then
shows `ERR_CONNECTION_REFUSED` on the tester's machine.
### Nvidia under Wayland
WebKitGTK's DMABUF renderer and the proprietary Nvidia driver disagree about
buffer import, and GTK aborts before the window is ever mapped:
```text
Gdk-Message: Error 71 (Protocol error) dispatching to Wayland display.
```
`main.rs` detects that combination at startup and sets
`WEBKIT_DISABLE_DMABUF_RENDERER=1` for itself. The detection is deliberately
narrow — Nvidia loaded, a Wayland session, GDK not already pinned to X11, and no
existing value to override — because the fallback costs GPU acceleration for
anyone who does not need it. `should_disable_dmabuf` is a pure function with
tests covering each condition.
To override, set the variable yourself; any value is honoured, including `0`.
## Direct joining
Given a server UUID, the launcher builds:
```text
{"id":"<uuid>","inviteToken":""}
-> UTF-8 bytes
-> standard base64
-> trailing "=" removed
-> steam://run/1874880//<payload>/
```
### The doubled slash
`steam://run/<appid>//<args>/` is Steam's form for passing launch arguments, and
the **empty path segment** is what marks the payload as one. With a single
slash Steam can start the game without forwarding the payload, so the player
lands on the main menu and the join request is silently lost. A test pins this
shape, because the difference is one character and the failure looks like a
successful launch.
**Not yet confirmed against a live client.** The single-slash form was observed
to launch without joining; the doubled slash is the current best understanding
of the fix.
A join only carries the payload when the game actually starts. If Arma Reforger
is already running, Steam may focus the existing process and drop the arguments,
so this must be tested from a fully closed game.
`steam://run/1874880` (no payload) is the plain "Launch Arma Reforger" action and
is unaffected.
### An alternative route
The game also registers its own URL protocol. Its Steam install script
(`steamapps/common/Arma Reforger/installscript.vdf`) writes:
```text
HKCU\SOFTWARE\Classes\ArmaReforger\shell\open\command
"<dir>\ArmaReforger_BE.exe" -exe ArmaReforgerSteam.exe
"-addonsDir=<dir>\addons" -uri="%1"
```
`reforger_join_uri()` builds `ArmaReforger://<payload>`, which reaches the
launcher as `-uri=` without depending on how Steam forwards arguments. It is
implemented, tested and unused — a second candidate if the Steam form does not
join.
This lives in [`crates/rfm-core/src/launch.rs`](crates/rfm-core/src/launch.rs)
and is pure and directly tested, including against the verified reference
vector. No shell is involved at any point: the URI is handed to the platform's
URI opener, and the only value interpolated into it is a `ServerId`, which cannot
be constructed without passing UUID validation.
## Ping
A browser cannot measure round-trip latency to a game server; a native launcher
can, and this is one of the reasons for building one.
**TCP connect timing was tried and rejected.** Connecting to a server's UDP game
port "succeeded" in 12–18 ms — and so did port 9 and port 47123, which are
closed. Some middlebox completes every handshake, so TCP timing yields
confident-looking numbers that are entirely fictional.
ICMP echo works. Measured against 12 live servers, 11 answered, with latencies
coherent by region (Los Angeles 41 ms, New York 58–83 ms, Frankfurt 154–184 ms,
Sydney 173–179 ms). The launcher sends three probes and displays the **median**,
so one delayed packet does not move the figure.
- Windows uses `IcmpSendEcho` via the IP Helper API — **no elevation required**.
- Unix uses an unprivileged ICMP datagram socket.
- The **selected** server is measured exactly, in the inspector.
- The **browser column** shows latency to each server's *region*. `/v2/servers`
publishes no per-server address — only `/v2/servers/{id}` does — so pinging a
100-row page would cost 100 detail requests against a 60/minute limit. Every
row does carry `pingSiteId`, and there are only eight sites, so eight probes
cover the list. Region and server latency agree within roughly 20 ms, which is
enough to sort by and is what the game's own browser shows.
- Roughly a third of servers filter ICMP. Those show "no reply" rather than a
region-level estimate dressed up as a measurement.
## Security posture
- Everything from the API — server names, scenario names, mod names, URLs — is
treated as untrusted. It is rendered as text content and never interpolated
into a shell command, a URI, or markup.
- Server ids are validated before they reach a URL, on both the launch path and
the "open on reforgermods.net" path. The latter builds its URL from the
validated id rather than from the API's own `modURL` field.
- No API key or credential is embedded. The launcher is a public client and uses
anonymous access only. The one value it sends that persists across requests is
a random install identifier, visible and resettable in Settings; see
[`docs/client-identification.md`](docs/client-identification.md).
- The API origin is not user-configurable, so a tampered settings file cannot
redirect requests — or the install identifier — to another host.
- Server addresses from the API are validated to public literal IPs before any
packet is sent, so remote data cannot drive DNS or probe the user's LAN.
- Image URLs are checked against a single-host allowlist before rendering, with
the page CSP enforcing the same list independently.
- The UI is granted `core:default` and nothing else — no filesystem, shell, HTTP
or opener permission. URIs are opened from Rust, after validation.
- A strict CSP allows `'self'` plus scenario thumbnails from the Bohemia CDN.
## Relationship to reforgermods.net
This is a client. All server and mod data comes from the public
`https://api.reforgermods.net` v2 API; the launcher holds no data of its own
beyond local preferences and caches, and duplicates no backend logic. See
[`docs/api-contract.md`](docs/api-contract.md).