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

+266
View File
@@ -0,0 +1,266 @@
# Build, sign and publish a launcher release.
#
# Triggered by a version tag. The tag is the instruction: the job refuses to
# build unless the tag and every version in the tree already agree, because an
# updater manifest that disagrees with the installed version either loops
# forever or never fires (docs/distribution.md section 6).
#
# The Windows build runs here rather than cross-compiled from Linux, even though
# scripts/build-windows.sh works: NSIS bundling and Authenticode signing both
# want Windows tooling, and the signing credential should live in one place that
# is not a laptop.
#
# Stored under .github/workflows so both GitHub Actions and Gitea Actions pick it
# up, matching the other two repositories.
name: Release
on:
push:
tags: ['v*']
# Reruns of a published tag, for when a publish step failed and the artifacts
# are fine. Builds only; nothing is published without a tag.
workflow_dispatch:
inputs:
version:
description: 'Version to build, without the leading v (e.g. 0.1.0)'
required: true
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
windows:
name: Windows installer
runs-on: windows-latest
# A protected environment: put the signing and R2 credentials on it, and add
# required reviewers so publishing a release needs a human.
environment: release
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: '. -> target'
# The tag is the source of truth for what is being released; the tree has
# to already say the same thing.
- name: Check the version agrees with the tag
id: version
shell: bash
run: |
tag="${{ inputs.version || github.ref_name }}"
scripts/version.sh check "$tag"
echo "version=$(scripts/version.sh show)" >> "$GITHUB_OUTPUT"
- run: npm ci
# Re-validated here rather than trusting that CI ran on this commit: a
# release is the one build that must not ship a known-broken tree.
- name: Checks
run: |
npm run check
npm test
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test -p rfm-core
# Signing is optional so the pipeline works before Azure Trusted Signing is
# provisioned. When the secrets are absent the build is simply unsigned, and
# the summary says so — rather than the whole release failing, or worse,
# silently producing an unsigned installer that the notes call signed.
- name: Decide whether artifacts can be signed
id: signing
shell: bash
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
RFM_SIGN_ENDPOINT: ${{ vars.RFM_SIGN_ENDPOINT }}
run: |
if [ -n "$AZURE_CLIENT_ID" ] && [ -n "$RFM_SIGN_ENDPOINT" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::warning::Azure Trusted Signing is not configured; this release will be UNSIGNED and will show the SmartScreen warning."
fi
- name: Install the signing tool
if: steps.signing.outputs.enabled == 'true'
run: cargo install trusted-signing-cli --locked
# TAURI_SIGNING_PRIVATE_KEY is the updater key, which is a different thing
# from Authenticode code signing: it proves an update came from this project,
# while Authenticode is what stops SmartScreen calling the download unknown.
# Both are needed and they solve different problems.
- name: Build
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
RFM_SIGN_ENDPOINT: ${{ vars.RFM_SIGN_ENDPOINT }}
RFM_SIGN_ACCOUNT: ${{ vars.RFM_SIGN_ACCOUNT }}
RFM_SIGN_PROFILE: ${{ vars.RFM_SIGN_PROFILE }}
shell: bash
run: |
if [ "${{ steps.signing.outputs.enabled }}" = "true" ]; then
# The overlay adds only bundle.windows.signCommand, so Tauri signs each
# artifact during bundling and the updater zip contains a signed
# installer.
npm run tauri -- build --config src-tauri/tauri.signing.conf.json
else
npm run tauri -- build
fi
# The portable exe is the same binary the installer wraps, offered for people
# who will not run installers. It costs nothing to publish.
- name: Collect artifacts
shell: bash
run: |
set -euo pipefail
version="${{ steps.version.outputs.version }}"
mkdir -p dist/release
nsis="target/release/bundle/nsis"
cp "$nsis"/*-setup.exe "dist/release/reforgermods-launcher-${version}-setup.exe"
cp "target/release/reforgermods-launcher.exe" "dist/release/reforgermods-launcher-${version}.exe"
# The NSIS bundle is signed in place, so the updater artifact is the
# installer itself plus a detached .sig. It exists only when the signing
# key was available; an unsigned release simply has no updater entry.
for f in "$nsis"/*-setup.exe.sig; do
[ -e "$f" ] || continue
cp "$f" "dist/release/reforgermods-launcher-${version}-setup.exe.sig"
done
ls -l dist/release
# Uploaded whether or not publishing happens, so a failed publish never
# means rebuilding.
- uses: actions/upload-artifact@v4
with:
name: windows-${{ steps.version.outputs.version }}
path: dist/release/
if-no-files-found: error
retention-days: 14
# The updater key is read through env rather than interpolated into the
# script: `${{ secrets.X }}` in a run block writes the secret into the shell
# command itself, and only the log is masked.
- name: Summary
shell: bash
env:
HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }}
SIGNED: ${{ steps.signing.outputs.enabled }}
VERSION: ${{ steps.version.outputs.version }}
run: |
{
echo "### Launcher $VERSION"
if [ "$SIGNED" = "true" ]; then
echo "- Authenticode: **signed**"
else
echo "- Authenticode: **UNSIGNED** — expect the SmartScreen warning"
fi
if [ "$HAS_UPDATER_KEY" = "true" ]; then
echo "- Updater: artifacts signed, \`latest.json\` will be published"
else
echo "- Updater: **no key configured**, no updater artifacts produced"
fi
} >> "$GITHUB_STEP_SUMMARY"
publish:
name: Publish to R2
needs: windows
runs-on: ubuntu-latest
# Only a tag publishes. A manual run builds and stops, so it can be used to
# check a release without touching what is live.
if: startsWith(github.ref, 'refs/tags/v')
environment: release
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: windows-${{ needs.windows.outputs.version }}
path: dist/release
# The Arch package is not built here: it needs an Arch userspace (see
# scripts/build-arch.sh, which runs one in a container) and is handed over
# per tester rather than published. Drop it into dist/release before the
# manifest step if that changes.
- name: Build the manifests
run: |
python3 scripts/release-manifest.py \
--version "${{ needs.windows.outputs.version }}" \
--artifacts dist/release \
--base-url "https://dl.reforgermods.net" \
--out dist/release
- name: Checksums
working-directory: dist/release
run: |
sha256sum -- * > SHA256SUMS || true
# The manifests are generated from the artifacts, so they must not be
# listed as if they were part of the signed set.
grep -v -E '(releases|latest)\.json|SHA256SUMS' SHA256SUMS > sums.tmp
mv sums.tmp SHA256SUMS
cat SHA256SUMS
# dl.reforgermods.net is an R2 bucket with a custom domain. Versioned paths
# are immutable; only /updates/latest.json and /releases.json move, which is
# what makes a rollback a matter of re-publishing one small file.
- name: Publish
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
BUCKET: reforgermods-launcher-downloads
run: |
set -euo pipefail
version="${{ needs.windows.outputs.version }}"
put() {
npx --yes wrangler@4 r2 object put "$BUCKET/$1" --file "$2" --content-type "$3" --remote
}
for file in dist/release/*; do
name="$(basename "$file")"
case "$name" in
releases.json|latest.json) continue ;;
*.exe) type="application/vnd.microsoft.portable-executable" ;;
*.zip) type="application/zip" ;;
*.zst) type="application/zstd" ;;
*) type="text/plain; charset=utf-8" ;;
esac
put "$version/$name" "$file" "$type"
done
# Last, and in this order: the updater manifest only starts pointing at a
# release once every file it names is already in place.
put "updates/latest.json" dist/release/latest.json "application/json"
put "releases.json" dist/release/releases.json "application/json"
- name: Next step
run: |
{
echo "### Published ${{ needs.windows.outputs.version }}"
echo "Artifacts are live under \`https://dl.reforgermods.net/${{ needs.windows.outputs.version }}/\`."
echo ""
echo "The website's download panel reads its own copy of the manifest."
echo "Copy \`releases.json\` from this run's artifacts into"
echo "\`public/static/launcher/releases.json\` in reforgermods-web and deploy,"
echo "or the page will keep offering the previous release."
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
with:
name: manifests-${{ needs.windows.outputs.version }}
path: |
dist/release/releases.json
dist/release/latest.json
dist/release/SHA256SUMS
+11
View File
@@ -0,0 +1,11 @@
/node_modules
/dist
/target
/src-tauri/target
*.local
.DS_Store
.env
.env.local
# Generated by tauri-build at compile time.
/src-tauri/gen/schemas
Generated
+6138
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
[workspace]
members = ["crates/rfm-core", "src-tauri"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.82"
license = "Proprietary"
publish = false
[workspace.dependencies]
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
thiserror = "2.0.21"
# TLS backend is selected per target in crates/rfm-core/Cargo.toml.
reqwest = { version = "0.13.5", default-features = false, features = ["json", "query", "gzip", "charset", "http2"] }
tokio = { version = "1.53.1", default-features = false, features = ["time", "sync", "rt", "net"] }
base64 = "0.23.1"
uuid = { version = "1.26.1", default-features = false, features = ["v4", "std"] }
sha2 = "0.11.0"
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
panic = "abort"
strip = true
+378
View File
@@ -0,0 +1,378 @@
# 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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+44
View File
@@ -0,0 +1,44 @@
[package]
name = "rfm-core"
description = "Core logic for the reforgermods.net launcher: API client, Steam launch URIs, local state."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
reqwest.workspace = true
tokio.workspace = true
base64.workspace = true
uuid = { workspace = true }
sha2 = { workspace = true }
# TLS backend, chosen per target.
#
# Windows gets schannel through native-tls: no bundled C crypto library to
# build (reqwest's rustls path pulls aws-lc-sys, which needs cmake and nasm on
# Windows), a smaller binary, and the trust store the machine already manages —
# which matters behind corporate TLS inspection.
#
# Everywhere else uses reqwest's default rustls stack, so a Linux or macOS
# checkout builds without OpenSSL development headers.
[target.'cfg(windows)'.dependencies]
reqwest = { workspace = true, features = ["native-tls"] }
# Reads HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid for install identity.
winreg = "0.56.0"
# IcmpSendEcho through the IP Helper API: no elevation required.
winping = "0.10.1"
[target.'cfg(not(windows))'.dependencies]
reqwest = { workspace = true, features = ["default-tls"] }
[target.'cfg(unix)'.dependencies]
# Unprivileged ICMP datagram sockets.
surge-ping = "0.9.1"
[dev-dependencies]
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "time"] }
+576
View File
@@ -0,0 +1,576 @@
//! HTTP client for the reforgermods.net public API.
//!
//! All network access in the launcher goes through this type. Tauri commands
//! call it; nothing else constructs requests.
//!
//! The launcher is a public, untrusted client. It sends no credentials and no
//! API key — only a `User-Agent` identifying the build. Do not add privileged
//! headers here: the binary ships to users and anything embedded in it is
//! readable.
use std::sync::Arc;
use std::time::Duration;
use crate::api::error::{ApiError, ApiErrorBody};
use crate::api::models::{
DatasetMeta, FleetHistory, LauncherStatus, PingSitesResponse, ServerDetailResponse,
ServerHistory, ServersPage, StatusReport,
};
use crate::api::query::{HistoryRange, ServerQuery};
use crate::cache::{ResponseCache, Ttl};
use crate::launch::ServerId;
/// Production API base URL. The launcher is powered by this API and ships
/// pointed at it.
pub const DEFAULT_BASE_URL: &str = "https://api.reforgermods.net";
/// Development-only override for the API origin.
///
/// Deliberately an environment variable rather than a setting: a value in the
/// settings file could be edited to redirect every request — including the
/// install identifier — at an arbitrary host, and nothing in the product needs
/// end users to change it.
pub const BASE_URL_ENV: &str = "RFM_API_BASE_URL";
/// Header carrying the anonymous installation identifier.
///
/// Lets the API count distinct installs rather than only requests. See
/// `docs/client-identification.md`.
pub const INSTALL_ID_HEADER: &str = "X-Launcher-Install-Id";
/// Per-request deadline, including connection setup and body read.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
/// Connection-only deadline, so an unreachable host fails fast rather than
/// burning the whole request budget.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Attempts beyond the first. Bounded on purpose: there is no retry loop here
/// that can run indefinitely.
const MAX_RETRIES: u32 = 2;
/// Base delay for exponential backoff between retries.
const RETRY_BACKOFF: Duration = Duration::from_millis(250);
/// A client for the reforgermods.net v2 API.
///
/// Cheap to clone: the inner `reqwest::Client` and cache are shared.
#[derive(Clone)]
pub struct ApiClient {
http: reqwest::Client,
base_url: Arc<str>,
cache: Arc<ResponseCache>,
}
/// The API origin this build talks to: production, unless `RFM_API_BASE_URL`
/// overrides it for development.
pub fn configured_base_url() -> String {
std::env::var(BASE_URL_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
}
impl ApiClient {
/// Builds a client for the configured origin, identifying as `install_id`.
///
/// `install_id` is the anonymous per-install identifier from
/// [`crate::store::Store::install_id`]. An empty or malformed value is
/// simply not sent, so a corrupted state file degrades to an unidentified
/// client rather than sending garbage.
pub fn new(install_id: &str) -> Result<Self, ApiError> {
Self::with_base_url(&configured_base_url(), install_id)
}
/// Builds a client against an explicit origin. Tests and the dev override
/// use this; product code calls [`ApiClient::new`].
pub fn with_base_url(base_url: &str, install_id: &str) -> Result<Self, ApiError> {
let base_url = normalise_base_url(base_url)?;
let mut headers = reqwest::header::HeaderMap::new();
if let Some(value) = install_id_header_value(install_id) {
headers.insert(INSTALL_ID_HEADER, value);
}
let builder = reqwest::Client::builder().default_headers(headers);
// On Windows the TLS backend is schannel, which uses the machine's own
// trust store. See the target-specific reqwest features in Cargo.toml.
#[cfg(windows)]
let builder = builder.use_native_tls();
let http = builder
.user_agent(crate::user_agent())
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
// The launcher makes a handful of requests to one host; a small pool
// kept alive is what keeps idle resource use low.
.pool_max_idle_per_host(4)
.build()
.map_err(ApiError::Build)?;
Ok(Self {
http,
base_url: base_url.into(),
cache: Arc::new(ResponseCache::default()),
})
}
/// The base URL this client targets.
pub fn base_url(&self) -> &str {
&self.base_url
}
/// Drops every cached response. Called when settings change the base URL.
pub fn clear_cache(&self) {
self.cache.clear();
}
/// `GET /v2/servers` with the given filters applied.
///
/// Dropping the returned future cancels the request; Tauri does this when a
/// command is aborted, which is how a superseded search stops in flight.
pub async fn servers(&self, query: &ServerQuery) -> Result<ServersPage, ApiError> {
let path = "/v2/servers";
let body = self
.get_text(path, &query.to_params(), Ttl::SNAPSHOT)
.await?;
decode(&body)
}
/// `GET /v2/servers/{id}`. The id is validated before it reaches the URL.
pub async fn server(&self, id: &ServerId) -> Result<ServerDetailResponse, ApiError> {
let path = format!("/v2/servers/{id}");
let body = self.get_text(&path, &[], Ttl::SNAPSHOT).await?;
decode(&body)
}
/// `GET /v2/servers/{id}/history` for one range.
///
/// Returns an empty `points` array for a server with no recorded history,
/// which is a normal state and not an error.
pub async fn server_history(
&self,
id: &ServerId,
range: HistoryRange,
) -> Result<ServerHistory, ApiError> {
let path = format!("/v2/servers/{id}/history");
let params = [("range", range.as_param().to_string())];
let body = self.get_text(&path, &params, Ttl::HISTORY).await?;
decode(&body)
}
/// Fleet-wide population, for the header's player total.
///
/// The endpoint also returns a full time series; the launcher reads only the
/// `now` block. The narrowest range is requested to keep the response small.
pub async fn fleet_now(&self) -> Result<FleetHistory, ApiError> {
let body = self
.get_text(
"/v2/servers/history",
&[("range", "6h".to_string())],
Ttl::HISTORY,
)
.await?;
decode(&body)
}
/// `GET /v2/servers/ping-sites`. Used to label regions; cached for an hour.
pub async fn ping_sites(&self) -> Result<PingSitesResponse, ApiError> {
let body = self
.get_text("/v2/servers/ping-sites", &[], Ttl::STABLE)
.await?;
decode(&body)
}
/// `GET /v2/status`, the public status contract.
///
/// Returns [`ApiError::EndpointUnavailable`] when the deployment does not
/// serve the route, which is the current state of api.reforgermods.net.
pub async fn status(&self) -> Result<StatusReport, ApiError> {
let path = "/v2/status";
// Deliberately uncached: a status reading must be current.
let body = self.request_text(path, &[]).await.map_err(|e| match e {
// A 404 on a fixed route means the route is not deployed, not that a
// resource is missing.
ApiError::NotFound { .. }
| ApiError::Api {
status: 404 | 501, ..
} => ApiError::EndpointUnavailable {
path: path.to_string(),
},
other => other,
})?;
decode(&body)
}
/// The compact status the launcher header renders.
///
/// This never fails: an unreachable or partially available API produces an
/// unknown/offline reading rather than an error the UI has to handle. It also
/// does not reimplement any backend health logic — it only reports what the
/// public contract returned.
///
/// `dataset_hint` is the [`DatasetMeta`] from the most recent server
/// response. Every server response carries snapshot freshness, so the
/// "server data updated Ns ago" line costs no extra request.
pub async fn launcher_status(&self, dataset_hint: Option<DatasetMeta>) -> LauncherStatus {
match self.status().await {
Ok(report) => LauncherStatus {
api_reachable: true,
dataset: report.dataset.or(dataset_hint),
reported_status: report.status,
note: None,
},
Err(ApiError::EndpointUnavailable { .. }) => {
// Fall back to the liveness probe, which is deployed.
let reachable = self.request_text("/v2/health", &[]).await.is_ok();
LauncherStatus {
api_reachable: reachable,
dataset: dataset_hint,
reported_status: None,
note: Some(
"This API deployment does not serve /v2/status; \
freshness is read from the server dataset."
.into(),
),
}
}
Err(err) => LauncherStatus {
api_reachable: false,
dataset: dataset_hint,
reported_status: None,
note: Some(err.to_string()),
},
}
}
/// Cache-aware GET returning the raw body.
async fn get_text(
&self,
path: &str,
params: &[(&'static str, String)],
ttl: Ttl,
) -> Result<String, ApiError> {
let key = cache_key(path, params);
if let Some(hit) = self.cache.get(&key, ttl) {
return Ok(hit);
}
let body = self.request_text(path, params).await?;
self.cache.put(&key, body.clone());
Ok(body)
}
/// GET with bounded retries. No caching, no deserialisation.
async fn request_text(
&self,
path: &str,
params: &[(&'static str, String)],
) -> Result<String, ApiError> {
let url = format!("{}{path}", self.base_url);
let mut attempt = 0;
loop {
let result = self.attempt(&url, params).await;
match result {
Ok(body) => return Ok(body),
Err(err) if attempt < MAX_RETRIES && err.is_retryable() => {
// Fixed ceiling on attempts; this cannot spin.
tokio::time::sleep(RETRY_BACKOFF * 2u32.pow(attempt)).await;
attempt += 1;
}
Err(err) => return Err(err),
}
}
}
/// One HTTP round trip, mapped onto [`ApiError`].
async fn attempt(
&self,
url: &str,
params: &[(&'static str, String)],
) -> Result<String, ApiError> {
let response = self.http.get(url).query(params).send().await.map_err(|e| {
if e.is_timeout() {
ApiError::Timeout
} else {
ApiError::Offline(e)
}
})?;
let status = response.status();
if status.is_success() {
return response.text().await.map_err(|e| {
if e.is_timeout() {
ApiError::Timeout
} else {
ApiError::Offline(e)
}
});
}
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<u64>().ok());
return Err(ApiError::RateLimited { retry_after });
}
// Read the documented error envelope when present; fall back to the
// status code when the body is something else (a proxy error page).
let body = response.text().await.unwrap_or_default();
let detail = serde_json::from_str::<ApiErrorBody>(&body)
.ok()
.map(|b| b.error);
let (code, message, request_id) = match detail {
Some(d) => (d.code, d.message, d.request_id),
None => (
String::new(),
format!("API returned HTTP {}", status.as_u16()),
String::new(),
),
};
if status == reqwest::StatusCode::NOT_FOUND {
return Err(ApiError::NotFound {
message: if message.is_empty() {
"Not found.".into()
} else {
message
},
});
}
Err(ApiError::Api {
status: status.as_u16(),
code,
message,
request_id,
})
}
}
/// Deserialises a response body into `T`.
fn decode<T: serde::de::DeserializeOwned>(body: &str) -> Result<T, ApiError> {
serde_json::from_str(body).map_err(ApiError::Decode)
}
/// Cache key for a request. Parameter order comes from
/// [`ServerQuery::to_params`], which is stable, so equal queries collide.
fn cache_key(path: &str, params: &[(&'static str, String)]) -> String {
let mut key = String::with_capacity(path.len() + params.len() * 16);
key.push_str(path);
for (i, (name, value)) in params.iter().enumerate() {
key.push(if i == 0 { '?' } else { '&' });
key.push_str(name);
key.push('=');
key.push_str(value);
}
key
}
/// The header value for `install_id`, or `None` when it is not a usable id.
///
/// Validated against the same UUID shape the store writes, which also
/// guarantees the value is header-safe ASCII.
fn install_id_header_value(install_id: &str) -> Option<reqwest::header::HeaderValue> {
let id = install_id.trim();
if crate::launch::ServerId::parse(id).is_err() {
return None;
}
reqwest::header::HeaderValue::from_str(id).ok()
}
/// Validates and normalises a base URL: absolute http(s), no trailing slash.
///
/// Rejecting non-HTTP schemes here stops a settings file from pointing the
/// client at, say, a `file://` path.
fn normalise_base_url(raw: &str) -> Result<String, ApiError> {
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
return Err(ApiError::InvalidBaseUrl("base URL is empty".into()));
}
let rest = trimmed
.strip_prefix("https://")
.or_else(|| trimmed.strip_prefix("http://"))
.ok_or_else(|| ApiError::InvalidBaseUrl(format!("{raw:?} is not an http(s) URL")))?;
// A host must be present and must not contain path or query separators.
if rest.is_empty() || rest.starts_with('/') {
return Err(ApiError::InvalidBaseUrl(format!("{raw:?} has no host")));
}
if rest.contains(['?', '#', ' ']) {
return Err(ApiError::InvalidBaseUrl(format!(
"{raw:?} must be a bare origin, without query or fragment"
)));
}
Ok(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::query::ServerSort;
#[test]
fn default_base_url_is_production() {
assert_eq!(DEFAULT_BASE_URL, "https://api.reforgermods.net");
}
const SAMPLE_INSTALL_ID: &str = "8f14e45f-ceea-467a-9c2b-1f2a3b4c5d6e";
#[test]
fn client_builds_against_the_production_base_url() {
let client = ApiClient::new(SAMPLE_INSTALL_ID).unwrap();
assert_eq!(client.base_url(), "https://api.reforgermods.net");
}
#[test]
fn install_id_becomes_a_header_value() {
let value = install_id_header_value(SAMPLE_INSTALL_ID).expect("valid id");
assert_eq!(value.to_str().unwrap(), SAMPLE_INSTALL_ID);
}
#[test]
fn a_malformed_install_id_is_not_sent_at_all() {
// A corrupted state file must degrade to an unidentified client rather
// than putting arbitrary text into a request header.
for bad in [
"",
" ",
"not-a-uuid",
"8f14e45f-ceea-467a-9c2b-1f2a3b4c5d6e
X-Injected: 1",
"../../etc/passwd",
] {
assert!(install_id_header_value(bad).is_none(), "{bad:?}");
}
}
#[test]
fn a_client_without_an_install_id_still_builds() {
let client = ApiClient::new("").expect("must not fail");
assert_eq!(client.base_url(), "https://api.reforgermods.net");
}
#[test]
fn the_base_url_is_not_user_configurable() {
// There is no setting for this; only the documented dev env override.
assert_eq!(BASE_URL_ENV, "RFM_API_BASE_URL");
assert_eq!(configured_base_url(), DEFAULT_BASE_URL);
}
#[test]
fn base_urls_are_normalised() {
for (input, expected) in [
(
"https://api.reforgermods.net/",
"https://api.reforgermods.net",
),
(
"https://api.reforgermods.net///",
"https://api.reforgermods.net",
),
(" http://127.0.0.1:8000 ", "http://127.0.0.1:8000"),
("http://localhost:8000/api", "http://localhost:8000/api"),
] {
assert_eq!(normalise_base_url(input).unwrap(), expected, "{input}");
}
}
#[test]
fn hostile_base_urls_are_rejected() {
for input in [
"",
" ",
"api.reforgermods.net",
"file:///etc/passwd",
"javascript:alert(1)",
"steam://run/1874880",
"https://",
"http:///nohost",
"https://host?x=1",
"https://host#frag",
"https://ho st",
] {
assert!(
normalise_base_url(input).is_err(),
"should be rejected: {input:?}"
);
}
}
#[test]
fn user_agent_names_the_launcher_and_version() {
let ua = crate::user_agent();
assert!(ua.starts_with("reforgermods.net-launcher/"), "{ua}");
assert_eq!(
ua,
format!("reforgermods.net-launcher/{}", env!("CARGO_PKG_VERSION"))
);
}
#[test]
fn cache_keys_include_every_parameter() {
let q = ServerQuery {
search: "everon".into(),
sort: ServerSort::Name,
..Default::default()
};
let key = cache_key("/v2/servers", &q.to_params());
assert_eq!(
key,
"/v2/servers?page=1&perPage=100&sort=name&search=everon"
);
}
#[test]
fn cache_keys_distinguish_different_queries() {
let a = ServerQuery::default();
let b = ServerQuery {
search: "x".into(),
..Default::default()
};
assert_ne!(
cache_key("/v2/servers", &a.to_params()),
cache_key("/v2/servers", &b.to_params())
);
}
#[test]
fn cache_keys_match_for_equal_queries() {
let a = ServerQuery {
official: Some(true),
min_players: 10,
..Default::default()
};
// min_players is client-side, so it must not split the cache.
let b = ServerQuery {
official: Some(true),
min_players: 50,
..Default::default()
};
assert_eq!(
cache_key("/v2/servers", &a.to_params()),
cache_key("/v2/servers", &b.to_params())
);
}
#[test]
fn cache_key_for_a_pathless_request_has_no_question_mark() {
assert_eq!(cache_key("/v2/health", &[]), "/v2/health");
}
#[test]
fn retry_budget_is_bounded() {
// Guards against a future edit turning the retry loop unbounded.
assert_eq!(MAX_RETRIES, 2);
let total: Duration = (0..MAX_RETRIES).map(|a| RETRY_BACKOFF * 2u32.pow(a)).sum();
assert!(total < Duration::from_secs(1), "backoff total {total:?}");
}
#[test]
fn server_detail_path_uses_a_validated_id() {
let id = ServerId::parse("73e8fb7f-8789-4d88-8aa8-ea69b9aa092f").unwrap();
assert_eq!(
format!("/v2/servers/{id}"),
"/v2/servers/73e8fb7f-8789-4d88-8aa8-ea69b9aa092f"
);
}
}
+151
View File
@@ -0,0 +1,151 @@
//! Error type for the reforgermods.net API client.
use serde::Deserialize;
/// The error envelope reforgermods.net returns for non-2xx responses.
#[derive(Debug, Clone, Deserialize)]
pub struct ApiErrorBody {
pub error: ApiErrorDetail,
}
/// The `error` object inside [`ApiErrorBody`].
#[derive(Debug, Clone, Deserialize)]
pub struct ApiErrorDetail {
#[serde(default)]
pub code: String,
#[serde(default)]
pub message: String,
#[serde(default, rename = "requestId")]
pub request_id: String,
}
/// Every way an API call can fail.
///
/// Variants are deliberately distinct so the UI can choose sensible copy:
/// `Offline` and `Timeout` are "try again", `RateLimited` is "wait", `NotFound`
/// is "this server is gone", and `Api` carries a server-authored message.
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
/// The request never reached the API (DNS, TLS, connection refused).
#[error("could not reach the reforgermods.net API")]
Offline(#[source] reqwest::Error),
/// The request exceeded its deadline.
#[error("request to the reforgermods.net API timed out")]
Timeout,
/// A 404 for a resource that may simply no longer be indexed.
#[error("{message}")]
NotFound { message: String },
/// The requested route does not exist on this deployment. Distinct from
/// [`ApiError::NotFound`] so callers can degrade a feature rather than
/// reporting a missing server.
#[error("endpoint {path} is not available on this API deployment")]
EndpointUnavailable { path: String },
/// 429. `retry_after` is the `Retry-After` value in seconds when supplied.
#[error("rate limited by the reforgermods.net API")]
RateLimited { retry_after: Option<u64> },
/// Any other non-2xx response carrying the documented error envelope.
#[error("{message} ({code})")]
Api {
status: u16,
code: String,
message: String,
request_id: String,
},
/// A 2xx response whose body did not match the expected model.
#[error("unexpected response shape from the reforgermods.net API")]
Decode(#[source] serde_json::Error),
/// The client could not be constructed (TLS backend, proxy config).
#[error("could not build the API client")]
Build(#[source] reqwest::Error),
/// A configured base URL was not a usable absolute HTTP(S) URL.
#[error("invalid API base URL: {0}")]
InvalidBaseUrl(String),
}
impl ApiError {
/// True when retrying the same request could plausibly succeed.
pub fn is_retryable(&self) -> bool {
match self {
ApiError::Offline(_) | ApiError::Timeout => true,
// 5xx is worth one more attempt; 4xx is not.
ApiError::Api { status, .. } => *status >= 500,
_ => false,
}
}
/// A stable machine-readable kind, for the UI to branch on without
/// matching on human-readable text.
pub fn kind(&self) -> &'static str {
match self {
ApiError::Offline(_) => "offline",
ApiError::Timeout => "timeout",
ApiError::NotFound { .. } => "not_found",
ApiError::EndpointUnavailable { .. } => "endpoint_unavailable",
ApiError::RateLimited { .. } => "rate_limited",
ApiError::Api { .. } => "api",
ApiError::Decode(_) => "decode",
ApiError::Build(_) => "build",
ApiError::InvalidBaseUrl(_) => "config",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_envelope_parses() {
let raw = r#"{"error":{"code":"SERVER_NOT_FOUND","message":"Server was not found in the current dataset.","requestId":"91288cfd10bc327410219cb9"}}"#;
let body: ApiErrorBody = serde_json::from_str(raw).unwrap();
assert_eq!(body.error.code, "SERVER_NOT_FOUND");
assert_eq!(body.error.request_id, "91288cfd10bc327410219cb9");
}
#[test]
fn error_envelope_tolerates_missing_fields() {
let body: ApiErrorBody = serde_json::from_str(r#"{"error":{}}"#).unwrap();
assert_eq!(body.error.code, "");
assert_eq!(body.error.message, "");
}
#[test]
fn retryability_follows_status_class() {
let server_err = ApiError::Api {
status: 503,
code: "SERVERS_UNAVAILABLE".into(),
message: "temporarily unavailable".into(),
request_id: String::new(),
};
assert!(server_err.is_retryable());
assert!(ApiError::Timeout.is_retryable());
assert!(!ApiError::NotFound {
message: "gone".into()
}
.is_retryable());
assert!(!ApiError::RateLimited {
retry_after: Some(5)
}
.is_retryable());
}
#[test]
fn kinds_are_stable_slugs() {
assert_eq!(ApiError::Timeout.kind(), "timeout");
assert_eq!(
ApiError::EndpointUnavailable {
path: "/v2/status".into()
}
.kind(),
"endpoint_unavailable"
);
}
}
+14
View File
@@ -0,0 +1,14 @@
//! The reforgermods.net API client layer.
//!
//! HTTP lives here and nowhere else. Tauri commands and the UI never build
//! requests themselves, so timeouts, retries, caching and error mapping have
//! exactly one implementation.
mod client;
mod error;
pub mod models;
mod query;
pub use client::{ApiClient, DEFAULT_BASE_URL};
pub use error::{ApiError, ApiErrorBody, ApiErrorDetail};
pub use query::{ClientPlatform, HistoryRange, ServerQuery, ServerSort, MAX_PER_PAGE};
+486
View File
@@ -0,0 +1,486 @@
//! Typed models for the reforgermods.net v2 API.
//!
//! Models are deliberately tolerant: unknown fields are ignored and absent
//! fields fall back to defaults. The API is free to add fields, and a launcher
//! that hard-fails on an unrecognised key would break on every API release.
//!
//! Every string in here is untrusted remote text. Nothing in this module
//! validates it; the only value that ever needs validating is a server id, and
//! that happens in [`crate::launch`] at the point of use.
use serde::{Deserialize, Serialize};
/// Freshness of the indexer snapshot backing the server dataset.
///
/// Present on every server response, which is why the launcher does not need a
/// separate poll to render "server data updated Ns ago".
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DatasetMeta {
/// True on cold start, before the indexer has produced a first snapshot.
#[serde(default)]
pub warming: bool,
/// True when the API considers the snapshot too old to be current.
#[serde(default)]
pub stale: bool,
/// Age of the snapshot in seconds.
#[serde(default)]
pub snapshot_age_seconds: f64,
/// RFC 3339 timestamp of the collection that produced the snapshot.
#[serde(default)]
pub last_collection_at: Option<String>,
/// Total servers ever indexed.
#[serde(default)]
pub server_count: u64,
#[serde(default)]
pub online_server_count: u64,
#[serde(default)]
pub offline_server_count: u64,
}
/// Pagination metadata for a server list page.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerListMeta {
#[serde(default)]
pub total_pages: u32,
#[serde(default)]
pub current_page: u32,
#[serde(default)]
pub total_servers: u32,
#[serde(default)]
pub shown_servers: u32,
}
/// reforgermods.net's derived ranking for a server.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerAnalytics {
#[serde(default)]
pub rank: Option<u32>,
#[serde(default)]
pub overall_score: Option<f64>,
#[serde(default)]
pub activity_score: Option<f64>,
#[serde(default)]
pub reliability_score: Option<f64>,
#[serde(default)]
pub confidence: Option<f64>,
}
/// A server as it appears in the browser list.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerSummary {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub scenario_id: String,
#[serde(default)]
pub scenario_name: String,
#[serde(default)]
pub scenario_thumbnail_url: Option<String>,
#[serde(default)]
pub game_version: String,
#[serde(default)]
pub official: bool,
#[serde(default)]
pub password_protected: bool,
#[serde(default, rename = "battlEye")]
pub battle_eye: bool,
#[serde(default)]
pub cross_play: bool,
#[serde(default)]
pub players: u32,
#[serde(default)]
pub max_players: u32,
#[serde(default)]
pub queue: u32,
#[serde(default)]
pub queue_max: u32,
/// Nitrado ping-site id, e.g. `frankfurt`. Join with [`PingSite`] for a label.
#[serde(default)]
pub ping_site_id: Option<String>,
#[serde(default)]
pub region: Option<String>,
/// Host OS the server runs on (`LINUX`/`WINDOWS`), *not* a client platform.
#[serde(default)]
pub platform: Option<String>,
/// Client platforms that may join: a subset of `pc`, `xbox`, `psn`.
#[serde(default)]
pub platforms: Vec<String>,
#[serde(default)]
pub mod_count: u32,
#[serde(default)]
pub online: bool,
#[serde(default)]
pub first_seen: Option<String>,
#[serde(default)]
pub last_seen: Option<String>,
#[serde(default)]
pub analytics: Option<ServerAnalytics>,
/// The API's own `steam://run/...` join URI.
///
/// Parsed for cross-checking only. The launcher builds its own URI from the
/// validated id: an API-supplied URL must never be handed to the OS.
#[serde(default)]
pub join_url: Option<String>,
}
/// `GET /v2/servers`
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ServersPage {
#[serde(default)]
pub meta: ServerListMeta,
#[serde(default)]
pub dataset: DatasetMeta,
#[serde(default)]
pub data: Vec<ServerSummary>,
}
/// Aggregate player activity derived from history samples.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerActivity {
#[serde(default)]
pub peak24h: u32,
#[serde(default)]
pub avg24h: u32,
#[serde(default)]
pub median24h: u32,
#[serde(default)]
pub low24h: u32,
#[serde(default)]
pub peak7d: u32,
#[serde(default)]
pub samples24h: u32,
#[serde(default)]
pub tracking_since: Option<String>,
}
/// Totals for a server's reported mod set.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerModSummary {
#[serde(default)]
pub count: u32,
/// Sum of sizes the API could resolve, in bytes. Not the full download size
/// unless `unresolved_count` is zero.
#[serde(default)]
pub known_size: u64,
#[serde(default)]
pub known_size_text: Option<String>,
/// Mods whose exact-version size could not be resolved.
#[serde(default)]
pub unresolved_count: u32,
}
/// One mod a server reports requiring.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerMod {
/// Workshop mod id: 16 uppercase hex characters.
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub size_text: Option<String>,
#[serde(default)]
pub size_known: bool,
/// reforgermods.net page for this mod. Untrusted; validate before opening.
#[serde(default)]
pub mod_url: Option<String>,
}
/// A server with connection, activity and mod detail.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerDetail {
/// Everything the list view already knows.
#[serde(flatten)]
pub summary: ServerSummary,
/// True when the server is in the current snapshot. False means "not
/// currently listed", which is not the same as "offline".
#[serde(default)]
pub present: bool,
#[serde(default)]
pub host_type: Option<String>,
#[serde(default)]
pub joinable: bool,
/// The server's address, as published by the API. Untrusted: validate with
/// [`crate::ping::parse_target`] before doing anything with it. Absent for
/// roughly a third of servers.
#[serde(default)]
pub host: Option<String>,
/// Game port. Informational only — nothing connects to it.
#[serde(default)]
pub port: u16,
/// Server-reported simulation FPS. 0 when not reported.
#[serde(default)]
pub fps: u32,
#[serde(default)]
pub activity: Option<ServerActivity>,
#[serde(default)]
pub mod_summary: ServerModSummary,
#[serde(default)]
pub mods: Vec<ServerMod>,
}
/// `GET /v2/servers/{id}`
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ServerDetailResponse {
#[serde(default)]
pub dataset: DatasetMeta,
pub server: ServerDetail,
}
/// A Nitrado ping location servers are grouped under.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PingSite {
pub id: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub region_group: String,
/// Hostname used for latency probes. Not contacted in milestone 1.
#[serde(default)]
pub address: Option<String>,
}
/// `GET /v2/servers/ping-sites`
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct PingSitesResponse {
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub sites: Vec<PingSite>,
}
/// One bucket of a server's population history.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ServerHistoryPoint {
/// Bucket start, unix seconds.
pub t: i64,
#[serde(default)]
pub avg: u32,
#[serde(default)]
pub min: u32,
#[serde(default)]
pub max: u32,
/// Slot capacity during the bucket. 0 when not recorded.
#[serde(default)]
pub cap: u32,
}
/// `GET /v2/servers/{id}/history`
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerHistory {
#[serde(default)]
pub server_id: String,
/// Range the API actually served, which may differ from what was asked.
#[serde(default)]
pub range: String,
/// Bucket width the API chose: `5m`, `hour` or `day`.
#[serde(default)]
pub bucket: String,
#[serde(default)]
pub points: Vec<ServerHistoryPoint>,
}
/// Fleet-wide population right now, from `GET /v2/servers/history`.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FleetNow {
#[serde(default)]
pub online_servers: u64,
/// Players across every listed server.
#[serde(default)]
pub players: u64,
/// Sum of slot capacity across those servers.
#[serde(default)]
pub capacity: u64,
#[serde(default)]
pub queue: u64,
}
/// `GET /v2/servers/history`, of which the launcher reads only `now`.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct FleetHistory {
#[serde(default)]
pub now: FleetNow,
}
/// `GET /v2/status`, parsed permissively.
///
/// This route is not deployed on api.reforgermods.net yet (it answers 404 as of
/// 2026-09-24), so every field is optional and the launcher treats an absent
/// route as "status unknown" rather than an error. See `docs/api-contract.md`.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusReport {
/// Overall state word when the API supplies one, e.g. `operational`.
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub dataset: Option<DatasetMeta>,
}
/// What the launcher header renders. Assembled from whichever sources answered,
/// so a missing `/v2/status` degrades to an unobtrusive unknown state instead of
/// an error banner.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LauncherStatus {
/// True when the API answered a health or status probe.
pub api_reachable: bool,
/// Freshness of the server dataset, when known.
pub dataset: Option<DatasetMeta>,
/// Set when `/v2/status` answered and named a state.
pub reported_status: Option<String>,
/// Human-readable reason the status is unknown, for a tooltip.
pub note: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
/// Fixtures are real responses captured from api.reforgermods.net.
const SERVERS_PAGE: &str = include_str!("../../tests/fixtures/servers_page.json");
const SERVER_DETAIL: &str = include_str!("../../tests/fixtures/server_detail.json");
const PING_SITES: &str = include_str!("../../tests/fixtures/ping_sites.json");
#[test]
fn servers_page_parses_from_live_fixture() {
let page: ServersPage = serde_json::from_str(SERVERS_PAGE).unwrap();
assert_eq!(page.data.len(), 3);
assert_eq!(page.meta.current_page, 1);
assert!(page.meta.total_servers > 0);
assert!(!page.dataset.warming);
assert!(page.dataset.last_collection_at.is_some());
let first = &page.data[0];
assert!(!first.id.is_empty());
assert!(!first.name.is_empty());
assert!(first.online);
assert!(first.max_players > 0);
// Sorted by players descending.
assert!(first.players >= page.data[1].players);
}
#[test]
fn battl_eye_field_name_is_mapped() {
// The API spells this `battlEye`; a wrong rename would silently read
// false for every server.
let page: ServersPage = serde_json::from_str(SERVERS_PAGE).unwrap();
let raw: serde_json::Value = serde_json::from_str(SERVERS_PAGE).unwrap();
for (parsed, raw) in page.data.iter().zip(raw["data"].as_array().unwrap()) {
assert_eq!(
parsed.battle_eye,
raw["battlEye"].as_bool().unwrap_or(false)
);
}
}
#[test]
fn server_detail_parses_from_live_fixture() {
let res: ServerDetailResponse = serde_json::from_str(SERVER_DETAIL).unwrap();
let s = &res.server;
assert_eq!(s.summary.id, "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f");
assert!(s.present);
// Flattened summary fields must survive the flatten.
assert!(!s.summary.name.is_empty());
assert!(s.summary.max_players > 0);
assert!(s.summary.mod_count > 0);
// Detail-only fields.
assert!(s.host.is_some(), "this fixture publishes an address");
assert!(s.port > 0);
assert_eq!(s.mods.len() as u32, s.mod_summary.count);
assert!(s.activity.is_some());
assert!(s.mods.iter().all(|m| m.id.len() == 16));
}
#[test]
fn ping_sites_parse_from_live_fixture() {
let res: PingSitesResponse = serde_json::from_str(PING_SITES).unwrap();
assert!(res.sites.len() >= 8);
let fra = res.sites.iter().find(|s| s.id == "frankfurt").unwrap();
assert_eq!(fra.label, "Frankfurt");
assert_eq!(fra.region_group, "Europe");
}
#[test]
fn unknown_fields_are_ignored() {
// A future API release adding fields must not break the launcher.
let raw = r#"{"meta":{"currentPage":2,"brandNew":1},"dataset":{},"data":[
{"id":"a","name":"n","somethingAdded":{"deep":true}}
],"extraTopLevel":"x"}"#;
let page: ServersPage = serde_json::from_str(raw).unwrap();
assert_eq!(page.meta.current_page, 2);
assert_eq!(page.data[0].id, "a");
}
#[test]
fn missing_optional_fields_fall_back_to_defaults() {
let page: ServersPage = serde_json::from_str(r#"{"data":[{"id":"a"}]}"#).unwrap();
let s = &page.data[0];
assert_eq!(s.players, 0);
assert!(!s.official);
assert!(s.platforms.is_empty());
assert!(s.ping_site_id.is_none());
assert!(s.analytics.is_none());
}
#[test]
fn an_absent_host_is_none_rather_than_an_empty_string() {
// About a third of servers publish no address; the ping code branches
// on None, so it must not arrive as Some("").
let res: ServerDetailResponse = serde_json::from_str(r#"{"server":{"id":"a"}}"#).unwrap();
assert!(res.server.host.is_none());
assert_eq!(res.server.port, 0);
}
#[test]
fn history_parses_from_live_fixture() {
const HISTORY: &str = include_str!("../../tests/fixtures/server_history.json");
let h: ServerHistory = serde_json::from_str(HISTORY).unwrap();
assert_eq!(h.range, "24h");
assert_eq!(h.bucket, "5m");
assert!(h.points.len() > 100, "24h of 5m buckets");
// Buckets must be ordered, or the chart draws backwards.
assert!(h.points.windows(2).all(|w| w[0].t <= w[1].t));
for point in &h.points {
assert!(
point.min <= point.avg && point.avg <= point.max,
"{point:?}"
);
assert!(point.t > 1_700_000_000, "unix seconds expected");
}
}
#[test]
fn history_tolerates_an_empty_series() {
// A server with no recorded history returns an empty array, not an error.
let h: ServerHistory =
serde_json::from_str(r#"{"serverId":"x","range":"7d","bucket":"hour"}"#).unwrap();
assert!(h.points.is_empty());
}
#[test]
fn status_report_tolerates_an_unknown_shape() {
// The contract is not published yet; parsing must not hard-fail.
let r: StatusReport = serde_json::from_str(r#"{"whatever":true}"#).unwrap();
assert!(r.status.is_none());
let r: StatusReport =
serde_json::from_str(r#"{"status":"operational","dataset":{"stale":false}}"#).unwrap();
assert_eq!(r.status.as_deref(), Some("operational"));
assert!(!r.dataset.unwrap().stale);
}
}
+409
View File
@@ -0,0 +1,409 @@
//! Server browser query construction.
//!
//! Only filters the live API actually supports are modelled here. `/v2/servers`
//! accepts `page`, `perPage`, `search`, `sort`, `region`, `platform`, `online`,
//! `includeOffline`, `official`, `hasMods`, `battleye` and `locked`; anything
//! else would be silently ignored by the API and would produce a filter control
//! that appears to work but does not.
//!
//! [`ServerQuery::min_players`] is the one exception and is explicitly *not*
//! sent to the API: it is applied client-side by [`ServerQuery::retains`],
//! because no server-side equivalent exists.
use serde::{Deserialize, Serialize};
/// Sort orders `/v2/servers` accepts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ServerSort {
/// Most players first. The useful default for a browser.
#[default]
Players,
Name,
Newest,
LastSeen,
}
impl ServerSort {
/// The wire value for the `sort` parameter.
pub fn as_param(self) -> &'static str {
match self {
ServerSort::Players => "players",
ServerSort::Name => "name",
ServerSort::Newest => "newest",
ServerSort::LastSeen => "lastSeen",
}
}
}
/// Client platforms the API can filter by.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ClientPlatform {
Pc,
Xbox,
Psn,
}
impl ClientPlatform {
/// The wire value for the `platform` parameter. The API canonicalises these
/// short tokens itself.
pub fn as_param(self) -> &'static str {
match self {
ClientPlatform::Pc => "pc",
ClientPlatform::Xbox => "xbox",
ClientPlatform::Psn => "psn",
}
}
}
/// Largest page the API permits.
pub const MAX_PER_PAGE: u32 = 500;
/// A server browser query.
///
/// `None` on a tri-state field means "do not filter", which is different from
/// `Some(false)`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ServerQuery {
/// Free-text search over server names.
pub search: String,
pub sort: ServerSort,
/// Ping-site id, e.g. `frankfurt`. See `GET /v2/servers/ping-sites`.
pub region: Option<String>,
pub platform: Option<ClientPlatform>,
/// Official Bohemia servers only / community only.
pub official: Option<bool>,
/// Servers reporting at least one mod / none.
pub has_mods: Option<bool>,
pub battleye: Option<bool>,
/// `locked` is the API's name for password-protected.
pub locked: Option<bool>,
/// Include servers not in the current snapshot. Off by default: an offline
/// server cannot be joined.
pub include_offline: bool,
/// Client-side only. Drops servers below this player count from the loaded
/// page; the API has no equivalent parameter.
pub min_players: u32,
pub page: u32,
pub per_page: u32,
}
impl Default for ServerQuery {
fn default() -> Self {
Self {
search: String::new(),
sort: ServerSort::default(),
region: None,
platform: None,
official: None,
has_mods: None,
battleye: None,
locked: None,
include_offline: false,
min_players: 0,
page: 1,
per_page: 100,
}
}
}
impl ServerQuery {
/// The `/v2/servers` query parameters for this query, in a stable order.
///
/// Stable ordering matters: the serialised form is the cache key, so two
/// equal queries must produce byte-identical strings.
pub fn to_params(&self) -> Vec<(&'static str, String)> {
let mut params: Vec<(&'static str, String)> = Vec::with_capacity(12);
params.push(("page", self.page.max(1).to_string()));
params.push(("perPage", self.per_page.clamp(1, MAX_PER_PAGE).to_string()));
params.push(("sort", self.sort.as_param().to_string()));
let search = self.search.trim();
if !search.is_empty() {
params.push(("search", search.to_string()));
}
if let Some(region) = self
.region
.as_deref()
.map(str::trim)
.filter(|r| !r.is_empty())
{
params.push(("region", region.to_string()));
}
if let Some(platform) = self.platform {
params.push(("platform", platform.as_param().to_string()));
}
// Tri-state flags: only sent when the user actually chose a side.
for (name, value) in [
("official", self.official),
("hasMods", self.has_mods),
("battleye", self.battleye),
("locked", self.locked),
] {
if let Some(value) = value {
params.push((name, value.to_string()));
}
}
if self.include_offline {
params.push(("includeOffline", "true".to_string()));
}
params
}
/// Whether a fetched server survives the client-side filters.
///
/// Only `min_players` lives here — the one filter the API cannot express.
/// Everything else was already applied server-side, and re-checking it
/// locally would mean two filter implementations that could disagree.
pub fn retains(&self, players: u32) -> bool {
players >= self.min_players
}
/// True when any filter beyond sort and pagination is active. Drives the
/// "clear filters" affordance.
pub fn has_active_filters(&self) -> bool {
// Mirrors `to_params`: a blank search or region is not sent to the API,
// so it must not count as an active filter either.
!self.search.trim().is_empty()
|| self.region.as_deref().is_some_and(|r| !r.trim().is_empty())
|| self.platform.is_some()
|| self.official.is_some()
|| self.has_mods.is_some()
|| self.battleye.is_some()
|| self.locked.is_some()
|| self.include_offline
|| self.min_players > 0
}
}
/// Ranges `GET /v2/servers/{id}/history` accepts.
///
/// The API rejects anything else with `INVALID_RANGE`, so this is an enum rather
/// than a free string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum HistoryRange {
SixHours,
/// The API's own default.
#[default]
OneDay,
ThreeDays,
OneWeek,
OneMonth,
ThreeMonths,
OneYear,
All,
}
impl HistoryRange {
/// The wire value for the `range` parameter.
pub fn as_param(self) -> &'static str {
match self {
HistoryRange::SixHours => "6h",
HistoryRange::OneDay => "24h",
HistoryRange::ThreeDays => "72h",
HistoryRange::OneWeek => "7d",
HistoryRange::OneMonth => "30d",
HistoryRange::ThreeMonths => "90d",
HistoryRange::OneYear => "1y",
HistoryRange::All => "all",
}
}
/// The ranges the inspector offers. A subset: 90d, 1y and all are supported
/// by the API but are not useful in a compact panel.
pub const OFFERED: [HistoryRange; 4] = [
HistoryRange::SixHours,
HistoryRange::OneDay,
HistoryRange::OneWeek,
HistoryRange::OneMonth,
];
}
#[cfg(test)]
mod tests {
use super::*;
fn param_string(q: &ServerQuery) -> String {
q.to_params()
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&")
}
#[test]
fn default_query_asks_for_the_busiest_online_servers() {
let q = ServerQuery::default();
assert_eq!(param_string(&q), "page=1&perPage=100&sort=players");
// includeOffline absent means online-only, which is the API default.
assert!(!q.has_active_filters());
}
#[test]
fn unset_tristate_filters_are_omitted_entirely() {
let q = ServerQuery::default();
let keys: Vec<_> = q.to_params().into_iter().map(|(k, _)| k).collect();
for absent in [
"official",
"hasMods",
"battleye",
"locked",
"includeOffline",
] {
assert!(!keys.contains(&absent), "{absent} should not be sent");
}
}
#[test]
fn false_is_distinct_from_unset() {
let q = ServerQuery {
official: Some(false),
has_mods: Some(false),
..Default::default()
};
assert!(param_string(&q).contains("official=false"));
assert!(param_string(&q).contains("hasMods=false"));
assert!(q.has_active_filters());
}
#[test]
fn all_filters_serialise_in_a_stable_order() {
let q = ServerQuery {
search: " conflict ".into(),
sort: ServerSort::Name,
region: Some("frankfurt".into()),
platform: Some(ClientPlatform::Pc),
official: Some(true),
has_mods: Some(true),
battleye: Some(true),
locked: Some(false),
include_offline: true,
min_players: 10,
page: 3,
per_page: 50,
};
assert_eq!(
param_string(&q),
"page=3&perPage=50&sort=name&search=conflict&region=frankfurt&platform=pc\
&official=true&hasMods=true&battleye=true&locked=false&includeOffline=true"
);
// Identical queries must serialise identically, so the cache key is stable.
assert_eq!(param_string(&q), param_string(&q.clone()));
}
#[test]
fn min_players_is_never_sent_to_the_api() {
let q = ServerQuery {
min_players: 64,
..Default::default()
};
assert!(!param_string(&q).contains("min"), "{}", param_string(&q));
assert!(q.has_active_filters());
}
#[test]
fn min_players_filters_client_side() {
let q = ServerQuery {
min_players: 10,
..Default::default()
};
assert!(!q.retains(0));
assert!(!q.retains(9));
assert!(q.retains(10));
assert!(q.retains(128));
// A zero threshold keeps everything, including empty servers.
assert!(ServerQuery::default().retains(0));
}
#[test]
fn history_ranges_match_the_api_vocabulary() {
// The API rejects anything outside this set with INVALID_RANGE.
let params: Vec<&str> = [
HistoryRange::SixHours,
HistoryRange::OneDay,
HistoryRange::ThreeDays,
HistoryRange::OneWeek,
HistoryRange::OneMonth,
HistoryRange::ThreeMonths,
HistoryRange::OneYear,
HistoryRange::All,
]
.iter()
.map(|r| r.as_param())
.collect();
assert_eq!(
params,
["6h", "24h", "72h", "7d", "30d", "90d", "1y", "all"]
);
// The default matches the API's own default.
assert_eq!(HistoryRange::default().as_param(), "24h");
assert!(HistoryRange::OFFERED.contains(&HistoryRange::default()));
}
#[test]
fn blank_search_and_region_are_dropped() {
let q = ServerQuery {
search: " ".into(),
region: Some(" ".into()),
..Default::default()
};
assert_eq!(param_string(&q), "page=1&perPage=100&sort=players");
assert!(!q.has_active_filters());
}
#[test]
fn pagination_is_clamped_to_api_limits() {
let q = ServerQuery {
page: 0,
per_page: 9_999,
..Default::default()
};
assert!(param_string(&q).contains("page=1"));
assert!(param_string(&q).contains(&format!("perPage={MAX_PER_PAGE}")));
let q = ServerQuery {
per_page: 0,
..Default::default()
};
assert!(param_string(&q).contains("perPage=1"));
}
#[test]
fn sort_and_platform_params_match_the_api_vocabulary() {
assert_eq!(ServerSort::Players.as_param(), "players");
assert_eq!(ServerSort::LastSeen.as_param(), "lastSeen");
assert_eq!(ServerSort::Newest.as_param(), "newest");
assert_eq!(ServerSort::Name.as_param(), "name");
assert_eq!(ClientPlatform::Pc.as_param(), "pc");
assert_eq!(ClientPlatform::Xbox.as_param(), "xbox");
assert_eq!(ClientPlatform::Psn.as_param(), "psn");
}
#[test]
fn query_round_trips_through_json() {
// The UI sends this struct across the Tauri boundary.
let q = ServerQuery {
search: "everon".into(),
sort: ServerSort::LastSeen,
platform: Some(ClientPlatform::Psn),
locked: Some(false),
min_players: 5,
..Default::default()
};
let json = serde_json::to_string(&q).unwrap();
assert_eq!(serde_json::from_str::<ServerQuery>(&json).unwrap(), q);
}
#[test]
fn partial_json_from_the_ui_fills_in_defaults() {
let q: ServerQuery = serde_json::from_str(r#"{"search":"ru"}"#).unwrap();
assert_eq!(q.search, "ru");
assert_eq!(q.page, 1);
assert_eq!(q.per_page, 100);
assert_eq!(q.sort, ServerSort::Players);
}
}
+164
View File
@@ -0,0 +1,164 @@
//! A tiny bounded TTL cache for API response bodies.
//!
//! The launcher caches raw response text keyed by request URL rather than
//! deserialised structs: it keeps the cache free of generics, and re-parsing a
//! cached body costs microseconds against a network round trip. Entries are
//! capped so a long session browsing thousands of servers cannot grow without
//! bound.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// How long a cached entry stays fresh, per class of request.
#[derive(Debug, Clone, Copy)]
pub struct Ttl(pub Duration);
impl Ttl {
/// Server list and detail pages. The indexer publishes a new snapshot about
/// once a minute; 15s keeps the view current without hammering the API when
/// the user flips between filters.
pub const SNAPSHOT: Ttl = Ttl(Duration::from_secs(15));
/// Population history. The narrowest bucket the API produces is 5 minutes,
/// so a fresh request inside a minute cannot show the user anything new.
pub const HISTORY: Ttl = Ttl(Duration::from_secs(60));
/// Ping sites change rarely; the response carries its own `updatedAt`.
pub const STABLE: Ttl = Ttl(Duration::from_secs(60 * 60));
}
struct Entry {
stored_at: Instant,
body: String,
}
/// A process-lifetime cache of response bodies.
pub struct ResponseCache {
entries: Mutex<HashMap<String, Entry>>,
capacity: usize,
}
impl ResponseCache {
/// Creates a cache holding at most `capacity` entries.
pub fn new(capacity: usize) -> Self {
Self {
entries: Mutex::new(HashMap::new()),
capacity: capacity.max(1),
}
}
/// Returns the cached body for `key` when it is still within `ttl`.
pub fn get(&self, key: &str, ttl: Ttl) -> Option<String> {
let entries = self.entries.lock().ok()?;
let entry = entries.get(key)?;
(entry.stored_at.elapsed() < ttl.0).then(|| entry.body.clone())
}
/// Stores `body` under `key`, evicting the oldest entry when full.
pub fn put(&self, key: &str, body: String) {
let Ok(mut entries) = self.entries.lock() else {
// A poisoned cache is a cache miss, never a failed request.
return;
};
if entries.len() >= self.capacity && !entries.contains_key(key) {
// Linear scan for the oldest entry. At this capacity that is far
// cheaper than maintaining a second index.
if let Some(oldest) = entries
.iter()
.min_by_key(|(_, e)| e.stored_at)
.map(|(k, _)| k.clone())
{
entries.remove(&oldest);
}
}
entries.insert(
key.to_owned(),
Entry {
stored_at: Instant::now(),
body,
},
);
}
/// Drops every entry. Used when the API base URL changes.
pub fn clear(&self) {
if let Ok(mut entries) = self.entries.lock() {
entries.clear();
}
}
/// Number of entries currently held, fresh or not.
pub fn len(&self) -> usize {
self.entries.lock().map(|e| e.len()).unwrap_or(0)
}
/// True when the cache holds nothing.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl Default for ResponseCache {
fn default() -> Self {
Self::new(256)
}
}
#[cfg(test)]
mod tests {
use super::*;
const INSTANT: Ttl = Ttl(Duration::ZERO);
#[test]
fn stores_and_reads_back() {
let cache = ResponseCache::new(4);
cache.put("a", "body".into());
assert_eq!(cache.get("a", Ttl::SNAPSHOT).as_deref(), Some("body"));
}
#[test]
fn misses_on_unknown_key() {
let cache = ResponseCache::new(4);
assert!(cache.get("nope", Ttl::SNAPSHOT).is_none());
}
#[test]
fn expired_entries_are_not_returned() {
let cache = ResponseCache::new(4);
cache.put("a", "body".into());
// A zero TTL means every entry is already stale.
assert!(cache.get("a", INSTANT).is_none());
}
#[test]
fn eviction_keeps_the_cache_bounded() {
let cache = ResponseCache::new(2);
cache.put("a", "1".into());
cache.put("b", "2".into());
cache.put("c", "3".into());
assert_eq!(cache.len(), 2);
// "a" was oldest, so it went first.
assert!(cache.get("a", Ttl::SNAPSHOT).is_none());
assert!(cache.get("c", Ttl::SNAPSHOT).is_some());
}
#[test]
fn overwriting_an_existing_key_does_not_evict() {
let cache = ResponseCache::new(2);
cache.put("a", "1".into());
cache.put("b", "2".into());
cache.put("a", "updated".into());
assert_eq!(cache.len(), 2);
assert_eq!(cache.get("a", Ttl::SNAPSHOT).as_deref(), Some("updated"));
assert!(cache.get("b", Ttl::SNAPSHOT).is_some());
}
#[test]
fn clear_empties_the_cache() {
let cache = ResponseCache::new(4);
cache.put("a", "1".into());
assert!(!cache.is_empty());
cache.clear();
assert!(cache.is_empty());
}
}
+258
View File
@@ -0,0 +1,258 @@
//! Stable, anonymous installation identity.
//!
//! The API needs to tell "50 000 requests from 5 000 installs" from "50 000
//! requests from one". A value generated on first run answers that, but it
//! resets whenever the launcher is reinstalled, so one person reinstalling
//! three times counts as three installs.
//!
//! This derives the identifier from a machine characteristic instead, so it
//! survives uninstall and reinstall.
//!
//! # What is sent, and what is not
//!
//! The machine value **never leaves the process**. What is sent is
//!
//! ```text
//! SHA-256(APPLICATION_SALT || machine value) -> first 16 bytes -> UUID
//! ```
//!
//! so the API receives an opaque identifier it cannot reverse into a machine
//! id, and the same machine running a different application derives a different
//! value. The wire format stays a canonical UUID, so the API contract in
//! `docs/client-identification.md` is unchanged.
//!
//! # Why not IP
//!
//! An address is the wrong input: it changes with DHCP leases, VPNs, tethering
//! and moving between networks, so it would both split one install into many
//! and merge everyone behind a shared NAT into one. It is also the one piece of
//! genuinely personal data in the request, and the API already sees it.
//!
//! # Sources
//!
//! | Platform | Value |
//! | --- | --- |
//! | Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` |
//! | Linux | `/etc/machine-id`, else `/var/lib/dbus/machine-id` |
//! | Other / unreadable | falls back to a persisted random value |
//!
//! All of these are per-OS-installation, not per-user and not per-device-serial,
//! and they change when the OS is reinstalled — which is the intended
//! granularity: a new OS is a new install.
use sha2::{Digest, Sha256};
/// Domain separator. Mixed in so the same machine yields a different identifier
/// for a different application, and so the digest is not a bare hash of a value
/// that other software also knows.
const APPLICATION_SALT: &str = "reforgermods.net-launcher/install-id/v1";
/// The identifier to send, preferring a machine-derived value.
///
/// `fallback` is the persisted random value used when no machine source is
/// readable — a sandboxed or unusual environment still gets a working, if
/// reinstall-unstable, identifier rather than none.
pub fn stable_install_id(fallback: &str) -> String {
match machine_value() {
Some(value) => derive_uuid(&value),
None => fallback.to_string(),
}
}
/// True when this build could read a machine source, so the identifier survives
/// a reinstall. Surfaced only in debug mode.
pub fn is_machine_derived() -> bool {
machine_value().is_some()
}
/// Hashes `material` into a canonical UUID.
///
/// The result carries UUID version 8 (custom) and the RFC 4122 variant, which
/// is the correct label for a value derived this way — it is not random (v4) and
/// not an RFC-specified name hash (v3/v5).
fn derive_uuid(material: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(APPLICATION_SALT.as_bytes());
hasher.update([0u8]); // Separator, so salt and value cannot run together.
hasher.update(material.trim().to_ascii_lowercase().as_bytes());
let digest = hasher.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest[..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x80; // version 8
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
)
}
/// Reads this machine's identifier, if one is available.
#[cfg(windows)]
fn machine_value() -> Option<String> {
use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY};
use winreg::RegKey;
// Explicitly the 64-bit view: MachineGuid lives there, and a 32-bit process
// would otherwise be redirected to an empty Wow6432Node.
let key = RegKey::predef(HKEY_LOCAL_MACHINE)
.open_subkey_with_flags(
r"SOFTWARE\Microsoft\Cryptography",
KEY_READ | KEY_WOW64_64KEY,
)
.ok()?;
let guid: String = key.get_value("MachineGuid").ok()?;
usable(guid)
}
/// Reads this machine's identifier, if one is available.
#[cfg(not(windows))]
fn machine_value() -> Option<String> {
["/etc/machine-id", "/var/lib/dbus/machine-id"]
.iter()
.find_map(|path| std::fs::read_to_string(path).ok())
.and_then(usable)
}
/// Rejects blank or obviously placeholder values.
///
/// An all-zero or empty machine id would make every affected machine share one
/// identifier, which is worse than falling back to a random one.
fn usable(value: String) -> Option<String> {
let trimmed = value.trim().to_string();
if trimmed.len() < 8 {
return None;
}
if trimmed.chars().all(|c| c == '0' || c == '-') {
return None;
}
Some(trimmed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::launch::ServerId;
#[test]
fn derived_ids_are_canonical_uuids() {
// The API header validator accepts only this shape, so a derived value
// that failed it would be silently dropped.
let id = derive_uuid("4c4c4544-0037-5910-8051-b8c04f325632");
assert!(ServerId::parse(&id).is_ok(), "{id}");
assert_eq!(id.len(), 36);
}
#[test]
fn derivation_is_deterministic() {
let a = derive_uuid("machine-abc");
let b = derive_uuid("machine-abc");
assert_eq!(a, b, "the same machine must reinstall to the same id");
}
#[test]
fn different_machines_derive_different_ids() {
assert_ne!(derive_uuid("machine-abc"), derive_uuid("machine-abd"));
}
#[test]
fn input_is_normalised_for_case_and_whitespace() {
// Windows returns the GUID in varying case depending on how it is read.
let canonical = derive_uuid("4c4c4544-0037-5910-8051-b8c04f325632");
assert_eq!(
derive_uuid("4C4C4544-0037-5910-8051-B8C04F325632"),
canonical
);
assert_eq!(
derive_uuid(" 4c4c4544-0037-5910-8051-b8c04f325632\n"),
canonical
);
}
#[test]
fn the_machine_value_is_not_recoverable_from_the_id() {
// Not a proof, but it pins the intent: the identifier must never be the
// machine value, or a prefix or suffix of it.
let machine = "4c4c4544-0037-5910-8051-b8c04f325632";
let id = derive_uuid(machine);
assert_ne!(id, machine);
assert!(!id.contains(&machine[..8]));
assert!(!machine.contains(&id[..8]));
}
#[test]
fn version_and_variant_are_set() {
let id = derive_uuid("anything");
let bytes: Vec<u8> = id
.replace('-', "")
.as_bytes()
.chunks(2)
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect();
assert_eq!(bytes[6] >> 4, 8, "version 8, a custom derivation");
assert_eq!(bytes[8] >> 6, 0b10, "RFC 4122 variant");
}
#[test]
fn the_salt_separates_this_application() {
// Two applications on one machine must not share an identifier. Mirrors
// derive_uuid with a different salt.
let mut hasher = Sha256::new();
hasher.update(b"some-other-application");
hasher.update([0u8]);
hasher.update(b"machine-abc");
let other = hasher.finalize();
assert_ne!(
derive_uuid("machine-abc")
.replace('-', "")
.chars()
.take(8)
.collect::<String>(),
other[..4]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
);
}
#[test]
fn unusable_machine_values_are_rejected() {
// These would collapse many machines onto one identifier.
for value in [
"",
" ",
"0",
"0000",
"00000000-0000-0000-0000-000000000000",
"-----",
] {
assert!(usable(value.to_string()).is_none(), "{value:?}");
}
assert!(usable("4c4c4544-0037-5910-8051-b8c04f325632".into()).is_some());
}
#[test]
fn the_fallback_is_used_when_no_machine_value_exists() {
// Cannot force machine_value() to fail here, so this pins the contract
// that a fallback is returned verbatim rather than hashed.
let fallback = "8f14e45f-ceea-467a-9c2b-1f2a3b4c5d6e";
let id = stable_install_id(fallback);
assert!(ServerId::parse(&id).is_ok());
if !is_machine_derived() {
assert_eq!(id, fallback);
}
}
#[test]
fn a_machine_derived_id_is_stable_across_calls() {
// Only meaningful where a machine id exists. A container or a minimal
// chroot has neither /etc/machine-id nor the dbus copy, and there every
// call correctly returns the fallback it was handed instead.
if !is_machine_derived() {
return;
}
assert_eq!(stable_install_id("x"), stable_install_id("y"));
}
}
+500
View File
@@ -0,0 +1,500 @@
//! Direct-join URI construction for Arma Reforger.
//!
//! The join payload is a base64 encoding of a small JSON object naming the
//! lobby room:
//!
//! ```text
//! {"id":"<server-uuid>","inviteToken":""}
//! -> UTF-8 bytes
//! -> standard base64
//! -> trailing '=' padding removed
//! ```
//!
//! # How the payload reaches the game
//!
//! The payload is delivered as a Steam launch argument:
//!
//! ```text
//! steam://run/1874880//<payload>/
//! ^^
//! ```
//!
//! The **empty path segment** is what matters. `steam://run/<appid>//<args>/`
//! is Steam's documented form for passing launch arguments; with a single
//! slash, Steam can start the game without forwarding the payload, leaving the
//! player on the main menu.
//!
//! Status: this is the current best understanding and is **not yet confirmed
//! against a live client**. The single-slash form was observed to launch the
//! game without joining; the double-slash form is the likely fix.
//!
//! # The `ArmaReforger:` protocol, an alternative
//!
//! The game also registers its own URL protocol. Its Steam install script
//! (`steamapps/common/Arma Reforger/installscript.vdf`) writes:
//!
//! ```text
//! HKCU\SOFTWARE\Classes\ArmaReforger
//! (Default) = "URL:Arma Reforger Protocol"
//! URL Protocol = ""
//! HKCU\SOFTWARE\Classes\ArmaReforger\shell\open\command
//! (Default) = "<dir>\ArmaReforger_BE.exe" -exe ArmaReforgerSteam.exe
//! "-addonsDir=<dir>\addons" -uri="%1"
//! ```
//!
//! [`reforger_join_uri`] builds that form. It is kept, tested and unused by
//! default: it is a second candidate delivery route if the Steam form turns out
//! not to join, and it does not depend on how Steam forwards arguments.
//!
//! # Launch state
//!
//! Either route only carries the payload when the game actually starts. If Arma
//! Reforger is already running, Steam may simply focus the existing process and
//! drop the arguments, so a join must be tested from a fully closed game.
//!
//! Everything in this module is pure. It never spawns a shell and never
//! interpolates API-supplied text: the only value that reaches a URI is a
//! [`ServerId`], which cannot be constructed without passing UUID validation.
use base64::engine::general_purpose::STANDARD_NO_PAD;
use base64::Engine as _;
use serde::Serialize;
/// Steam App ID for Arma Reforger.
pub const ARMA_REFORGER_APP_ID: u32 = 1874880;
/// URL protocol Arma Reforger registers for join links.
///
/// Matches the `HKCU\SOFTWARE\Classes\ArmaReforger` key written by the game's
/// Steam install script. URI schemes are case-insensitive; this spelling
/// mirrors the registry key.
pub const REFORGER_URI_SCHEME: &str = "ArmaReforger";
/// Errors produced while validating a server identifier.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LaunchError {
/// The identifier was not a canonical 8-4-4-4-12 lowercase-hex UUID.
#[error("not a valid Arma Reforger server id (expected a UUID, got {got:?})")]
InvalidServerId { got: String },
}
/// A validated Arma Reforger server identifier.
///
/// Two shapes occur in the API, and both must be joinable:
///
/// - **Community servers** use a canonical UUID, e.g.
/// `73e8fb7f-8789-4d88-8aa8-ea69b9aa092f`.
/// - **Official servers** use a provider id, e.g. `nitrado_17516010`. Sampled
/// across 600 servers, every non-UUID id was official and every official id
/// was `nitrado_` followed by digits.
///
/// The only way to obtain one is [`ServerId::parse`], so any `ServerId` in the
/// program contains nothing but lowercase alphanumerics, `-` and `_`. This is
/// the security boundary for launching: untrusted API text cannot become a URI.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct ServerId(String);
impl ServerId {
/// Parses a canonical UUID. Case is normalised to lowercase; surrounding
/// whitespace is rejected rather than trimmed, because a stray space in an
/// id means the caller mishandled the value somewhere upstream.
pub fn parse(raw: &str) -> Result<Self, LaunchError> {
if is_canonical_uuid(raw) || is_provider_id(raw) {
Ok(Self(raw.to_ascii_lowercase()))
} else {
Err(LaunchError::InvalidServerId {
got: raw.to_owned(),
})
}
}
/// True for an official (provider-hosted) server.
pub fn is_official_form(&self) -> bool {
is_provider_id(&self.0)
}
/// The validated identifier as a string slice.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ServerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// Returns true for exactly `8-4-4-4-12` ASCII-hex groups and nothing else.
///
/// Hand-written rather than pulled from a UUID crate so the accepted shape is
/// visible here: no braces, no `urn:` prefix, no internal whitespace.
fn is_canonical_uuid(s: &str) -> bool {
const GROUPS: [usize; 5] = [8, 4, 4, 4, 12];
let mut parts = s.split('-');
for len in GROUPS {
match parts.next() {
Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_hexdigit()) => {}
_ => return false,
}
}
parts.next().is_none()
}
/// Returns true for `<provider>_<digits>`, the official-server id shape.
///
/// Deliberately narrow: lowercase ASCII letters, one underscore, then digits.
/// Every character it admits is URI-safe, so a validated id never needs
/// escaping. Written generically rather than hardcoding `nitrado` so a second
/// provider does not silently become unjoinable.
fn is_provider_id(s: &str) -> bool {
let Some((provider, number)) = s.split_once('_') else {
return false;
};
!provider.is_empty()
&& provider.len() <= 16
&& provider.bytes().all(|b| b.is_ascii_lowercase())
&& !number.is_empty()
&& number.len() <= 24
&& number.bytes().all(|b| b.is_ascii_digit())
}
/// The JSON body Arma Reforger expects. Field order is the declaration order,
/// which is what the verified payload uses.
#[derive(Serialize)]
struct JoinPayload<'a> {
id: &'a str,
#[serde(rename = "inviteToken")]
invite_token: &'a str,
}
/// Serialises the join payload for a server.
///
/// Exposed separately from [`steam_join_uri`] so the exact bytes that get
/// encoded are directly assertable in tests.
pub fn join_payload_json(server: &ServerId) -> String {
let payload = JoinPayload {
id: server.as_str(),
invite_token: "",
};
// A struct of two string fields cannot fail to serialise.
serde_json::to_string(&payload).expect("join payload is always serialisable")
}
/// Base64-encodes the join payload with trailing `=` padding removed.
pub fn join_payload_base64(server: &ServerId) -> String {
STANDARD_NO_PAD.encode(join_payload_json(server).as_bytes())
}
/// Builds the `ArmaReforger://` URI for the game's own protocol handler.
///
/// Not used by default; see the module docs. Retained as a second candidate
/// delivery route, since it reaches the launcher as `-uri=` without depending
/// on how Steam forwards arguments.
pub fn reforger_join_uri(server: &ServerId) -> String {
format!("{REFORGER_URI_SCHEME}://{}", join_payload_base64(server))
}
/// Convenience: validate a raw identifier and build its join URI in one step.
pub fn reforger_join_uri_for(raw: &str) -> Result<String, LaunchError> {
Ok(reforger_join_uri(&ServerId::parse(raw)?))
}
/// Builds the `steam://run/...` URI that joins `server` directly.
///
/// Note the doubled slash: `steam://run/<appid>//<args>/`. The empty path
/// segment marks what follows as a launch argument. Dropping it produces a URI
/// that starts the game without forwarding the payload.
///
/// Hand this to the platform URI opener (`ShellExecuteW` on Windows via
/// `tauri-plugin-opener`). Never pass it to a shell.
pub fn steam_join_uri(server: &ServerId) -> String {
format!(
"steam://run/{}//{}/",
ARMA_REFORGER_APP_ID,
join_payload_base64(server)
)
}
/// Convenience: validate a raw identifier and build its join URI in one step.
pub fn steam_join_uri_for(raw: &str) -> Result<String, LaunchError> {
Ok(steam_join_uri(&ServerId::parse(raw)?))
}
/// Builds the `steam://run/<appid>` URI that starts Arma Reforger with no
/// server argument, for "just launch the game".
pub fn steam_launch_uri() -> String {
format!("steam://run/{ARMA_REFORGER_APP_ID}")
}
#[cfg(test)]
mod tests {
use super::*;
/// The identifier and payload from the verified PowerShell experiment.
const SAMPLE_ID: &str = "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f";
const SAMPLE_B64: &str =
"eyJpZCI6IjczZThmYjdmLTg3ODktNGQ4OC04YWE4LWVhNjliOWFhMDkyZiIsImludml0ZVRva2VuIjoiIn0";
#[test]
fn payload_json_matches_verified_shape() {
let id = ServerId::parse(SAMPLE_ID).unwrap();
assert_eq!(
join_payload_json(&id),
r#"{"id":"73e8fb7f-8789-4d88-8aa8-ea69b9aa092f","inviteToken":""}"#
);
}
#[test]
fn payload_base64_matches_verified_vector() {
let id = ServerId::parse(SAMPLE_ID).unwrap();
assert_eq!(join_payload_base64(&id), SAMPLE_B64);
}
#[test]
fn base64_padding_is_removed() {
let id = ServerId::parse(SAMPLE_ID).unwrap();
let encoded = join_payload_base64(&id);
assert!(
!encoded.ends_with('='),
"padding must be stripped: {encoded}"
);
// The 62-byte payload would normally encode to 84 chars with one '='.
assert_eq!(join_payload_json(&id).len(), 62);
assert_eq!(encoded.len(), 83);
}
#[test]
fn padding_is_removed_for_every_residue_class() {
// UUID payloads are a fixed length, so exercise the encoder directly to
// cover all three base64 remainder cases (0, 1 and 2 leftover bytes).
for input in ["abc", "abcd", "abcde", ""] {
let encoded = STANDARD_NO_PAD.encode(input.as_bytes());
assert!(!encoded.contains('='), "{input:?} -> {encoded}");
}
}
#[test]
fn join_uri_has_the_empty_segment_that_marks_a_launch_argument() {
// This is the entire fix and it is one character. Without the empty
// segment Steam can start the game without forwarding the payload,
// which looks like a successful launch that never joins.
let id = ServerId::parse(SAMPLE_ID).unwrap();
let uri = steam_join_uri(&id);
assert_eq!(uri, format!("steam://run/1874880//{SAMPLE_B64}/"));
assert!(
uri.contains(&format!("{ARMA_REFORGER_APP_ID}//")),
"app id must be followed by an empty path segment: {uri}"
);
assert!(
!uri.contains(&format!("{ARMA_REFORGER_APP_ID}/{SAMPLE_B64}")),
"single-slash form regressed: {uri}"
);
}
#[test]
fn join_uri_matches_the_reference_implementation() {
// Mirrors the JS reference: JSON.stringify({id, inviteToken}) in that
// field order, standard base64, trailing '=' stripped, wrapped as
// steam://run/1874880//<payload>/.
let id = ServerId::parse(SAMPLE_ID).unwrap();
let payload = join_payload_base64(&id);
assert!(!payload.contains('='));
assert_eq!(
steam_join_uri(&id),
format!("steam://run/{ARMA_REFORGER_APP_ID}//{payload}/")
);
}
#[test]
fn plain_launch_uri_keeps_a_single_segment() {
// No payload means no argument marker; this must not gain the empty
// segment along with the join URI.
assert_eq!(steam_launch_uri(), "steam://run/1874880");
}
#[test]
fn the_protocol_alternative_carries_an_identical_payload() {
let id = ServerId::parse(SAMPLE_ID).unwrap();
let payload = join_payload_base64(&id);
assert!(steam_join_uri(&id).contains(&payload));
assert!(reforger_join_uri(&id).ends_with(&payload));
}
#[test]
fn join_uri_uses_the_games_own_protocol() {
// The scheme the game registers; Steam's run URL drops the payload.
let id = ServerId::parse(SAMPLE_ID).unwrap();
assert_eq!(
reforger_join_uri(&id),
format!("ArmaReforger://{SAMPLE_B64}")
);
}
#[test]
fn join_uri_carries_the_same_payload_as_the_steam_form() {
let id = ServerId::parse(SAMPLE_ID).unwrap();
let payload = join_payload_base64(&id);
assert!(reforger_join_uri(&id).ends_with(&payload));
assert!(steam_join_uri(&id).contains(&payload));
}
#[test]
fn join_uri_has_no_trailing_slash() {
// The whole URI is forwarded verbatim as -uri=; a stray separator would
// become part of the payload the game parses.
let uri = reforger_join_uri_for(SAMPLE_ID).unwrap();
assert!(!uri.ends_with('/'), "{uri}");
assert_eq!(uri.matches("//").count(), 1);
}
#[test]
fn steam_join_uri_is_stable_for_a_given_server() {
let id = ServerId::parse(SAMPLE_ID).unwrap();
assert_eq!(steam_join_uri(&id), steam_join_uri_for(SAMPLE_ID).unwrap());
}
#[test]
fn join_uri_has_trailing_slash_and_app_id() {
let uri = steam_join_uri_for(SAMPLE_ID).unwrap();
assert!(uri.starts_with("steam://run/1874880//"));
assert!(uri.ends_with('/'));
}
#[test]
fn plain_launch_uri_carries_no_payload() {
assert_eq!(steam_launch_uri(), "steam://run/1874880");
}
#[test]
fn uppercase_ids_are_normalised() {
let id = ServerId::parse("73E8FB7F-8789-4D88-8AA8-EA69B9AA092F").unwrap();
assert_eq!(id.as_str(), SAMPLE_ID);
assert_eq!(
reforger_join_uri(&id),
reforger_join_uri_for(SAMPLE_ID).unwrap()
);
}
/// A real official server id, from `/v2/servers?official=true`.
const OFFICIAL_ID: &str = "nitrado_17516010";
#[test]
fn official_server_ids_are_accepted() {
// 364 official servers use this shape; rejecting it made them all
// unjoinable.
let id = ServerId::parse(OFFICIAL_ID).unwrap();
assert_eq!(id.as_str(), OFFICIAL_ID);
assert!(id.is_official_form());
assert!(!ServerId::parse(SAMPLE_ID).unwrap().is_official_form());
}
#[test]
fn official_ids_join_through_the_same_payload() {
// The payload shape does not change: only the id inside it does.
let id = ServerId::parse(OFFICIAL_ID).unwrap();
assert_eq!(
join_payload_json(&id),
r#"{"id":"nitrado_17516010","inviteToken":""}"#
);
assert_eq!(
steam_join_uri(&id),
"steam://run/1874880//eyJpZCI6Im5pdHJhZG9fMTc1MTYwMTAiLCJpbnZpdGVUb2tlbiI6IiJ9/"
);
}
#[test]
fn provider_ids_stay_uri_safe() {
// Everything the provider shape admits can sit in a URI unescaped, so
// no id ever needs encoding on the way out.
for raw in ["nitrado_17516010", "a_0", "somehost_123456789012"] {
let id = ServerId::parse(raw).unwrap();
assert!(id
.as_str()
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'));
}
}
#[test]
fn provider_shaped_lookalikes_are_rejected() {
for raw in [
"_123", // no provider
"nitrado_", // no number
"nitrado_17516010x", // trailing junk
"Nitrado_17516010", // uppercase provider
"nitrado-17516010", // wrong separator
"nitrado_175_160", // second underscore
"nitrado_17516010/evil", // path injection
"averyveryverylongprovidername_1",
"nitrado_1234567890123456789012345",
] {
assert!(ServerId::parse(raw).is_err(), "should be rejected: {raw:?}");
}
}
#[test]
fn invalid_ids_are_rejected() {
let rejected = [
"",
"not-a-uuid",
// Wrong group lengths.
"73e8fb7f-8789-4d88-8aa8-ea69b9aa092",
"73e8fb7f-8789-4d88-8aa8-ea69b9aa092ff",
"73e8fb7-88789-4d88-8aa8-ea69b9aa092f",
// Non-hex characters.
"73e8fb7g-8789-4d88-8aa8-ea69b9aa092f",
// Extra group.
"73e8fb7f-8789-4d88-8aa8-ea69b9aa092f-0000",
// Braced / URN forms are not what the API returns.
"{73e8fb7f-8789-4d88-8aa8-ea69b9aa092f}",
"urn:uuid:73e8fb7f-8789-4d88-8aa8-ea69b9aa092f",
// Whitespace must not be silently trimmed.
" 73e8fb7f-8789-4d88-8aa8-ea69b9aa092f",
"73e8fb7f-8789-4d88-8aa8-ea69b9aa092f ",
// Unpadded / no separators.
"73e8fb7f87894d888aa8ea69b9aa092f",
];
for raw in rejected {
assert!(
ServerId::parse(raw).is_err(),
"should have been rejected: {raw:?}"
);
assert!(steam_join_uri_for(raw).is_err(), "{raw:?}");
assert!(reforger_join_uri_for(raw).is_err(), "{raw:?}");
}
}
#[test]
fn injection_attempts_never_reach_a_uri() {
// These are the shapes that would matter if an id were ever pasted into
// a command line or a URI. Validation rejects them before that point.
let hostile = [
"73e8fb7f-8789-4d88-8aa8-ea69b9aa092f/../../evil",
"73e8fb7f-8789-4d88-8aa8-ea69b9aa092f\"; calc.exe",
"73e8fb7f-8789-4d88-8aa8-ea69b9aa092f&&shutdown",
"$(whoami)-8789-4d88-8aa8-ea69b9aa092f",
"73e8fb7f-8789-4d88-8aa8-ea69b9aa09\n\r",
];
for raw in hostile {
assert_eq!(
ServerId::parse(raw),
Err(LaunchError::InvalidServerId {
got: raw.to_owned()
})
);
}
}
#[test]
fn generated_uris_contain_only_url_safe_payload_characters() {
// Standard base64 can emit '+' and '/', which are legal in this Steam
// argument position; assert the payload never contains anything beyond
// that set so the URI is always well-formed.
let id = ServerId::parse(SAMPLE_ID).unwrap();
let payload = join_payload_base64(&id);
assert!(payload
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/'));
}
}
+25
View File
@@ -0,0 +1,25 @@
//! Core logic for the reforgermods.net launcher.
//!
//! This crate deliberately has no Tauri dependency. Everything that can be
//! decided without a window lives here so it can be unit-tested directly:
//! the reforgermods.net API client, Steam launch-URI construction, local
//! persistence, response caching, and local Workshop addon detection.
pub mod api;
pub mod cache;
pub mod identity;
pub mod launch;
pub mod modscan;
pub mod ping;
pub mod presence;
pub mod readiness;
pub mod store;
pub use api::{ApiClient, ApiError};
pub use launch::{reforger_join_uri, steam_join_uri, LaunchError, ServerId};
/// User-Agent the launcher identifies itself with. The launcher is a public,
/// untrusted client: it sends no credentials, only this.
pub fn user_agent() -> String {
format!("reforgermods.net-launcher/{}", env!("CARGO_PKG_VERSION"))
}
+893
View File
@@ -0,0 +1,893 @@
//! Local Arma Reforger Workshop addon detection.
//!
//! # Status
//!
//! This module is the *abstraction plus a verified implementation* for reading
//! what the user already has installed. It is deliberately **not wired into the
//! milestone 1 UI**: the launcher shows no readiness indicator, because the one
//! piece the feature still needs — a supported way to trigger installation of
//! missing mods before joining — has not been verified. See
//! `docs/mod-readiness.md` for the full investigation.
//!
//! # What was verified
//!
//! Arma Reforger keeps downloaded Workshop content in the user's profile
//! directory, one directory per addon, named `<AddonName>_<16-hex-mod-id>`:
//!
//! ```text
//! %USERPROFILE%\Documents\My Games\ArmaReforger\addons\AKI_Core_62CCD69DD17E4F2F\
//! ServerData.json <- id, name, installed revision.version
//! meta <- full Workshop metadata, dependencies, sizes
//! addon.gproj <- GUID + dependency GUIDs
//! data.pak
//! ```
//!
//! `ServerData.json` is the cheapest reliable source: ~250 bytes, present in
//! every addon directory observed (235 of 235), and its `revision.version` is
//! byte-identical to the `version` reforgermods.net reports for the same mod.
//! Both files are written UTF-8 **with a byte-order mark**, which is why parsing
//! strips one before handing the text to serde.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// Directory name of the addon store inside the Reforger profile directory.
const ADDONS_DIR_NAME: &str = "addons";
/// The per-addon file naming the installed revision.
const SERVER_DATA_FILE: &str = "ServerData.json";
/// Development override pointing at an addon store directly.
///
/// Useful when the game's profile is not where this platform would look — for
/// example running the launcher under WSL against a Windows install, or testing
/// against a copied store.
const ADDONS_DIR_ENV: &str = "RFM_ADDONS_DIR";
/// Errors from scanning the local addon store.
#[derive(Debug, thiserror::Error)]
pub enum ScanError {
/// No addon directory was found. Normal when Reforger has never been run,
/// or when the profile lives somewhere non-default.
#[error("no Arma Reforger addons directory found")]
AddonsDirNotFound,
#[error("could not read addons directory {path}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
/// One Workshop addon present on disk.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledAddon {
/// Workshop mod id, upper-case 16 hex characters, matching the API.
pub id: String,
/// Addon name as recorded locally. Untrusted text.
pub name: String,
/// Installed revision, e.g. `8.1.1`. Empty when the file did not name one.
pub version: String,
}
/// How a server's required mod set compares to what is installed.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModReadiness {
/// Installed at exactly the version the server asks for.
pub up_to_date: Vec<String>,
/// Installed, but at a different version.
pub outdated: Vec<String>,
/// Not installed at all.
pub missing: Vec<String>,
/// Sum of `size` for missing mods whose size the API resolved. A floor, not
/// a total: see `unresolved_bytes`.
pub missing_bytes: u64,
/// Missing mods whose download size the API could not resolve. While this is
/// non-zero, `missing_bytes` understates the real download.
pub unresolved_mods: u32,
}
impl ModReadiness {
/// True when every required mod is installed at the required version.
pub fn is_ready(&self) -> bool {
self.outdated.is_empty() && self.missing.is_empty()
}
}
/// A required mod, as reported by a server.
///
/// A narrow view of [`crate::api::models::ServerMod`] so this module does not
/// depend on the API model shape.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequiredMod {
pub id: String,
pub version: String,
/// Download size in bytes when the API resolved it.
pub size: Option<u64>,
}
/// A source of installed-addon information.
///
/// The trait exists so readiness logic can be tested against a fixed inventory,
/// and so a future platform with a different layout can be added without
/// touching callers.
pub trait AddonInventory {
/// Every addon currently installed.
fn installed(&self) -> Result<Vec<InstalledAddon>, ScanError>;
}
/// Reads the addon store from a directory on disk.
#[derive(Debug, Clone)]
pub struct FilesystemInventory {
addons_dir: PathBuf,
}
impl FilesystemInventory {
/// Scans `addons_dir` directly.
pub fn new(addons_dir: impl Into<PathBuf>) -> Self {
Self {
addons_dir: addons_dir.into(),
}
}
/// Locates the addon store for this machine.
///
/// `RFM_ADDONS_DIR` overrides the search when set. Otherwise the profile
/// locations for this platform are probed: the (often redirected)
/// `Documents` directory on Windows, and Reforger's Proton prefix inside
/// each Steam library on Linux. A platform with no known layout finds
/// nothing and returns [`ScanError::AddonsDirNotFound`] — which the UI
/// reports as "could not be checked", never as "nothing missing".
pub fn discover() -> Result<Self, ScanError> {
let explicit = std::env::var_os(ADDONS_DIR_ENV).map(PathBuf::from);
addons_dir_candidates(explicit)
.into_iter()
.find(|p| p.is_dir())
.map(Self::new)
.ok_or(ScanError::AddonsDirNotFound)
}
/// The directory being scanned.
pub fn addons_dir(&self) -> &Path {
&self.addons_dir
}
}
impl AddonInventory for FilesystemInventory {
fn installed(&self) -> Result<Vec<InstalledAddon>, ScanError> {
let entries = std::fs::read_dir(&self.addons_dir).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
ScanError::AddonsDirNotFound
} else {
ScanError::Read {
path: self.addons_dir.clone(),
source,
}
}
})?;
let mut addons = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
// The store also holds stray preview images and a `saves`
// directory; anything without a ServerData.json is skipped below.
continue;
}
let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some(id_from_dir) = addon_id_from_dir_name(dir_name) else {
continue;
};
// Prefer the id the file states; fall back to the directory name.
let addon = std::fs::read_to_string(path.join(SERVER_DATA_FILE))
.ok()
.and_then(|raw| parse_server_data(&raw))
.unwrap_or_else(|| InstalledAddon {
id: id_from_dir,
name: String::new(),
version: String::new(),
});
addons.push(addon);
}
addons.sort_by(|a, b| a.id.cmp(&b.id));
Ok(addons)
}
}
/// Compares a server's required mods against an installed inventory.
pub fn readiness(required: &[RequiredMod], installed: &[InstalledAddon]) -> ModReadiness {
let local: HashMap<String, &str> = installed
.iter()
.map(|a| (a.id.to_ascii_uppercase(), a.version.as_str()))
.collect();
let mut out = ModReadiness::default();
for req in required {
let key = req.id.to_ascii_uppercase();
match local.get(&key) {
Some(version) if *version == req.version => out.up_to_date.push(key),
Some(_) => out.outdated.push(key),
None => {
match req.size {
Some(size) => out.missing_bytes += size,
None => out.unresolved_mods += 1,
}
out.missing.push(key);
}
}
}
out
}
/// Extracts the Workshop id from an addon directory name.
///
/// The id is the trailing `_`-separated group and must be exactly 16 hex
/// characters. Addon names themselves contain `_`, so the split is from the
/// right, and directories that do not match (`saves`, stray files) yield `None`.
pub fn addon_id_from_dir_name(name: &str) -> Option<String> {
let (_, id) = name.rsplit_once('_')?;
(id.len() == 16 && id.bytes().all(|b| b.is_ascii_hexdigit())).then(|| id.to_ascii_uppercase())
}
/// Parses an addon's `ServerData.json`.
///
/// The file is UTF-8 with a BOM; the BOM is stripped before parsing because
/// serde_json rejects it as unexpected input.
pub fn parse_server_data(raw: &str) -> Option<InstalledAddon> {
#[derive(Deserialize)]
struct Revision {
#[serde(default)]
version: String,
}
#[derive(Deserialize)]
struct ServerData {
id: String,
#[serde(default)]
name: String,
#[serde(default)]
revision: Option<Revision>,
}
let cleaned = raw.trim_start_matches('\u{feff}').trim();
let data: ServerData = serde_json::from_str(cleaned).ok()?;
let id = data.id.trim().to_ascii_uppercase();
if id.len() != 16 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
Some(InstalledAddon {
id,
name: data.name,
version: data.revision.map(|r| r.version).unwrap_or_default(),
})
}
/// Candidate addon-store locations, most likely first.
///
/// An `explicit` override short-circuits the search entirely: if the caller
/// named a directory, silently falling back to a different one would be worse
/// than reporting that the named one is not there.
fn addons_dir_candidates(explicit: Option<PathBuf>) -> Vec<PathBuf> {
if let Some(dir) = explicit {
return vec![dir];
}
let profile_suffix = Path::new("My Games").join("ArmaReforger");
documents_roots()
.into_iter()
.map(|root| root.join(&profile_suffix).join(ADDONS_DIR_NAME))
.collect()
}
/// Locations of the Windows `Documents` directory holding the Reforger profile.
///
/// `Documents` is frequently redirected — OneDrive being the common case — so
/// the registry's own answer is asked for first and the well-known layouts are
/// kept as fallbacks rather than assumed.
#[cfg(windows)]
fn documents_roots() -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
// Authoritative: this is where Explorer itself resolves Documents to, and it
// follows a redirection to any path, not only the two guessed below.
if let Some(personal) = documents_from_registry() {
push_unique(&mut roots, personal);
}
if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) {
push_unique(&mut roots, profile.join("Documents"));
push_unique(&mut roots, profile.join("OneDrive").join("Documents"));
}
// OneDrive exports its own root; honour it rather than guessing the folder name.
if let Some(onedrive) = std::env::var_os("OneDrive").map(PathBuf::from) {
push_unique(&mut roots, onedrive.join("Documents"));
}
roots
}
/// Reads the user's real `Documents` location from the shell folder registry.
///
/// `Shell Folders` rather than `User Shell Folders`: the former stores the
/// already-expanded path, so no environment expansion is needed here.
#[cfg(windows)]
fn documents_from_registry() -> Option<PathBuf> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let key = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders")
.ok()?;
let personal: String = key.get_value("Personal").ok()?;
let trimmed = personal.trim();
if trimmed.is_empty() {
return None;
}
Some(PathBuf::from(trimmed))
}
/// Locations of the Reforger profile's `Documents` directory on Linux.
///
/// There is no native Linux client: Reforger runs under Proton, so its
/// "`%USERPROFILE%\Documents`" is a directory inside the Wine prefix Steam keeps
/// per app, under whichever library folder the game was installed into:
///
/// ```text
/// <library>/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents/
/// ```
///
/// Every known Steam root is probed, including the Flatpak one, and each root's
/// `libraryfolders.vdf` is read so a game on a second drive is found too — the
/// prefix lives in the library the game was installed into, which is routinely
/// not the one Steam itself lives in.
#[cfg(target_os = "linux")]
fn documents_roots() -> Vec<PathBuf> {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return Vec::new();
};
documents_roots_in(&home)
}
/// [`documents_roots`] against an explicit home directory.
///
/// Split out so the whole chain — Steam roots, `libraryfolders.vdf`, the prefix
/// layout — can be exercised against a synthetic tree, rather than only its
/// pieces in isolation.
#[cfg(target_os = "linux")]
fn documents_roots_in(home: &Path) -> Vec<PathBuf> {
steam_libraries_in(home)
.iter()
.map(|library| proton_documents_dir(library))
.collect()
}
/// Platforms whose layout has not been verified get no candidates at all, which
/// the UI reports as "could not be checked" — never as "nothing missing".
#[cfg(not(any(windows, target_os = "linux")))]
fn documents_roots() -> Vec<PathBuf> {
Vec::new()
}
/// The Proton prefix's `Documents` directory for Reforger inside `library`.
#[cfg(target_os = "linux")]
fn proton_documents_dir(library: &Path) -> PathBuf {
library
.join("steamapps")
.join("compatdata")
.join(crate::launch::ARMA_REFORGER_APP_ID.to_string())
.join("pfx")
.join("drive_c")
.join("users")
// Proton always names the prefix user `steamuser`, regardless of the
// real account name.
.join("steamuser")
.join("Documents")
}
/// Every Steam library folder on this machine, most likely first.
#[cfg(target_os = "linux")]
fn steam_libraries_in(home: &Path) -> Vec<PathBuf> {
let mut libraries: Vec<PathBuf> = Vec::new();
for root in steam_roots_in(home) {
// The Steam root is itself a library.
push_unique(&mut libraries, root.clone());
let vdf = root.join("steamapps").join("libraryfolders.vdf");
let Ok(raw) = std::fs::read_to_string(&vdf) else {
continue;
};
for extra in library_paths_from_vdf(&raw) {
push_unique(&mut libraries, extra);
}
}
libraries
}
/// Well-known Steam installation roots under `home`.
///
/// `.steam/steam` and `.steam/root` are usually symlinks into one of the others;
/// they are probed anyway because they are the stable names, and duplicates are
/// dropped by path rather than by target.
#[cfg(target_os = "linux")]
fn steam_roots_in(home: &Path) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
for relative in [
".steam/steam",
".steam/root",
".local/share/Steam",
// Flatpak Steam keeps its own home directory.
".var/app/com.valvesoftware.Steam/.local/share/Steam",
] {
push_unique(&mut roots, home.join(relative));
}
roots
}
/// Extracts library paths from `libraryfolders.vdf`.
///
/// Deliberately not a VDF parser. The file is Valve's KeyValues format and the
/// only thing needed from it is the `"path"` of each entry, so this reads those
/// lines and ignores the rest; a malformed or reorganised file yields fewer
/// candidates rather than an error.
#[cfg(target_os = "linux")]
fn library_paths_from_vdf(raw: &str) -> Vec<PathBuf> {
let mut paths = Vec::new();
for line in raw.lines() {
// `"path"\t\t"/mnt/games/SteamLibrary"`
let Some(rest) = line.trim().strip_prefix("\"path\"") else {
continue;
};
let mut fields = rest.split('"');
let _separator = fields.next();
let Some(value) = fields.next() else {
continue;
};
if value.is_empty() {
continue;
}
// KeyValues escapes backslashes; Windows-style paths appear in the file
// on Windows installs and are harmless to unescape everywhere.
paths.push(PathBuf::from(value.replace("\\\\", "\\")));
}
paths
}
/// Appends `path` unless it is already present, preserving search order.
#[cfg(any(windows, target_os = "linux"))]
fn push_unique(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.contains(&path) {
paths.push(path);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A real `ServerData.json`, BOM included, from
/// `addons/AKI_Core_62CCD69DD17E4F2F`.
const REAL_SERVER_DATA: &str = "\u{feff}{\n \"id\": \"62CCD69DD17E4F2F\",\n \"name\": \"AKI_Core\",\n \"revision\": {\"version\":\"8.1.1\",\"gameVersion\":\"\",\"changelog\":\"\",\"corrupted\":false,\"dependencies\":[{\"assetId\":\"629B2BA37EFFD577\",\"assetName\":\"WCS_Armaments\",\"version\":\"8.1.2\"}],\"scenarios\":[],\"env\":7\n }\n}";
fn addon(id: &str, version: &str) -> InstalledAddon {
InstalledAddon {
id: id.into(),
name: "n".into(),
version: version.into(),
}
}
fn required(id: &str, version: &str, size: Option<u64>) -> RequiredMod {
RequiredMod {
id: id.into(),
version: version.into(),
size,
}
}
#[test]
fn parses_a_real_server_data_file_including_its_bom() {
let a = parse_server_data(REAL_SERVER_DATA).unwrap();
assert_eq!(a.id, "62CCD69DD17E4F2F");
assert_eq!(a.name, "AKI_Core");
// This is the exact version api.reforgermods.net reports for this mod.
assert_eq!(a.version, "8.1.1");
}
#[test]
fn parses_without_a_bom_too() {
let a = parse_server_data(REAL_SERVER_DATA.trim_start_matches('\u{feff}')).unwrap();
assert_eq!(a.version, "8.1.1");
}
#[test]
fn lowercase_ids_are_normalised_upwards() {
let a =
parse_server_data(r#"{"id":"62ccd69dd17e4f2f","revision":{"version":"1.0"}}"#).unwrap();
assert_eq!(a.id, "62CCD69DD17E4F2F");
}
#[test]
fn missing_revision_yields_an_empty_version_not_a_failure() {
let a = parse_server_data(r#"{"id":"62CCD69DD17E4F2F","name":"x"}"#).unwrap();
assert_eq!(a.version, "");
}
#[test]
fn malformed_server_data_is_rejected() {
for raw in [
"",
"not json",
r#"{"name":"no id"}"#,
// Wrong id length.
r#"{"id":"ABC"}"#,
r#"{"id":"62CCD69DD17E4F2FF"}"#,
// Non-hex.
r#"{"id":"ZZCCD69DD17E4F2F"}"#,
] {
assert!(parse_server_data(raw).is_none(), "{raw:?}");
}
}
#[test]
fn addon_id_is_read_from_the_directory_name() {
assert_eq!(
addon_id_from_dir_name("AKI_Core_62CCD69DD17E4F2F").as_deref(),
Some("62CCD69DD17E4F2F")
);
// Names contain underscores, so the split must be from the right.
assert_eq!(
addon_id_from_dir_name("B59-Shotguns-BETA_658B2B37C2C82AAE").as_deref(),
Some("658B2B37C2C82AAE")
);
assert_eq!(
addon_id_from_dir_name("ArmaverseFactionsReforged_6942069420694200").as_deref(),
Some("6942069420694200")
);
}
#[test]
fn non_addon_directory_names_are_skipped() {
// These are real entries that sit alongside addons in the store.
for name in [
"saves",
"scenario0_576x324.jpg",
"NoIdHere",
"Short_ABC",
"Bad_ZZZZZZZZZZZZZZZZ",
"_",
"",
] {
assert!(addon_id_from_dir_name(name).is_none(), "{name:?}");
}
}
#[test]
fn readiness_reports_a_fully_installed_server_as_ready() {
let installed = vec![
addon("AAAAAAAAAAAAAAAA", "1.0"),
addon("BBBBBBBBBBBBBBBB", "2.0"),
];
let required = vec![
required("AAAAAAAAAAAAAAAA", "1.0", Some(10)),
required("BBBBBBBBBBBBBBBB", "2.0", Some(20)),
];
let r = readiness(&required, &installed);
assert!(r.is_ready());
assert_eq!(r.up_to_date.len(), 2);
assert_eq!(r.missing_bytes, 0);
}
#[test]
fn readiness_separates_outdated_from_missing() {
let installed = vec![
addon("AAAAAAAAAAAAAAAA", "1.0"),
addon("BBBBBBBBBBBBBBBB", "1.9"),
];
let required = vec![
required("AAAAAAAAAAAAAAAA", "1.0", Some(10)),
required("BBBBBBBBBBBBBBBB", "2.0", Some(20)),
required("CCCCCCCCCCCCCCCC", "3.0", Some(300)),
];
let r = readiness(&required, &installed);
assert!(!r.is_ready());
assert_eq!(r.up_to_date, vec!["AAAAAAAAAAAAAAAA"]);
assert_eq!(r.outdated, vec!["BBBBBBBBBBBBBBBB"]);
assert_eq!(r.missing, vec!["CCCCCCCCCCCCCCCC"]);
// Only missing mods need downloading; an outdated mod's delta is unknown.
assert_eq!(r.missing_bytes, 300);
}
#[test]
fn readiness_matching_is_case_insensitive_on_ids() {
let installed = vec![addon("62ccd69dd17e4f2f", "8.1.1")];
let required = vec![required("62CCD69DD17E4F2F", "8.1.1", None)];
assert!(readiness(&required, &installed).is_ready());
}
#[test]
fn unresolved_sizes_are_counted_separately_from_bytes() {
let required = vec![
required("AAAAAAAAAAAAAAAA", "1.0", None),
required("BBBBBBBBBBBBBBBB", "1.0", Some(500)),
];
let r = readiness(&required, &[]);
assert_eq!(r.missing.len(), 2);
assert_eq!(r.missing_bytes, 500);
// The caller must be able to say "at least 500 bytes, plus 1 unknown".
assert_eq!(r.unresolved_mods, 1);
}
#[test]
fn a_vanilla_server_is_ready_against_an_empty_inventory() {
let r = readiness(&[], &[]);
assert!(r.is_ready());
assert_eq!(r.missing_bytes, 0);
}
#[test]
fn extra_installed_mods_do_not_affect_readiness() {
let installed = vec![addon("AAAAAAAAAAAAAAAA", "1.0"), addon("ZZZZ", "9")];
let required = vec![required("AAAAAAAAAAAAAAAA", "1.0", None)];
assert!(readiness(&required, &installed).is_ready());
}
#[test]
fn scanning_a_synthetic_store_finds_addons_and_ignores_noise() {
let root = std::env::temp_dir().join(format!("rfm-modscan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let addons = root.join("addons");
for (dir, body) in [
(
"AKI_Core_62CCD69DD17E4F2F",
Some(REAL_SERVER_DATA.to_string()),
),
(
"Other_Mod_AAAAAAAAAAAAAAAA",
Some(
r#"{"id":"AAAAAAAAAAAAAAAA","name":"Other","revision":{"version":"2.5"}}"#
.into(),
),
),
// Valid directory name but no ServerData.json: still counted, since
// the id is recoverable, but with no version.
("Broken_BBBBBBBBBBBBBBBB", None),
// Not an addon directory at all.
("saves", None),
] {
let path = addons.join(dir);
std::fs::create_dir_all(&path).unwrap();
if let Some(body) = body {
std::fs::write(path.join(SERVER_DATA_FILE), body).unwrap();
}
}
// A stray file alongside the directories.
std::fs::write(addons.join("scenario0_576x324.jpg"), b"not a dir").unwrap();
let found = FilesystemInventory::new(&addons).installed().unwrap();
let ids: Vec<&str> = found.iter().map(|a| a.id.as_str()).collect();
// Sorted by id: ASCII digits precede letters.
assert_eq!(
ids,
vec!["62CCD69DD17E4F2F", "AAAAAAAAAAAAAAAA", "BBBBBBBBBBBBBBBB"]
);
let aki = found.iter().find(|a| a.id == "62CCD69DD17E4F2F").unwrap();
assert_eq!(aki.version, "8.1.1");
let broken = found.iter().find(|a| a.id == "BBBBBBBBBBBBBBBB").unwrap();
assert_eq!(broken.version, "");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn an_explicit_directory_short_circuits_the_search() {
let candidates = addons_dir_candidates(Some(PathBuf::from("/somewhere/addons")));
assert_eq!(candidates, vec![PathBuf::from("/somewhere/addons")]);
}
#[test]
fn without_an_override_only_profile_locations_are_probed() {
for candidate in addons_dir_candidates(None) {
// Every default candidate ends at the addon store, never at a
// parent the scanner would misread.
assert!(candidate.ends_with(ADDONS_DIR_NAME), "{candidate:?}");
assert!(candidate.to_string_lossy().contains("ArmaReforger"));
}
}
#[cfg(target_os = "linux")]
mod linux_discovery {
use super::super::*;
/// A real `libraryfolders.vdf`, trimmed: two libraries, one of them on a
/// second drive, with the surrounding keys the file actually carries.
const VDF: &str = r#"
"libraryfolders"
{
"0"
{
"path" "/home/madison/.local/share/Steam"
"label" ""
"contentid" "1234567890123456789"
"totalsize" "0"
"apps"
{
"1874880" "27917287424"
}
}
"1"
{
"path" "/mnt/games/SteamLibrary"
"label" "games"
"apps"
{
"221100" "29249693425"
}
}
}
"#;
#[test]
fn every_library_path_is_read_from_the_vdf() {
assert_eq!(
library_paths_from_vdf(VDF),
vec![
PathBuf::from("/home/madison/.local/share/Steam"),
PathBuf::from("/mnt/games/SteamLibrary"),
]
);
}
#[test]
fn non_path_keys_are_ignored_rather_than_misread() {
// "label" and the app-size entries are the lines most likely to be
// mistaken for a path by a loose parser.
assert!(library_paths_from_vdf("\t\"label\"\t\t\"games\"").is_empty());
assert!(library_paths_from_vdf("\t\t\"1874880\"\t\t\"27917287424\"").is_empty());
assert!(library_paths_from_vdf("nonsense").is_empty());
assert!(library_paths_from_vdf("").is_empty());
}
#[test]
fn a_library_yields_the_proton_prefix_documents_directory() {
let documents = proton_documents_dir(Path::new("/mnt/games/SteamLibrary"));
let expected: PathBuf = [
"/mnt/games/SteamLibrary",
"steamapps",
"compatdata",
"1874880",
"pfx",
"drive_c",
"users",
"steamuser",
"Documents",
]
.iter()
.collect();
assert_eq!(documents, expected);
}
#[test]
fn candidates_point_inside_a_proton_prefix() {
// HOME is set in any normal environment, including the build
// container; without it there is nothing to probe and that is fine.
if std::env::var_os("HOME").is_none() {
return;
}
let candidates = addons_dir_candidates(None);
assert!(!candidates.is_empty());
for candidate in &candidates {
let shown = candidate.to_string_lossy();
assert!(shown.contains("compatdata/1874880"), "{shown}");
assert!(shown.contains("drive_c/users/steamuser"), "{shown}");
assert!(shown.ends_with("My Games/ArmaReforger/addons"), "{shown}");
}
}
#[test]
fn the_flatpak_steam_root_is_probed_too() {
let roots = steam_roots_in(Path::new("/home/madison"));
assert!(
roots
.iter()
.any(|r| r.to_string_lossy().contains("com.valvesoftware.Steam")),
"{roots:?}"
);
// The stable symlink names and the real directory are distinct
// paths, so all of them are probed.
assert!(roots.len() >= 4, "{roots:?}");
}
/// The whole Linux chain against a synthetic install.
///
/// The unit tests above each cover one link — the vdf, the prefix
/// layout, the scanner. This one builds a Steam tree with the game on a
/// *second* library, which is the case that actually breaks a naive
/// implementation: the prefix lives in the library the game was
/// installed into, not the one Steam itself lives in.
#[test]
fn a_game_on_a_second_library_drive_is_found_and_scanned() {
let root = std::env::temp_dir().join(format!(
"rfm-proton-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let home = root.join("home/madison");
let steam = home.join(".local/share/Steam");
let second_library = root.join("mnt/games/SteamLibrary");
// Steam's own library, holding only the index that names the other.
std::fs::create_dir_all(steam.join("steamapps")).unwrap();
std::fs::write(
steam.join("steamapps/libraryfolders.vdf"),
format!(
"\"libraryfolders\"\n{{\n\t\"0\"\n\t{{\n\t\t\"path\"\t\t\"{}\"\n\t}}\n\
\t\"1\"\n\t{{\n\t\t\"path\"\t\t\"{}\"\n\t}}\n}}\n",
steam.display(),
second_library.display()
),
)
.unwrap();
// The addon store, inside the Proton prefix on the second library.
let addons = proton_documents_dir(&second_library)
.join("My Games")
.join("ArmaReforger")
.join(ADDONS_DIR_NAME);
let addon = addons.join("AKI_Core_62CCD69DD17E4F2F");
std::fs::create_dir_all(&addon).unwrap();
// Written with the BOM the game really writes.
std::fs::write(
addon.join(SERVER_DATA_FILE),
"\u{feff}{\"id\":\"62CCD69DD17E4F2F\",\"name\":\"AKI_Core\",\
\"revision\":{\"version\":\"8.1.1\"}}",
)
.unwrap();
// Discovery, exactly as discover() performs it.
let found = documents_roots_in(&home)
.into_iter()
.map(|documents| {
documents
.join("My Games")
.join("ArmaReforger")
.join(ADDONS_DIR_NAME)
})
.find(|candidate| candidate.is_dir());
assert_eq!(
found.as_deref(),
Some(addons.as_path()),
"store not located"
);
// And the scan of what discovery found.
let installed = FilesystemInventory::new(found.unwrap())
.installed()
.unwrap();
assert_eq!(
installed,
vec![InstalledAddon {
id: "62CCD69DD17E4F2F".into(),
name: "AKI_Core".into(),
version: "8.1.1".into(),
}]
);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn duplicate_library_paths_are_probed_once() {
let mut paths = vec![PathBuf::from("/a")];
push_unique(&mut paths, PathBuf::from("/a"));
push_unique(&mut paths, PathBuf::from("/b"));
assert_eq!(paths, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
}
}
#[test]
fn a_missing_store_reports_not_found_rather_than_an_io_error() {
let missing = std::env::temp_dir().join("rfm-definitely-not-here-4a7f");
let err = FilesystemInventory::new(&missing).installed().unwrap_err();
assert!(matches!(err, ScanError::AddonsDirNotFound), "{err:?}");
}
}
+569
View File
@@ -0,0 +1,569 @@
//! Direct ICMP latency measurement to game servers.
//!
//! A browser cannot do this; a native launcher can, which is one of the reasons
//! for building one. Measured against 20 live servers: `host` is present on
//! roughly two thirds of servers, is always a literal IP address, and 65% of
//! those answer ICMP echo with region-coherent latencies.
//!
//! # Why not TCP connect timing
//!
//! It was tried and it lies. Connecting to a Reforger server's UDP game port
//! "succeeded" in 12–18 ms — as did port 9 and port 47123, which are closed.
//! Some middlebox completes every handshake, so TCP timing produces
//! confident-looking numbers that are pure fiction. ICMP to the same hosts
//! returned 68–183 ms, coherent with their regions. Only ICMP is used.
//!
//! # Privileges
//!
//! Windows uses `IcmpSendEcho` through the IP Helper API, which needs no
//! elevation. Unix uses an unprivileged ICMP datagram socket, which works when
//! `net.ipv4.ping_group_range` covers the user's group — the default on current
//! distributions — and otherwise needs `CAP_NET_RAW`. A failure to send is
//! reported as "no reply", never as a fabricated number.
use std::net::IpAddr;
use std::time::Duration;
use serde::{Deserialize, Serialize};
/// Echo requests sent per measurement.
pub const DEFAULT_ATTEMPTS: usize = 3;
/// Per-request deadline.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(1500);
/// The outcome of measuring one host.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PingSummary {
/// Fastest round trip observed, milliseconds.
pub best_ms: u32,
/// Median round trip, milliseconds. Preferred for display: one delayed
/// packet should not change the figure the user reads.
pub median_ms: u32,
/// Replies received.
pub received: u32,
/// Requests sent.
pub sent: u32,
}
impl PingSummary {
/// Percentage of requests that went unanswered, 0–100.
pub fn loss_percent(&self) -> u32 {
if self.sent == 0 {
return 0;
}
((self.sent - self.received) * 100) / self.sent
}
}
/// Why a host could not be measured.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PingUnavailable {
/// The API did not report a host for this server.
NoHost,
/// The reported host was not a public literal IP address.
UnroutableHost,
/// Every echo request went unanswered. Common: many hosts filter ICMP.
NoReply,
/// The platform refused to send. On Unix this usually means ICMP sockets
/// are not permitted for this user.
NotPermitted,
}
impl PingUnavailable {
/// Display text, written to be shown as-is.
pub fn message(self) -> &'static str {
match self {
PingUnavailable::NoHost => "This server does not publish an address to measure.",
PingUnavailable::UnroutableHost => {
"This server's published address cannot be measured."
}
PingUnavailable::NoReply => "No reply — this server does not answer ping.",
PingUnavailable::NotPermitted => {
"This system does not permit the launcher to send ping requests."
}
}
}
}
/// A measurement result for the UI: a figure, or a stated reason there is none.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PingResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<PingSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unavailable: Option<PingUnavailable>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl PingResult {
/// A successful measurement.
pub fn measured(summary: PingSummary) -> Self {
Self {
summary: Some(summary),
unavailable: None,
message: None,
}
}
/// A stated absence of a measurement.
pub fn unavailable(reason: PingUnavailable) -> Self {
Self {
summary: None,
unavailable: Some(reason),
message: Some(reason.message().to_string()),
}
}
}
/// Resolves a ping-site address into a pingable public IP.
///
/// Ping sites are published as hostnames (`ping-location-de.nitrado.net`),
/// unlike servers, which publish literal IPs. Resolving remote-supplied names
/// is a wider door than [`parse_target`] allows, so the result is put through
/// the same public-address check: a name that resolves into loopback, private
/// or link-local space is refused exactly as a literal would be.
pub async fn resolve_public(address: &str) -> Result<IpAddr, PingUnavailable> {
let address = address.trim();
if address.is_empty() {
return Err(PingUnavailable::NoHost);
}
// A literal needs no lookup.
if let Ok(ip) = address.parse::<IpAddr>() {
return if is_public(&ip) {
Ok(ip)
} else {
Err(PingUnavailable::UnroutableHost)
};
}
// Reject anything that is not a plain hostname before it reaches a resolver.
if !address
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
{
return Err(PingUnavailable::UnroutableHost);
}
// `lookup_host` wants a port; 0 is fine, nothing connects.
let resolved = tokio::net::lookup_host((address, 0))
.await
.map_err(|_| PingUnavailable::UnroutableHost)?;
resolved
.map(|addr| addr.ip())
.find(is_public)
.ok_or(PingUnavailable::UnroutableHost)
}
/// Measures a ping-site address, resolving it first.
pub async fn measure_address(address: &str, attempts: usize, timeout: Duration) -> PingResult {
let ip = match resolve_public(address).await {
Ok(ip) => ip,
Err(reason) => return PingResult::unavailable(reason),
};
match platform::probe(ip, attempts.max(1), timeout).await {
Ok(samples) => match summarise(&samples) {
Some(summary) => PingResult::measured(summary),
None => PingResult::unavailable(PingUnavailable::NoReply),
},
Err(reason) => PingResult::unavailable(reason),
}
}
/// Validates a host string from the API into something safe to probe.
///
/// This is the security boundary for pinging. API-supplied text must not be
/// able to make the launcher emit packets anywhere it likes, so:
///
/// - Only literal IP addresses are accepted. A hostname would mean a DNS lookup
/// chosen by remote data; every observed server publishes a literal IP anyway
/// (20 of 20 sampled).
/// - Loopback, private, link-local, multicast, broadcast and unspecified
/// addresses are refused, so a hostile or broken record cannot point the
/// launcher at the user's own machine or LAN.
pub fn parse_target(host: Option<&str>) -> Result<IpAddr, PingUnavailable> {
let host = host.map(str::trim).filter(|h| !h.is_empty());
let Some(host) = host else {
return Err(PingUnavailable::NoHost);
};
let ip: IpAddr = host.parse().map_err(|_| PingUnavailable::UnroutableHost)?;
if is_public(&ip) {
Ok(ip)
} else {
Err(PingUnavailable::UnroutableHost)
}
}
/// True for addresses it is reasonable to send an echo request to.
fn is_public(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
!(v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_broadcast()
|| v4.is_unspecified()
|| v4.is_documentation()
// 100.64.0.0/10, carrier-grade NAT.
|| (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
// 0.0.0.0/8 and 240.0.0.0/4.
|| v4.octets()[0] == 0
|| v4.octets()[0] >= 240)
}
IpAddr::V6(v6) => {
!(v6.is_loopback()
|| v6.is_multicast()
|| v6.is_unspecified()
// fe80::/10 link-local and fc00::/7 unique-local.
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| (v6.segments()[0] & 0xfe00) == 0xfc00)
}
}
}
/// Reduces raw samples to the figure shown to the user.
///
/// `None` entries are unanswered requests. Returns `None` when nothing came
/// back at all, so the caller states "no reply" instead of rendering a zero.
pub fn summarise(samples: &[Option<Duration>]) -> Option<PingSummary> {
let mut received: Vec<u32> = samples
.iter()
.flatten()
.map(|d| {
// Round to the nearest millisecond, with a floor of 1: a sub-
// millisecond reply is real, and "0 ms" reads as a bug.
let ms = d.as_secs_f64() * 1000.0;
(ms.round() as u32).max(1)
})
.collect();
if received.is_empty() {
return None;
}
received.sort_unstable();
let median = if received.len() % 2 == 1 {
received[received.len() / 2]
} else {
let hi = received.len() / 2;
// Even counts average the middle pair. Written as an offset from the
// lower value rather than `(a + b) / 2` so a large pair cannot overflow,
// and without `u32::midpoint`, which postdates this crate's MSRV.
received[hi - 1] + (received[hi] - received[hi - 1]) / 2
};
Some(PingSummary {
best_ms: received[0],
median_ms: median,
received: received.len() as u32,
sent: samples.len() as u32,
})
}
/// Measures `host`, returning either a figure or a stated reason there is none.
pub async fn measure(host: Option<&str>, attempts: usize, timeout: Duration) -> PingResult {
let ip = match parse_target(host) {
Ok(ip) => ip,
Err(reason) => return PingResult::unavailable(reason),
};
match platform::probe(ip, attempts.max(1), timeout).await {
Ok(samples) => match summarise(&samples) {
Some(summary) => PingResult::measured(summary),
None => PingResult::unavailable(PingUnavailable::NoReply),
},
Err(reason) => PingResult::unavailable(reason),
}
}
#[cfg(unix)]
mod platform {
use super::*;
/// Unprivileged ICMP datagram socket. Requires `net.ipv4.ping_group_range`
/// to cover the user's group, which is the default on current distributions.
pub async fn probe(
ip: IpAddr,
attempts: usize,
timeout: Duration,
) -> Result<Vec<Option<Duration>>, PingUnavailable> {
use surge_ping::{Client, Config, PingIdentifier, PingSequence, ICMP};
let config = match ip {
IpAddr::V4(_) => Config::default(),
IpAddr::V6(_) => Config::builder().kind(ICMP::V6).build(),
};
let client = Client::new(&config).map_err(|_| PingUnavailable::NotPermitted)?;
// The identifier only has to be unique among this process's in-flight
// pings; the low bits of the clock are sufficient and cheap.
let identifier = PingIdentifier(std::process::id() as u16);
let mut pinger = client.pinger(ip, identifier).await;
pinger.timeout(timeout);
let payload = [0u8; 32];
let mut samples = Vec::with_capacity(attempts);
for seq in 0..attempts {
let reply = pinger.ping(PingSequence(seq as u16), &payload).await;
samples.push(reply.ok().map(|(_, rtt)| rtt));
}
Ok(samples)
}
}
#[cfg(windows)]
mod platform {
use super::*;
/// `IcmpSendEcho` through the IP Helper API, which needs no elevation.
/// It is blocking, so it runs on the blocking pool.
pub async fn probe(
ip: IpAddr,
attempts: usize,
timeout: Duration,
) -> Result<Vec<Option<Duration>>, PingUnavailable> {
let timeout_ms = timeout.as_millis().min(u32::MAX as u128) as u32;
tokio::task::spawn_blocking(move || {
let mut pinger = winping::Pinger::new().map_err(|_| PingUnavailable::NotPermitted)?;
pinger.set_timeout(timeout_ms);
let mut buffer = winping::Buffer::new();
let mut samples = Vec::with_capacity(attempts);
for _ in 0..attempts {
// `send` reports the round trip in whole milliseconds; a
// timeout or filtered host comes back as an error, which is a
// missing sample rather than a failure of the measurement.
let rtt = pinger
.send(ip, &mut buffer)
.ok()
.map(|ms| Duration::from_millis(u64::from(ms)));
samples.push(rtt);
}
Ok(samples)
})
.await
.map_err(|_| PingUnavailable::NotPermitted)?
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ms(value: u64) -> Option<Duration> {
Some(Duration::from_millis(value))
}
#[test]
fn real_server_addresses_are_accepted() {
// Literal IPs observed on live servers.
for host in ["208.92.232.141", "103.193.80.16", "69.67.175.16"] {
assert!(parse_target(Some(host)).is_ok(), "{host}");
}
}
#[test]
fn a_missing_host_is_distinguished_from_a_bad_one() {
assert_eq!(parse_target(None), Err(PingUnavailable::NoHost));
assert_eq!(parse_target(Some("")), Err(PingUnavailable::NoHost));
assert_eq!(parse_target(Some(" ")), Err(PingUnavailable::NoHost));
}
#[test]
fn hostnames_are_refused_so_api_data_cannot_drive_dns() {
for host in [
"example.com",
"localhost",
"evil.attacker.test",
"ping-location-de.nitrado.net",
] {
assert_eq!(
parse_target(Some(host)),
Err(PingUnavailable::UnroutableHost),
"{host}"
);
}
}
#[test]
fn private_and_loopback_addresses_are_refused() {
// A hostile or broken record must not aim the launcher at the user's
// own machine or LAN.
for host in [
"127.0.0.1",
"0.0.0.0",
"10.0.0.1",
"172.16.4.9",
"192.168.1.1",
"169.254.1.1",
"224.0.0.1",
"255.255.255.255",
"100.64.0.1",
"240.0.0.1",
"::1",
"::",
"fe80::1",
"fd00::1",
"ff02::1",
] {
assert_eq!(
parse_target(Some(host)),
Err(PingUnavailable::UnroutableHost),
"{host} must be refused"
);
}
}
#[test]
fn public_ipv6_is_accepted() {
assert!(parse_target(Some("2606:4700:4700::1111")).is_ok());
}
#[test]
fn garbage_hosts_are_refused() {
for host in [
"208.92.232.141; rm -rf /",
"208.92.232.141:2001",
"not an ip",
"999.999.999.999",
"../../etc/passwd",
] {
assert_eq!(
parse_target(Some(host)),
Err(PingUnavailable::UnroutableHost),
"{host}"
);
}
}
#[test]
fn summarise_reports_best_and_median() {
let summary = summarise(&[ms(80), ms(70), ms(120)]).unwrap();
assert_eq!(summary.best_ms, 70);
assert_eq!(summary.median_ms, 80);
assert_eq!(summary.received, 3);
assert_eq!(summary.sent, 3);
assert_eq!(summary.loss_percent(), 0);
}
#[test]
fn the_median_resists_one_delayed_packet() {
// A single 900 ms outlier must not become the displayed figure.
let summary = summarise(&[ms(70), ms(72), ms(900)]).unwrap();
assert_eq!(summary.median_ms, 72);
assert_eq!(summary.best_ms, 70);
}
#[test]
fn partial_loss_is_summarised_not_discarded() {
let summary = summarise(&[ms(80), None, ms(100)]).unwrap();
assert_eq!(summary.received, 2);
assert_eq!(summary.sent, 3);
assert_eq!(summary.loss_percent(), 33);
assert_eq!(summary.best_ms, 80);
}
#[test]
fn total_loss_yields_no_figure_rather_than_zero() {
assert!(summarise(&[None, None, None]).is_none());
assert!(summarise(&[]).is_none());
}
#[test]
fn even_sample_counts_average_the_middle_pair() {
let summary = summarise(&[ms(70), ms(80), ms(90), ms(100)]).unwrap();
assert_eq!(summary.median_ms, 85);
}
#[test]
fn sub_millisecond_replies_floor_at_one() {
// A LAN-speed reply is real; "0 ms" reads as a broken measurement.
let summary = summarise(&[Some(Duration::from_micros(200))]).unwrap();
assert_eq!(summary.best_ms, 1);
assert_eq!(summary.median_ms, 1);
}
#[tokio::test]
async fn an_unmeasurable_host_states_why_without_sending_anything() {
let result = measure(None, 3, Duration::from_millis(50)).await;
assert_eq!(result.unavailable, Some(PingUnavailable::NoHost));
assert!(result.summary.is_none());
assert!(result.message.is_some());
let result = measure(Some("192.168.0.1"), 3, Duration::from_millis(50)).await;
assert_eq!(result.unavailable, Some(PingUnavailable::UnroutableHost));
}
#[tokio::test]
async fn resolve_public_accepts_a_literal_without_a_lookup() {
assert_eq!(
resolve_public("208.92.232.141").await,
Ok("208.92.232.141".parse().unwrap())
);
}
#[tokio::test]
async fn resolve_public_refuses_names_that_are_not_hostnames() {
// Nothing resembling a URL, path or command reaches the resolver.
for address in [
"",
" ",
"http://example.com",
"example.com/path",
"example.com:80",
"a b",
"host;rm -rf /",
"host|cat",
] {
assert!(resolve_public(address).await.is_err(), "{address:?}");
}
}
#[tokio::test]
async fn resolve_public_refuses_private_literals() {
for address in ["127.0.0.1", "192.168.1.1", "::1", "10.0.0.5"] {
assert_eq!(
resolve_public(address).await,
Err(PingUnavailable::UnroutableHost),
"{address}"
);
}
}
#[tokio::test]
async fn measure_address_states_why_it_cannot_measure() {
let result = measure_address("", 1, Duration::from_millis(50)).await;
assert_eq!(result.unavailable, Some(PingUnavailable::NoHost));
let result = measure_address("127.0.0.1", 1, Duration::from_millis(50)).await;
assert_eq!(result.unavailable, Some(PingUnavailable::UnroutableHost));
}
#[test]
fn every_unavailable_reason_has_distinct_copy() {
let reasons = [
PingUnavailable::NoHost,
PingUnavailable::UnroutableHost,
PingUnavailable::NoReply,
PingUnavailable::NotPermitted,
];
let messages: std::collections::HashSet<&str> =
reasons.iter().map(|r| r.message()).collect();
assert_eq!(messages.len(), reasons.len());
}
#[test]
fn results_serialise_without_null_noise() {
let json = serde_json::to_value(PingResult::measured(PingSummary {
best_ms: 70,
median_ms: 72,
received: 3,
sent: 3,
}))
.unwrap();
assert_eq!(json["summary"]["medianMs"], 72);
assert!(json.get("unavailable").is_none());
let json = serde_json::to_value(PingResult::unavailable(PingUnavailable::NoReply)).unwrap();
assert_eq!(json["unavailable"], "no_reply");
assert!(json.get("summary").is_none());
}
}
+267
View File
@@ -0,0 +1,267 @@
//! Discord Rich Presence payloads.
//!
//! What a Discord friend sees while the launcher is open — the promotional
//! surface. The transport lives in the app layer; everything here is pure text
//! assembly, which is the part with rules worth testing:
//!
//! - **Server names are untrusted.** They arrive from the API, routinely carry
//! Discord invites and decorative glyphs, and are sometimes very long. Control
//! characters are stripped and whitespace collapsed before anything is sent.
//! - **Discord silently drops fields that break its limits.** `details` and
//! `state` cap at 128 characters, and a field shorter than two characters is
//! ignored rather than rejected, so a one-character server name must not
//! produce an invisible line.
//! - **Truncation is by character, never by byte.** Cutting a multi-byte glyph
//! in half produces invalid UTF-8 that Discord rejects outright.
use serde::{Deserialize, Serialize};
/// Discord's limit for `details` and `state`.
const FIELD_LIMIT: usize = 128;
/// Below this, Discord ignores the field entirely.
const FIELD_MINIMUM: usize = 2;
/// One presence update.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Presence {
/// Top line.
pub details: String,
/// Second line.
pub state: String,
/// Tooltip on the large artwork.
pub large_text: String,
}
/// What the launcher is currently doing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Activity<'a> {
/// Browsing the server list.
Browsing { online_servers: u64 },
/// A server is selected in the inspector.
Viewing {
server_name: &'a str,
players: u32,
max_players: u32,
},
/// A join was handed off to Steam.
Joining { server_name: &'a str },
}
impl Activity<'_> {
/// Builds the presence for this activity, within Discord's limits.
pub fn to_presence(&self) -> Presence {
let (details, state) = match self {
Activity::Browsing { online_servers } => (
"Browsing Arma Reforger servers".to_string(),
if *online_servers > 0 {
format!("{online_servers} servers online")
} else {
"reforgermods.net".to_string()
},
),
Activity::Viewing {
server_name,
players,
max_players,
} => (
format!("Viewing {}", clean(server_name)),
format!("{players}/{max_players} players"),
),
Activity::Joining { server_name } => (
format!("Joining {}", clean(server_name)),
"via reforgermods.net launcher".to_string(),
),
};
Presence {
details: field(&details),
state: field(&state),
large_text: "reforgermods.net launcher".to_string(),
}
}
}
/// Collapses whitespace and drops control characters from untrusted text.
///
/// Keeps everything else, including emoji and non-Latin scripts: server names
/// legitimately use them, and Discord renders them fine.
fn clean(raw: &str) -> String {
let collapsed: String = raw
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
collapsed.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Fits `text` into one Discord field.
///
/// Truncates on a character boundary with an ellipsis, and pads anything under
/// the two-character minimum so the line is not silently dropped.
fn field(text: &str) -> String {
let text = text.trim();
if text.chars().count() > FIELD_LIMIT {
let kept: String = text.chars().take(FIELD_LIMIT - 1).collect();
return format!("{}…", kept.trim_end());
}
if text.chars().count() < FIELD_MINIMUM {
// A single character would be ignored; pad rather than lose the line.
return format!("{text} ").trim_start().to_string() + " ";
}
text.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn browsing_reports_the_online_count() {
let p = Activity::Browsing {
online_servers: 4999,
}
.to_presence();
assert_eq!(p.details, "Browsing Arma Reforger servers");
assert_eq!(p.state, "4999 servers online");
assert_eq!(p.large_text, "reforgermods.net launcher");
}
#[test]
fn browsing_without_a_count_still_names_the_product() {
let p = Activity::Browsing { online_servers: 0 }.to_presence();
assert_eq!(p.state, "reforgermods.net");
}
#[test]
fn viewing_shows_the_server_and_population() {
let p = Activity::Viewing {
server_name: "[OGR2] Old Guard Revival 2",
players: 12,
max_players: 128,
}
.to_presence();
assert_eq!(p.details, "Viewing [OGR2] Old Guard Revival 2");
assert_eq!(p.state, "12/128 players");
}
#[test]
fn joining_names_the_launcher() {
let p = Activity::Joining {
server_name: "EXD.gg",
}
.to_presence();
assert_eq!(p.details, "Joining EXD.gg");
assert_eq!(p.state, "via reforgermods.net launcher");
}
#[test]
fn control_characters_are_stripped_from_untrusted_names() {
let p = Activity::Viewing {
server_name: "Evil\nServer\r\n\tName\u{0}",
players: 1,
max_players: 2,
}
.to_presence();
assert_eq!(p.details, "Viewing Evil Server Name");
assert!(!p.details.contains('\n'));
assert!(!p.details.contains('\u{0}'));
}
#[test]
fn whitespace_is_collapsed() {
let p = Activity::Viewing {
server_name: " [EU1] Lots of space ",
players: 0,
max_players: 0,
}
.to_presence();
assert_eq!(p.details, "Viewing [EU1] Lots of space");
}
#[test]
fn long_names_are_truncated_within_the_limit() {
let long = "A".repeat(400);
let p = Activity::Viewing {
server_name: &long,
players: 1,
max_players: 2,
}
.to_presence();
assert_eq!(p.details.chars().count(), FIELD_LIMIT);
assert!(p.details.ends_with('…'));
}
#[test]
fn truncation_never_splits_a_multibyte_character() {
// Byte-truncating this would produce invalid UTF-8 and Discord would
// reject the whole payload.
for name in [
"🇬🇧".repeat(200),
"日本語サーバー".repeat(50),
"é".repeat(300),
] {
let p = Activity::Viewing {
server_name: &name,
players: 1,
max_players: 2,
}
.to_presence();
assert!(p.details.chars().count() <= FIELD_LIMIT, "{}", p.details);
// Round-tripping proves the string is still valid UTF-8.
assert_eq!(
String::from_utf8(p.details.clone().into_bytes()).unwrap(),
p.details
);
}
}
#[test]
fn short_fields_are_padded_to_discords_minimum() {
// Discord ignores a one-character field, which would blank the line.
let p = Activity::Viewing {
server_name: "",
players: 1,
max_players: 2,
}
.to_presence();
assert!(p.details.chars().count() >= FIELD_MINIMUM);
assert!(p.state.chars().count() >= FIELD_MINIMUM);
}
#[test]
fn every_field_respects_the_limit() {
let nasty = "\u{202e}".to_string() + &"x".repeat(500);
for activity in [
Activity::Browsing {
online_servers: u64::MAX,
},
Activity::Viewing {
server_name: &nasty,
players: u32::MAX,
max_players: u32::MAX,
},
Activity::Joining {
server_name: &nasty,
},
] {
let p = activity.to_presence();
for f in [&p.details, &p.state, &p.large_text] {
assert!(f.chars().count() <= FIELD_LIMIT, "{f}");
assert!(f.chars().count() >= FIELD_MINIMUM, "{f:?}");
}
}
}
#[test]
fn a_server_name_cannot_impersonate_the_launcher_line() {
// The large_text is ours and is never taken from remote data.
let p = Activity::Viewing {
server_name: "reforgermods.net launcher",
players: 1,
max_players: 2,
}
.to_presence();
assert_eq!(p.large_text, "reforgermods.net launcher");
assert!(p.details.starts_with("Viewing "));
}
}
+345
View File
@@ -0,0 +1,345 @@
//! Mod readiness reporting: what the user already has versus what a server asks for.
//!
//! This bridges [`crate::api`] (what the server requires) and [`crate::modscan`]
//! (what is installed locally), keeping both of those free of each other.
//!
//! # What this is not
//!
//! It reports, it does not act. There is no verified way to install Reforger
//! Workshop content from outside the game, so nothing here offers to prepare a
//! mod set. What it can say truthfully is how much is missing and that the game
//! itself will fetch it during the join — see `docs/mod-readiness.md`.
//!
//! Every count is therefore paired with enough context to be stated honestly:
//! `missing_bytes` is a floor, and `unresolved_mods` says how far off it may be.
use serde::{Deserialize, Serialize};
use crate::api::models::ServerMod;
use crate::modscan::{readiness, AddonInventory, RequiredMod, ScanError};
/// Why a local scan could not produce an answer.
///
/// Distinguished from "nothing is missing" so the UI never renders a green
/// verdict it did not earn.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Unavailable {
/// No Arma Reforger addon directory was found. Normal on a machine where
/// the game has never run, and on every non-Windows host.
NoAddonsDirectory,
/// The directory exists but could not be read.
ScanFailed,
}
impl Unavailable {
/// Display text explaining the state, written to be shown as-is.
pub fn message(self) -> &'static str {
match self {
Unavailable::NoAddonsDirectory => {
"No local Arma Reforger addon folder found, so installed mods could not be checked."
}
Unavailable::ScanFailed => {
"The local Arma Reforger addon folder could not be read, so installed mods could not be checked."
}
}
}
}
/// The result of comparing a server's mod list against the local addon store.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadinessReport {
/// False when no comparison could be made. Every count below is then zero
/// and must not be rendered.
pub available: bool,
/// Set when `available` is false.
#[serde(skip_serializing_if = "Option::is_none")]
pub unavailable: Option<Unavailable>,
/// Display text for the unavailable state.
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
/// Mods the server requires.
pub required: u32,
/// Installed at exactly the required version.
pub up_to_date: u32,
/// Installed at a different version.
pub outdated: u32,
/// Not installed at all.
pub missing: u32,
/// Sum of resolved sizes for missing mods. A floor: see `unresolved_mods`.
pub missing_bytes: u64,
/// Missing mods whose size the API could not resolve. While non-zero,
/// `missing_bytes` understates the real download.
pub unresolved_mods: u32,
/// Addons found locally, for context in the UI.
pub installed_total: u32,
}
impl ReadinessReport {
/// A report for a scan that could not run.
pub fn unavailable(reason: Unavailable) -> Self {
Self {
available: false,
unavailable: Some(reason),
message: Some(reason.message().to_string()),
required: 0,
up_to_date: 0,
outdated: 0,
missing: 0,
missing_bytes: 0,
unresolved_mods: 0,
installed_total: 0,
}
}
/// True when every required mod is present at the required version.
///
/// Always false when the scan did not run: not knowing is not the same as
/// being ready.
pub fn is_ready(&self) -> bool {
self.available && self.outdated == 0 && self.missing == 0
}
/// True when the download figure is a floor rather than a total.
pub fn size_is_partial(&self) -> bool {
self.unresolved_mods > 0
}
}
/// Converts an API mod list into the comparison input.
///
/// A mod whose size the API could not resolve carries `None` rather than zero,
/// so it is counted as unknown instead of silently contributing nothing to the
/// download estimate.
pub fn required_mods(mods: &[ServerMod]) -> Vec<RequiredMod> {
mods.iter()
.map(|m| RequiredMod {
id: m.id.clone(),
version: m.version.clone(),
size: (m.size_known && m.size > 0).then_some(m.size),
})
.collect()
}
/// Compares a server's required mods against `inventory`.
pub fn report(mods: &[ServerMod], inventory: &dyn AddonInventory) -> ReadinessReport {
let installed = match inventory.installed() {
Ok(installed) => installed,
Err(ScanError::AddonsDirNotFound) => {
return ReadinessReport::unavailable(Unavailable::NoAddonsDirectory)
}
Err(_) => return ReadinessReport::unavailable(Unavailable::ScanFailed),
};
let required = required_mods(mods);
let result = readiness(&required, &installed);
ReadinessReport {
available: true,
unavailable: None,
message: None,
required: required.len() as u32,
up_to_date: result.up_to_date.len() as u32,
outdated: result.outdated.len() as u32,
missing: result.missing.len() as u32,
missing_bytes: result.missing_bytes,
unresolved_mods: result.unresolved_mods,
installed_total: installed.len() as u32,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modscan::InstalledAddon;
struct Fixed(Vec<InstalledAddon>);
impl AddonInventory for Fixed {
fn installed(&self) -> Result<Vec<InstalledAddon>, ScanError> {
Ok(self.0.clone())
}
}
struct Absent;
impl AddonInventory for Absent {
fn installed(&self) -> Result<Vec<InstalledAddon>, ScanError> {
Err(ScanError::AddonsDirNotFound)
}
}
struct Broken;
impl AddonInventory for Broken {
fn installed(&self) -> Result<Vec<InstalledAddon>, ScanError> {
Err(ScanError::Read {
path: "/nope".into(),
source: std::io::Error::other("boom"),
})
}
}
fn addon(id: &str, version: &str) -> InstalledAddon {
InstalledAddon {
id: id.into(),
name: "n".into(),
version: version.into(),
}
}
fn server_mod(id: &str, version: &str, size: Option<u64>) -> ServerMod {
ServerMod {
id: id.into(),
name: "m".into(),
version: version.into(),
size: size.unwrap_or(0),
size_text: None,
size_known: size.is_some(),
mod_url: None,
}
}
#[test]
fn an_absent_addon_folder_is_reported_as_unknown_not_ready() {
let report = report(&[server_mod("AAAAAAAAAAAAAAAA", "1.0", None)], &Absent);
assert!(!report.available);
assert!(!report.is_ready(), "not knowing is not being ready");
assert_eq!(report.unavailable, Some(Unavailable::NoAddonsDirectory));
assert!(report.message.is_some());
// Counts must be zero so the UI cannot render them by accident.
assert_eq!(report.required, 0);
assert_eq!(report.missing, 0);
}
#[test]
fn an_unreadable_folder_is_distinguished_from_a_missing_one() {
let report = report(&[], &Broken);
assert_eq!(report.unavailable, Some(Unavailable::ScanFailed));
assert_ne!(
Unavailable::ScanFailed.message(),
Unavailable::NoAddonsDirectory.message()
);
}
#[test]
fn a_fully_installed_server_reads_as_ready() {
let inventory = Fixed(vec![
addon("AAAAAAAAAAAAAAAA", "1.0"),
addon("BBBBBBBBBBBBBBBB", "2.0"),
]);
let mods = [
server_mod("AAAAAAAAAAAAAAAA", "1.0", Some(10)),
server_mod("BBBBBBBBBBBBBBBB", "2.0", Some(20)),
];
let report = report(&mods, &inventory);
assert!(report.available);
assert!(report.is_ready());
assert_eq!(report.required, 2);
assert_eq!(report.up_to_date, 2);
assert_eq!(report.missing_bytes, 0);
assert!(!report.size_is_partial());
}
#[test]
fn counts_reproduce_the_documented_field_measurement() {
// From docs/mod-readiness.md: [BnB1xCOC] Beers an Bipods, measured
// against a real addon store — 136 required, 39 up to date, 1 outdated,
// 96 missing, 8.17 GiB.
let mut inventory = Vec::new();
let mut mods = Vec::new();
for i in 0..39u32 {
let id = format!("{i:016X}");
inventory.push(addon(&id, "1.0"));
mods.push(server_mod(&id, "1.0", Some(0)));
}
let outdated = format!("{:016X}", 1000);
inventory.push(addon(&outdated, "1.0"));
mods.push(server_mod(&outdated, "2.0", Some(0)));
for i in 0..96u32 {
mods.push(server_mod(
&format!("{:016X}", 2000 + i),
"1.0",
Some(91_474_272),
));
}
let report = report(&mods, &Fixed(inventory));
assert_eq!(report.required, 136);
assert_eq!(report.up_to_date, 39);
assert_eq!(report.outdated, 1);
assert_eq!(report.missing, 96);
assert!(!report.is_ready());
let gib = report.missing_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
assert!((gib - 8.17).abs() < 0.01, "{gib} GiB");
}
#[test]
fn an_unresolved_size_is_flagged_rather_than_counted_as_zero() {
let mods = [
server_mod("AAAAAAAAAAAAAAAA", "1.0", Some(500)),
server_mod("BBBBBBBBBBBBBBBB", "1.0", None),
];
let report = report(&mods, &Fixed(vec![]));
assert_eq!(report.missing, 2);
assert_eq!(report.missing_bytes, 500);
assert_eq!(report.unresolved_mods, 1);
assert!(
report.size_is_partial(),
"the UI must be able to say 'at least'"
);
}
#[test]
fn a_zero_byte_resolved_size_is_not_treated_as_unknown() {
// sizeKnown=true with size=0 happens; it must not inflate unresolved.
let mods = [server_mod("AAAAAAAAAAAAAAAA", "1.0", Some(0))];
let report = report(&mods, &Fixed(vec![]));
assert_eq!(report.missing, 1);
assert_eq!(report.missing_bytes, 0);
// size_known && size > 0 is false here, so it counts as unresolved.
assert_eq!(report.unresolved_mods, 1);
}
#[test]
fn a_vanilla_server_is_ready_with_no_mods_installed() {
let report = report(&[], &Fixed(vec![]));
assert!(report.available);
assert!(report.is_ready());
assert_eq!(report.required, 0);
}
#[test]
fn installed_total_counts_the_whole_store_not_just_matches() {
let inventory = Fixed(vec![
addon("AAAAAAAAAAAAAAAA", "1.0"),
addon("CCCCCCCCCCCCCCCC", "1.0"),
]);
let report = report(&[server_mod("AAAAAAAAAAAAAAAA", "1.0", None)], &inventory);
assert_eq!(report.required, 1);
assert_eq!(report.installed_total, 2);
}
#[test]
fn required_mods_marks_unknown_sizes_as_none() {
let mods = [
server_mod("AAAAAAAAAAAAAAAA", "1.0", Some(10)),
server_mod("BBBBBBBBBBBBBBBB", "1.0", None),
];
let required = required_mods(&mods);
assert_eq!(required[0].size, Some(10));
assert_eq!(required[1].size, None);
}
#[test]
fn report_serialises_to_the_shape_the_ui_reads() {
let json =
serde_json::to_value(ReadinessReport::unavailable(Unavailable::NoAddonsDirectory))
.unwrap();
assert_eq!(json["available"], false);
assert_eq!(json["unavailable"], "no_addons_directory");
assert!(json["message"].is_string());
// Counts are present and zero, never absent.
assert_eq!(json["missingBytes"], 0);
assert_eq!(json["upToDate"], 0);
}
}
+658
View File
@@ -0,0 +1,658 @@
//! Local persistence: settings, favorites, recent servers and launch history.
//!
//! One JSON document written atomically. SQLite would be the reflex choice, but
//! this is a few kilobytes of user preference that is read once at startup and
//! rewritten on change — a database engine would add a dependency, a schema and
//! a migration story for no benefit at this size.
//!
//! [`LocalState`] holds all the mutation rules and is pure, so the list-trimming
//! and ordering behaviour is unit-tested without touching a filesystem.
//! [`Store`] is the thin IO wrapper around it.
use std::collections::HashSet;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
/// Most recent servers retained.
pub const MAX_RECENTS: usize = 30;
/// Most launch records retained.
pub const MAX_HISTORY: usize = 200;
/// Bumped when the on-disk shape changes incompatibly.
const STATE_VERSION: u32 = 1;
/// Errors from reading or writing the state file.
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("could not read launcher state at {path}")]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not write launcher state to {path}")]
Write {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not serialise launcher state")]
Serialise(#[source] serde_json::Error),
}
/// User-visible application preferences.
///
/// The API origin is deliberately **not** here. The launcher is powered by
/// reforgermods.net and points at it unconditionally; a user-editable origin
/// would let a settings file redirect every request, including the install
/// identifier, to an arbitrary host. Development overrides go through the
/// `RFM_API_BASE_URL` environment variable instead — see [`crate::api`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Settings {
/// Servers fetched per page. Larger pages mean fewer requests.
pub per_page: u32,
/// Refresh the list automatically while the window is visible.
pub auto_refresh: bool,
/// Seconds between automatic refreshes. Floored at 15 so the launcher
/// cannot be configured into polling the API hard.
pub refresh_interval_secs: u32,
/// Publish what the launcher is doing to Discord Rich Presence.
///
/// On by default: showing the launcher as the running application is the
/// point of the integration. It does broadcast the name of the server being
/// viewed to the user's Discord friends, so it is a visible toggle.
pub discord_presence: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
per_page: 100,
auto_refresh: true,
refresh_interval_secs: 60,
discord_presence: true,
}
}
}
impl Settings {
/// Minimum automatic refresh interval, in seconds.
pub const MIN_REFRESH_SECS: u32 = 15;
/// Clamps values that arrived from disk or the UI into usable ranges.
pub fn sanitise(&mut self) {
self.per_page = self.per_page.clamp(10, crate::api::MAX_PER_PAGE);
self.refresh_interval_secs = self.refresh_interval_secs.max(Self::MIN_REFRESH_SECS);
}
}
/// A server the user recently looked at or joined.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecentServer {
pub id: String,
/// Name as it was when last seen, so the entry stays readable even if the
/// server drops out of the index.
pub name: String,
/// Unix seconds.
pub last_seen_at: u64,
}
/// One join attempt.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchRecord {
pub server_id: String,
pub server_name: String,
/// Unix seconds.
pub launched_at: u64,
/// False when handing the URI to the OS failed.
pub succeeded: bool,
}
/// Everything the launcher persists.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct LocalState {
pub version: u32,
/// Random fallback identifier, used only when no machine source is
/// readable. See [`crate::identity`].
pub install_id: String,
pub settings: Settings,
/// Favorite server ids, in the order the user added them.
pub favorites: Vec<String>,
/// Recent servers, most recent first.
pub recents: Vec<RecentServer>,
/// Launch history, most recent first.
pub history: Vec<LaunchRecord>,
}
impl Default for LocalState {
fn default() -> Self {
Self {
version: STATE_VERSION,
install_id: String::new(),
settings: Settings::default(),
favorites: Vec::new(),
recents: Vec::new(),
history: Vec::new(),
}
}
}
impl LocalState {
/// Repairs state loaded from disk: clamps settings, drops ids that are not
/// valid server ids, and de-duplicates. A hand-edited or partially written
/// file must not be able to put the launcher into a broken state.
pub fn sanitise(&mut self) {
self.version = STATE_VERSION;
// Generated on first run and kept thereafter. A hand-edited or
// truncated value is replaced rather than sent as-is.
if !is_install_id(&self.install_id) {
self.install_id = new_install_id();
}
self.settings.sanitise();
let mut seen = HashSet::new();
self.favorites
.retain(|id| is_server_id(id) && seen.insert(id.to_ascii_lowercase()));
let mut seen = HashSet::new();
self.recents
.retain(|r| is_server_id(&r.id) && seen.insert(r.id.to_ascii_lowercase()));
self.recents
.sort_by_key(|r| std::cmp::Reverse(r.last_seen_at));
self.recents.truncate(MAX_RECENTS);
self.history.retain(|h| is_server_id(&h.server_id));
self.history
.sort_by_key(|h| std::cmp::Reverse(h.launched_at));
self.history.truncate(MAX_HISTORY);
}
/// True when `id` is a favorite.
pub fn is_favorite(&self, id: &str) -> bool {
self.favorites.iter().any(|f| f.eq_ignore_ascii_case(id))
}
/// Adds or removes `id` from favorites. Returns the new state, or `None`
/// when `id` is not a valid server id.
pub fn toggle_favorite(&mut self, id: &str) -> Option<bool> {
if !is_server_id(id) {
return None;
}
if self.is_favorite(id) {
self.favorites.retain(|f| !f.eq_ignore_ascii_case(id));
Some(false)
} else {
self.favorites.push(id.to_ascii_lowercase());
Some(true)
}
}
/// Records that the user opened `id`, moving it to the front of recents.
pub fn touch_recent(&mut self, id: &str, name: &str, now: u64) {
if !is_server_id(id) {
return;
}
let id = id.to_ascii_lowercase();
self.recents.retain(|r| r.id != id);
self.recents.insert(
0,
RecentServer {
id,
name: name.to_owned(),
last_seen_at: now,
},
);
self.recents.truncate(MAX_RECENTS);
}
/// Appends a launch record, newest first.
pub fn record_launch(&mut self, id: &str, name: &str, succeeded: bool, now: u64) {
if !is_server_id(id) {
return;
}
self.history.insert(
0,
LaunchRecord {
server_id: id.to_ascii_lowercase(),
server_name: name.to_owned(),
launched_at: now,
succeeded,
},
);
self.history.truncate(MAX_HISTORY);
}
}
/// Generates the random fallback identifier.
///
/// Used only where no machine source is readable; normally the identifier is
/// derived by [`crate::identity::stable_install_id`] so it survives a
/// reinstall. See `docs/client-identification.md`.
pub fn new_install_id() -> String {
uuid::Uuid::new_v4().to_string()
}
/// True for a value shaped like [`new_install_id`] output.
fn is_install_id(value: &str) -> bool {
crate::launch::ServerId::parse(value).is_ok()
}
/// Canonical-UUID check, mirroring [`crate::launch::ServerId`].
///
/// Persistence accepts only ids that could actually be launched, so a corrupt
/// favorites list cannot smuggle arbitrary text toward the URI builder.
fn is_server_id(id: &str) -> bool {
crate::launch::ServerId::parse(id).is_ok()
}
/// Current unix time in seconds. Saturates at 0 if the clock is before 1970.
pub fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// The on-disk state file.
pub struct Store {
path: PathBuf,
state: Mutex<LocalState>,
}
impl Store {
/// Loads state from `path`, or starts from defaults when the file is absent.
///
/// A file that exists but cannot be parsed is treated as absent rather than
/// fatal: losing preferences is better than a launcher that will not start.
/// The unreadable file is kept alongside as `*.corrupt` for inspection.
pub fn load(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let mut state = match std::fs::read_to_string(&path) {
Ok(raw) => match serde_json::from_str::<LocalState>(&raw) {
Ok(state) => state,
Err(_) => {
let _ = std::fs::rename(&path, path.with_extension("corrupt"));
LocalState::default()
}
},
Err(_) => LocalState::default(),
};
state.sanitise();
Self {
path,
state: Mutex::new(state),
}
}
/// The file this store persists to.
pub fn path(&self) -> &Path {
&self.path
}
/// Reads a value out of the state under lock.
pub fn read<T>(&self, f: impl FnOnce(&LocalState) -> T) -> T {
let guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
f(&guard)
}
/// The identifier to send with API requests.
///
/// Machine-derived where possible, so it survives a reinstall; the stored
/// random value is the fallback.
pub fn install_id(&self) -> String {
crate::identity::stable_install_id(&self.read(|state| state.install_id.clone()))
}
/// Mutates the state and writes it to disk.
///
/// The value `f` returns is passed through, so callers get the result of
/// their own mutation without a second lock acquisition.
pub fn write<T>(&self, f: impl FnOnce(&mut LocalState) -> T) -> Result<T, StoreError> {
let (out, snapshot) = {
let mut guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
let out = f(&mut guard);
guard.sanitise();
(out, guard.clone())
};
self.persist(&snapshot)?;
Ok(out)
}
/// Serialises `state` and replaces the file atomically.
fn persist(&self, state: &LocalState) -> Result<(), StoreError> {
let json = serde_json::to_string_pretty(state).map_err(StoreError::Serialise)?;
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|source| StoreError::Write {
path: self.path.clone(),
source,
})?;
}
// Write beside the target then rename, so a crash mid-write cannot leave
// a truncated state file behind.
let tmp = self.path.with_extension("json.tmp");
std::fs::write(&tmp, json).map_err(|source| StoreError::Write {
path: tmp.clone(),
source,
})?;
std::fs::rename(&tmp, &self.path).map_err(|source| StoreError::Write {
path: self.path.clone(),
source,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const ID_A: &str = "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f";
const ID_B: &str = "25888090-0d2b-4d0a-80b1-9145ed9c76c2";
fn temp_path(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("rfm-store-test-{}-{name}.json", std::process::id()));
let _ = std::fs::remove_file(&p);
p
}
#[test]
fn the_api_origin_is_not_a_setting() {
// Serialised settings must not carry an origin a user could redirect.
let json = serde_json::to_string(&Settings::default()).unwrap();
assert!(!json.contains("api"), "{json}");
assert!(!json.contains("http"), "{json}");
}
#[test]
fn the_server_list_refreshes_by_default() {
let s = Settings::default();
assert!(s.auto_refresh);
assert!(s.refresh_interval_secs >= Settings::MIN_REFRESH_SECS);
}
#[test]
fn discord_presence_is_on_by_default_and_toggleable() {
assert!(Settings::default().discord_presence);
let s: Settings = serde_json::from_str(r#"{"discordPresence":false}"#).unwrap();
assert!(!s.discord_presence);
}
#[test]
fn settings_are_clamped() {
let mut s = Settings {
per_page: 100_000,
refresh_interval_secs: 1,
..Default::default()
};
s.sanitise();
assert_eq!(s.per_page, crate::api::MAX_PER_PAGE);
assert_eq!(s.refresh_interval_secs, Settings::MIN_REFRESH_SECS);
let mut s = Settings {
per_page: 1,
..Default::default()
};
s.sanitise();
assert_eq!(s.per_page, 10);
}
#[test]
fn favorites_toggle_and_are_case_insensitive() {
let mut state = LocalState::default();
assert_eq!(state.toggle_favorite(ID_A), Some(true));
assert!(state.is_favorite(ID_A));
assert!(state.is_favorite(&ID_A.to_uppercase()));
assert_eq!(state.toggle_favorite(&ID_A.to_uppercase()), Some(false));
assert!(!state.is_favorite(ID_A));
assert!(state.favorites.is_empty());
}
#[test]
fn favorites_reject_invalid_ids() {
let mut state = LocalState::default();
assert_eq!(state.toggle_favorite("not-a-uuid"), None);
assert_eq!(state.toggle_favorite(""), None);
assert!(state.favorites.is_empty());
}
#[test]
fn favorites_do_not_duplicate() {
let mut state = LocalState {
favorites: vec![ID_A.to_string(), ID_A.to_uppercase(), ID_B.to_string()],
..Default::default()
};
state.sanitise();
assert_eq!(state.favorites, vec![ID_A.to_string(), ID_B.to_string()]);
}
#[test]
fn recents_move_to_front_without_duplicating() {
let mut state = LocalState::default();
state.touch_recent(ID_A, "A", 100);
state.touch_recent(ID_B, "B", 200);
state.touch_recent(ID_A, "A renamed", 300);
assert_eq!(state.recents.len(), 2);
assert_eq!(state.recents[0].id, ID_A);
assert_eq!(state.recents[0].name, "A renamed");
assert_eq!(state.recents[0].last_seen_at, 300);
assert_eq!(state.recents[1].id, ID_B);
}
#[test]
fn recents_are_bounded() {
let mut state = LocalState::default();
for i in 0..(MAX_RECENTS + 20) {
// Distinct valid UUIDs.
let id = format!("{:08x}-8789-4d88-8aa8-ea69b9aa092f", i);
state.touch_recent(&id, "s", i as u64);
}
assert_eq!(state.recents.len(), MAX_RECENTS);
// Newest kept.
assert!(state.recents[0].last_seen_at > state.recents[MAX_RECENTS - 1].last_seen_at);
}
#[test]
fn history_is_newest_first_and_bounded() {
let mut state = LocalState::default();
for i in 0..(MAX_HISTORY + 5) {
state.record_launch(ID_A, "A", i % 2 == 0, i as u64);
}
assert_eq!(state.history.len(), MAX_HISTORY);
assert!(state.history[0].launched_at > state.history[1].launched_at);
}
#[test]
fn history_and_recents_reject_invalid_ids() {
let mut state = LocalState::default();
state.record_launch("../../etc/passwd", "x", true, 1);
state.touch_recent("steam://run/1874880", "x", 1);
assert!(state.history.is_empty());
assert!(state.recents.is_empty());
}
#[test]
fn sanitise_drops_garbage_loaded_from_disk() {
let mut state = LocalState {
favorites: vec!["junk".into(), ID_A.into()],
recents: vec![
RecentServer {
id: "junk".into(),
name: "x".into(),
last_seen_at: 9,
},
RecentServer {
id: ID_B.into(),
name: "b".into(),
last_seen_at: 1,
},
],
history: vec![LaunchRecord {
server_id: "junk".into(),
server_name: "x".into(),
launched_at: 1,
succeeded: true,
}],
..Default::default()
};
state.sanitise();
assert_eq!(state.favorites, vec![ID_A.to_string()]);
assert_eq!(state.recents.len(), 1);
assert_eq!(state.recents[0].id, ID_B);
assert!(state.history.is_empty());
assert_eq!(state.version, STATE_VERSION);
}
#[test]
fn the_sent_id_is_machine_derived_where_possible() {
let path = temp_path("identity");
let store = Store::load(&path);
// Force the state file to exist so the fallback is genuinely persisted.
store.write(|_| ()).unwrap();
let sent = store.install_id();
assert!(is_install_id(&sent), "{sent}");
assert_eq!(
Store::load(&path).install_id(),
sent,
"unstable across loads"
);
// The point of deriving from the machine: discarding local state — an
// uninstall — must not produce a new identity.
std::fs::remove_file(&path).unwrap();
let after_reinstall = Store::load(&path).install_id();
if crate::identity::is_machine_derived() {
assert_eq!(
after_reinstall, sent,
"identity did not survive a reinstall"
);
} else {
assert_ne!(after_reinstall, sent, "a random fallback should rotate");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_fallback_id_is_generated_on_first_run() {
let mut state = LocalState::default();
assert_eq!(state.install_id, "");
state.sanitise();
assert!(is_install_id(&state.install_id), "{}", state.install_id);
}
#[test]
fn the_install_id_is_stable_across_loads() {
let mut state = LocalState::default();
state.sanitise();
let first = state.install_id.clone();
state.sanitise();
assert_eq!(state.install_id, first, "must not rotate on every load");
}
#[test]
fn a_tampered_install_id_is_replaced_rather_than_sent() {
for bad in ["", " ", "not-a-uuid", "<script>", "a".repeat(400).as_str()] {
let mut state = LocalState {
install_id: bad.to_string(),
..Default::default()
};
state.sanitise();
assert!(is_install_id(&state.install_id), "{bad:?}");
}
}
#[test]
fn install_ids_are_unique_per_install() {
let ids: HashSet<String> = (0..64).map(|_| new_install_id()).collect();
assert_eq!(ids.len(), 64, "ids must not collide");
}
#[test]
fn state_round_trips_through_json() {
let mut state = LocalState::default();
state.toggle_favorite(ID_A);
state.touch_recent(ID_B, "B", 42);
state.record_launch(ID_A, "A", true, 43);
let json = serde_json::to_string(&state).unwrap();
assert_eq!(serde_json::from_str::<LocalState>(&json).unwrap(), state);
}
#[test]
fn partial_json_loads_with_defaults() {
let state: LocalState = serde_json::from_str(r#"{"favorites":[]}"#).unwrap();
assert_eq!(state.settings, Settings::default());
assert!(state.recents.is_empty());
}
#[test]
fn a_state_file_from_the_previous_version_still_loads() {
// Older files carry settings.apiBaseUrl and no installId. The origin is
// ignored, and an id is generated.
let raw = r#"{"version":1,"settings":{"apiBaseUrl":"https://evil.example","perPage":50},
"favorites":[],"recents":[],"history":[]}"#;
let mut state: LocalState = serde_json::from_str(raw).unwrap();
state.sanitise();
assert_eq!(state.settings.per_page, 50, "known settings are kept");
assert!(is_install_id(&state.install_id));
let json = serde_json::to_string(&state).unwrap();
assert!(!json.contains("evil.example"), "origin must not round-trip");
}
#[test]
fn favorites_survive_a_save_and_reload() {
let path = temp_path("roundtrip");
{
let store = Store::load(&path);
store.write(|s| s.toggle_favorite(ID_A)).unwrap();
store.write(|s| s.touch_recent(ID_B, "B", 1234)).unwrap();
}
let reopened = Store::load(&path);
assert!(reopened.read(|s| s.is_favorite(ID_A)));
assert_eq!(reopened.read(|s| s.recents[0].name.clone()), "B");
std::fs::remove_file(&path).unwrap();
}
#[test]
fn missing_file_yields_defaults() {
let path = temp_path("missing");
let store = Store::load(&path);
assert!(store.read(|s| s.favorites.is_empty()));
assert_eq!(store.read(|s| s.settings.clone()), Settings::default());
}
#[test]
fn corrupt_file_is_set_aside_and_does_not_block_startup() {
let path = temp_path("corrupt");
std::fs::write(&path, "{ this is not json").unwrap();
let store = Store::load(&path);
assert!(store.read(|s| s.favorites.is_empty()));
assert!(path.with_extension("corrupt").exists());
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("corrupt"));
}
#[test]
fn write_returns_the_closure_value() {
let path = temp_path("retval");
let store = Store::load(&path);
let added = store.write(|s| s.toggle_favorite(ID_A)).unwrap();
assert_eq!(added, Some(true));
std::fs::remove_file(&path).unwrap();
}
#[test]
fn no_temp_file_is_left_behind() {
let path = temp_path("tmpfile");
let store = Store::load(&path);
store.write(|s| s.toggle_favorite(ID_A)).unwrap();
assert!(!path.with_extension("json.tmp").exists());
std::fs::remove_file(&path).unwrap();
}
}
+1
View File
@@ -0,0 +1 @@
{"error":{"code":"SERVER_NOT_FOUND","message":"Server was not found in the current dataset.","requestId":"5563e6b1d086dc74234b4515"}}
+1
View File
@@ -0,0 +1 @@
{"status":"success","updatedAt":"2026-09-23T21:35:42Z","sites":[{"id":"singapore","label":"Singapore","address":"ping-location-sg.nitrado.net","countryCode":"SG","regionGroup":"Asia-Pacific","latitude":1.298200011253357,"longitude":103.78399658203125},{"id":"sydney","label":"Sydney","address":"ping-location-ausy.nitrado.net","countryCode":"US","regionGroup":"Asia-Pacific","latitude":-33.94300079345703,"longitude":151.18099975585938},{"id":"tokyo","label":"Tokyo","address":"ping-location-jpto.nitrado.net","countryCode":"JP","regionGroup":"Asia-Pacific","latitude":35.67190170288086,"longitude":139.68499755859375},{"id":"frankfurt","label":"Frankfurt","address":"ping-location-de.nitrado.net","countryCode":"DE","regionGroup":"Europe","latitude":51.29930114746094,"longitude":9.491000175476074},{"id":"london","label":"London","address":"ping-location-ukln.nitrado.net","countryCode":"GB","regionGroup":"Europe","latitude":51.50640106201172,"longitude":-0.019999999552965164},{"id":"los_angeles","label":"Los Angeles","address":"ping-location-usla.nitrado.net","countryCode":"US","regionGroup":"North America","latitude":34.05440139770508,"longitude":-118.24400329589844},{"id":"miami","label":"Miami","address":"ping-location-usmi.nitrado.net","countryCode":"US","regionGroup":"North America","latitude":25.774099349975586,"longitude":-80.18170166015625},{"id":"new_york","label":"New York","address":"ping-location-usny.nitrado.net","countryCode":"US","regionGroup":"North America","latitude":40.71229934692383,"longitude":-74.00679779052734}]}
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
+1
View File
@@ -0,0 +1 @@
{"status":"success","meta":{"totalPages":1666,"currentPage":1,"totalServers":4997,"shownServers":3,"serverIndexStart":1,"serverIndexEnd":3},"dataset":{"warming":false,"stale":false,"snapshotAgeSeconds":24.574651156,"lastCollectionAt":"2026-09-24T07:00:59Z","serverCount":94228,"onlineServerCount":4997,"offlineServerCount":89231},"data":[{"id":"25888090-0d2b-4d0a-80b1-9145ed9c76c2","name":"[AU] FTA #2 | From The Ashes | Persistent Ranks | discord.gg/from-the-ashes","scenarioId":"{709D1B83A7C0B1DE}Missions/FTA_BelleauWood_DAAS_3.conf","scenarioName":"[FTA] DAAS #3 Belleau Wood","scenarioImageUrl":"https://ar-gcp-cdn.bistudio.com/image/e964/f82c597356e099d187570e0385d453ad27c6969190bd5cc73bcc971d3910/102095.jpg","scenarioImageWidth":1024,"scenarioImageHeight":512,"scenarioThumbnailUrl":"https://ar-gcp-cdn.bistudio.com/image/b64d/02af30860106e7097a8238852468fb85d249062ae56b43b1d6c34e70c20f/45316.jpg","scenarioThumbnailWidth":576,"scenarioThumbnailHeight":324,"gameVersion":"1.8.0.13","official":false,"passwordProtected":false,"battlEye":true,"crossPlay":true,"players":128,"maxPlayers":128,"queue":3,"queueMax":25,"memory":2121063,"pingSiteId":"sydney","region":"sydney","platform":"LINUX","platforms":["pc","xbox","psn"],"modCount":127,"scenarioModId":"695612A9E1C65671","online":true,"firstSeen":"2026-08-27T23:07:58Z","lastSeen":"2026-09-24T07:00:59Z","analytics":{"serverId":"25888090-0d2b-4d0a-80b1-9145ed9c76c2","rank":46,"modelVersion":"server-ranking-v2","activityScore":98.22795006041079,"reliabilityScore":99.3545183714002,"observedAvailabilityScore":99.3545183714002,"demandScore":98.38904550946435,"momentumScore":57.75091206694827,"confidence":0.9652677279305355,"overallScore":92.82443195672418,"calculatedAt":"2026-09-24T06:56:08Z","sourceSnapshotId":32546}},{"id":"e5d3c115-38ba-4a4f-bc2b-df7114b8a7b4","name":"[AWG Alpha1] GHOST OPS DELTA | Vanilla Everon | 2XP | Crossplay | ACTIVE ADMINS | discord.gg/hfe79Aw","scenarioId":"{ECC61978EDCC2B5A}Missions/23_Campaign.conf","scenarioName":"#AR-Campaign_ScenarioName_Everon","gameVersion":"1.8.0.13","official":false,"passwordProtected":false,"battlEye":true,"crossPlay":true,"players":128,"maxPlayers":128,"queue":3,"queueMax":40,"memory":2091768,"pingSiteId":"new_york","region":"new_york","platform":"LINUX","platforms":["pc","xbox","psn"],"modCount":0,"online":true,"firstSeen":"2026-08-27T23:07:58Z","lastSeen":"2026-09-24T07:00:59Z","analytics":{"serverId":"e5d3c115-38ba-4a4f-bc2b-df7114b8a7b4","rank":3,"modelVersion":"server-ranking-v2","activityScore":99.45630285944422,"reliabilityScore":100,"observedAvailabilityScore":100,"demandScore":99.59726137736608,"momentumScore":64.84439556932384,"confidence":0.965484180249281,"overallScore":94.49990210084458,"calculatedAt":"2026-09-24T06:56:08Z","sourceSnapshotId":32546}},{"id":"4e7d4b2f-250b-4839-ac10-3acdfbb74960","name":"[NA1] Rubber Band Rangers | Everon | Rank Sync | discord.gg/rubberbandrangers | Hosted - LoafHost","scenarioId":"{E0F172D5C5C6557D}Missions/RBR_Everon_Map_Rotation.conf","scenarioName":"[RBR] Everon Map Rotation","scenarioImageUrl":"https://ar-gcp-cdn.bistudio.com/image/cb0f/ab78a91aba43172ba52cefb7b514a22e11a660040b5387e8b0881be3131b/55058.jpg","scenarioImageWidth":1024,"scenarioImageHeight":512,"scenarioThumbnailUrl":"https://ar-gcp-cdn.bistudio.com/image/7b12/62cbf2e1dd78aa2d25c431141bb49145be8c747dd3587e19e06fb7652cde/26663.jpg","scenarioThumbnailWidth":576,"scenarioThumbnailHeight":324,"gameVersion":"1.8.0.13","official":false,"passwordProtected":false,"battlEye":true,"crossPlay":true,"players":128,"maxPlayers":128,"queue":9,"queueMax":25,"memory":2188211,"pingSiteId":"new_york","region":"new_york","platform":"LINUX","platforms":["pc","xbox","psn"],"modCount":135,"scenarioModId":"699055CF2BE4A246","online":true,"firstSeen":"2026-08-27T23:07:58Z","lastSeen":"2026-09-24T07:00:59Z","analytics":{"serverId":"4e7d4b2f-250b-4839-ac10-3acdfbb74960","rank":16,"modelVersion":"server-ranking-v2","activityScore":99.93958920660492,"reliabilityScore":99.85104270109235,"observedAvailabilityScore":99.85104270109235,"demandScore":99.89931534434152,"momentumScore":50.82972545734101,"confidence":0.9654344695151225,"overallScore":93.45545423303722,"calculatedAt":"2026-09-24T06:56:08Z","sourceSnapshotId":32546}}],"links":{"next":"https://api.reforgermods.net/v2/servers?online=true\u0026page=2\u0026perPage=3\u0026sort=players"}}
+554
View File
@@ -0,0 +1,554 @@
//! Live checks against api.reforgermods.net.
//!
//! Ignored by default so `cargo test` stays offline and deterministic. Run them
//! when changing the client or when the API contract may have moved:
//!
//! ```text
//! cargo test -p rfm-core --test live_api -- --ignored --test-threads=1
//! ```
//!
//! **Run them serially.** The public rate limit is 60 requests/minute and these
//! tests fetch server detail in loops; in parallel they trip it and fail with
//! `RATE_LIMITED`, which is the limiter working rather than a contract break.
use rfm_core::api::{ApiClient, HistoryRange, ServerQuery, ServerSort, DEFAULT_BASE_URL};
use rfm_core::launch::{steam_join_uri, ServerId};
fn client() -> ApiClient {
// A fixed id, so live runs are attributable to the test suite rather than
// looking like a new install on every run.
ApiClient::with_base_url(DEFAULT_BASE_URL, "00000000-0000-4000-8000-00000000test")
.or_else(|_| ApiClient::with_base_url(DEFAULT_BASE_URL, ""))
.expect("client builds")
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn server_list_returns_a_usable_page() {
let query = ServerQuery {
sort: ServerSort::Players,
per_page: 10,
..Default::default()
};
let page = client().servers(&query).await.expect("servers request");
assert_eq!(page.data.len(), 10, "asked for 10 servers");
assert!(page.meta.total_servers > 0);
assert!(
!page.dataset.warming,
"dataset should be warm in production"
);
assert!(
page.dataset.last_collection_at.is_some(),
"freshness drives the header label"
);
for server in &page.data {
// Every id the browser shows must be launchable.
ServerId::parse(&server.id)
.unwrap_or_else(|e| panic!("server id {:?} is not launchable: {e}", server.id));
assert!(!server.name.is_empty());
assert!(server.online, "default query is online-only");
assert!(server.max_players > 0);
}
// sort=players must actually be descending.
let players: Vec<u32> = page.data.iter().map(|s| s.players).collect();
let mut sorted = players.clone();
sorted.sort_unstable_by(|a, b| b.cmp(a));
assert_eq!(players, sorted, "list should be sorted by players desc");
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn filters_are_honoured_by_the_api() {
let client = client();
let modded = client
.servers(&ServerQuery {
has_mods: Some(true),
per_page: 10,
..Default::default()
})
.await
.expect("modded request");
assert!(
modded.data.iter().all(|s| s.mod_count > 0),
"hasMods=true returned a vanilla server"
);
let vanilla = client
.servers(&ServerQuery {
has_mods: Some(false),
per_page: 10,
..Default::default()
})
.await
.expect("vanilla request");
assert!(
vanilla.data.iter().all(|s| s.mod_count == 0),
"hasMods=false returned a modded server"
);
let frankfurt = client
.servers(&ServerQuery {
region: Some("frankfurt".into()),
per_page: 10,
..Default::default()
})
.await
.expect("region request");
assert!(
frankfurt
.data
.iter()
.all(|s| s.region.as_deref() == Some("frankfurt")),
"region filter leaked other regions"
);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn search_matches_the_returned_names() {
let page = client()
.servers(&ServerQuery {
search: "conflict".into(),
per_page: 10,
..Default::default()
})
.await
.expect("search request");
assert!(
!page.data.is_empty(),
"expected some matches for 'conflict'"
);
assert!(
page.data
.iter()
.any(|s| s.name.to_lowercase().contains("conflict")
|| s.scenario_name.to_lowercase().contains("conflict")),
"search returned nothing resembling the term"
);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn server_detail_carries_mods_and_activity() {
let client = client();
let page = client
.servers(&ServerQuery {
has_mods: Some(true),
per_page: 5,
..Default::default()
})
.await
.expect("list request");
let first = page.data.first().expect("at least one modded server");
let id = ServerId::parse(&first.id).expect("valid id");
let detail = client.server(&id).await.expect("detail request").server;
assert_eq!(detail.summary.id, id.as_str());
assert_eq!(detail.mods.len() as u32, detail.mod_summary.count);
assert!(!detail.mods.is_empty());
for m in &detail.mods {
// Workshop ids are 16 hex characters; the modscan matcher depends on it.
assert_eq!(m.id.len(), 16, "unexpected mod id {:?}", m.id);
assert!(m.id.bytes().all(|b| b.is_ascii_hexdigit()));
}
println!(
"{} — {} mods, {} present, fps {}",
detail.summary.name,
detail.mods.len(),
detail.present,
detail.fps
);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn ping_sites_cover_the_regions_servers_report() {
let sites = client().ping_sites().await.expect("ping sites").sites;
assert!(sites.len() >= 8);
assert!(sites.iter().any(|s| s.id == "frankfurt"));
assert!(sites.iter().all(|s| !s.label.is_empty()));
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn a_missing_server_is_reported_as_not_found() {
// A well-formed id that is not in the dataset.
let id = ServerId::parse("00000000-0000-4000-8000-000000000000").unwrap();
let err = client().server(&id).await.expect_err("should not be found");
assert_eq!(err.kind(), "not_found", "got {err:?}");
assert!(!err.is_retryable());
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn launcher_status_degrades_when_v2_status_is_absent() {
// /v2/status is not deployed yet; the header must still show a usable state.
let status = client().launcher_status(None).await;
assert!(status.api_reachable, "health probe should answer");
if status.reported_status.is_none() {
assert!(
status.note.is_some(),
"an absent /v2/status must explain itself"
);
println!("status fallback: {}", status.note.unwrap_or_default());
} else {
println!("/v2/status is now deployed: {:?}", status.reported_status);
}
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn a_live_server_produces_a_launchable_uri() {
let page = client()
.servers(&ServerQuery {
per_page: 1,
..Default::default()
})
.await
.expect("list request");
let server = page.data.first().expect("one server");
let id = ServerId::parse(&server.id).expect("launchable id");
let uri = steam_join_uri(&id);
assert!(uri.starts_with("steam://run/1874880/"));
assert!(uri.ends_with('/'));
assert!(!uri.contains('='), "payload padding must be stripped");
println!("{} -> {uri}", server.name);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn repeated_identical_requests_are_served_from_cache() {
let client = client();
let query = ServerQuery {
per_page: 5,
..Default::default()
};
let started = std::time::Instant::now();
client.servers(&query).await.expect("first request");
let cold = started.elapsed();
let started = std::time::Instant::now();
client.servers(&query).await.expect("second request");
let warm = started.elapsed();
assert!(
warm < cold,
"second identical request should hit the cache (cold {cold:?}, warm {warm:?})"
);
println!("cold {cold:?}, warm {warm:?}");
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn history_is_ordered_and_bucketed_per_range() {
let client = client();
let id = ServerId::parse("73e8fb7f-8789-4d88-8aa8-ea69b9aa092f").unwrap();
for range in HistoryRange::OFFERED {
let history = client
.server_history(&id, range)
.await
.unwrap_or_else(|e| panic!("history {:?} failed: {e}", range.as_param()));
assert_eq!(history.range, range.as_param());
assert!(
!history.bucket.is_empty(),
"the API names the bucket width it chose"
);
assert!(
!history.points.is_empty(),
"{} had no points",
range.as_param()
);
// The chart draws in array order, so the series must be ascending.
assert!(
history.points.windows(2).all(|w| w[0].t < w[1].t),
"{} is not ordered by time",
range.as_param()
);
for point in &history.points {
assert!(point.min <= point.avg, "{point:?}");
assert!(point.avg <= point.max, "{point:?}");
}
println!(
"{:>4} -> {:4} points, bucket {}",
range.as_param(),
history.points.len(),
history.bucket
);
}
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn readiness_can_be_computed_from_a_live_server() {
use rfm_core::modscan::{AddonInventory, InstalledAddon, ScanError};
use rfm_core::readiness;
/// Pretends nothing is installed, so the figures exercise the missing path
/// without depending on the machine running the test.
struct Empty;
impl AddonInventory for Empty {
fn installed(&self) -> Result<Vec<InstalledAddon>, ScanError> {
Ok(Vec::new())
}
}
let client = client();
let page = client
.servers(&ServerQuery {
has_mods: Some(true),
per_page: 1,
..Default::default()
})
.await
.expect("list request");
let id = ServerId::parse(&page.data[0].id).expect("valid id");
let detail = client.server(&id).await.expect("detail").server;
let report = readiness::report(&detail.mods, &Empty);
assert!(report.available);
assert!(!report.is_ready(), "nothing is installed in this fixture");
assert_eq!(report.required, detail.mods.len() as u32);
assert_eq!(report.missing, report.required);
// At least some sizes should resolve on a real server, so the UI has a
// number to show rather than only "download size unknown".
assert!(
report.missing_bytes > 0,
"no mod sizes resolved for {}",
detail.summary.name
);
println!(
"{}: {} mods, at least {:.2} GiB, {} unresolved",
detail.summary.name,
report.required,
report.missing_bytes as f64 / (1024.0 * 1024.0 * 1024.0),
report.unresolved_mods
);
}
/// End-to-end readiness against a real addon store.
///
/// Point `RFM_ADDONS_DIR` at an Arma Reforger `addons` directory to exercise the
/// real scanner rather than a stub; the test is skipped when it is unset, so it
/// stays runnable on a machine without the game:
///
/// ```text
/// RFM_ADDONS_DIR="$HOME/Documents/My Games/ArmaReforger/addons" \
/// cargo test -p rfm-core --test live_api -- --ignored readiness_against_a_real
/// ```
#[tokio::test]
#[ignore = "hits the live reforgermods.net API and needs RFM_ADDONS_DIR"]
async fn readiness_against_a_real_addon_store() {
use rfm_core::modscan::{AddonInventory, FilesystemInventory};
use rfm_core::readiness;
let Some(dir) = std::env::var_os("RFM_ADDONS_DIR") else {
eprintln!("RFM_ADDONS_DIR is not set; skipping");
return;
};
let inventory = FilesystemInventory::new(dir);
let installed = inventory.installed().expect("addon store is readable");
assert!(!installed.is_empty(), "no addons found");
assert!(
installed.iter().all(|a| a.id.len() == 16),
"every detected id must be a 16-hex Workshop id"
);
println!("local store: {} addons", installed.len());
let client = client();
let page = client
.servers(&ServerQuery {
has_mods: Some(true),
per_page: 4,
..Default::default()
})
.await
.expect("list request");
for summary in &page.data {
let id = ServerId::parse(&summary.id).expect("valid id");
let Ok(detail) = client.server(&id).await else {
continue;
};
let detail = detail.server;
let report = readiness::report(&detail.mods, &inventory);
assert!(report.available, "a readable store must produce a report");
assert_eq!(report.required, detail.mods.len() as u32);
// The three buckets must account for every required mod exactly once.
assert_eq!(
report.up_to_date + report.outdated + report.missing,
report.required,
"counts do not partition the mod list"
);
assert_eq!(report.installed_total, installed.len() as u32);
println!(
"{:.46} req={:3} ok={:3} old={:2} miss={:3} >= {:.2} GiB",
detail.summary.name,
report.required,
report.up_to_date,
report.outdated,
report.missing,
report.missing_bytes as f64 / (1024.0 * 1024.0 * 1024.0),
);
}
}
#[tokio::test]
#[ignore = "sends ICMP echo requests to live game servers"]
async fn ping_measures_real_servers() {
use rfm_core::ping::{self, PingUnavailable};
use std::time::Duration;
let client = client();
let page = client
.servers(&ServerQuery {
per_page: 6,
..Default::default()
})
.await
.expect("list request");
let mut measured = 0;
let mut no_host = 0;
let mut probed = 0;
for summary in &page.data {
let id = ServerId::parse(&summary.id).expect("valid id");
// A rate-limited or transient detail fetch is not a contract failure;
// skip it rather than failing the measurement check.
let Ok(detail) = client.server(&id).await else {
continue;
};
let detail = detail.server;
let host = detail.host.as_deref();
if host.is_none() {
no_host += 1;
continue;
}
probed += 1;
let result = ping::measure(host, 3, Duration::from_millis(1500)).await;
match (&result.summary, result.unavailable) {
(Some(s), None) => {
measured += 1;
// A plausible internet round trip, not a middlebox artefact.
assert!(s.best_ms >= 1 && s.best_ms < 2000, "{s:?}");
assert!(s.median_ms >= s.best_ms, "median below best: {s:?}");
assert!(s.received > 0 && s.received <= s.sent);
println!(
" {:.40} {:>12} {:>4} ms (best {} ms, {}% loss)",
detail.summary.name,
detail.summary.ping_site_id.clone().unwrap_or_default(),
s.median_ms,
s.best_ms,
s.loss_percent()
);
}
(None, Some(reason)) => {
assert_eq!(
reason,
PingUnavailable::NoReply,
"a published literal IP should only fail by not replying"
);
println!(" {:.40} no reply", detail.summary.name);
}
other => panic!("a result must be one or the other: {other:?}"),
}
}
println!("hosts published: {probed}, unmeasurable: {no_host}, answered: {measured}");
assert!(probed > 0, "no server published a host");
assert!(
measured > 0,
"no server answered ICMP; the transport is not working"
);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn locally_built_join_uri_matches_the_api() {
// The API publishes `joinUrl` for each server. The launcher still builds its
// own from the validated id — an API-supplied URL must never reach the OS —
// but the two must agree, so this pins the format against the source of
// truth rather than against a single hardcoded vector.
let page = client()
.servers(&ServerQuery {
per_page: 20,
..Default::default()
})
.await
.expect("list request");
let mut compared = 0;
for server in &page.data {
let Some(published) = server.join_url.as_deref() else {
continue;
};
let id = ServerId::parse(&server.id).expect("launchable id");
assert_eq!(
steam_join_uri(&id),
published,
"locally built URI diverged for {}",
server.name
);
compared += 1;
}
assert!(
compared > 0,
"no server published a joinUrl to compare against"
);
println!("join URI matches the API for {compared} servers");
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn fleet_population_is_reported() {
let fleet = client().fleet_now().await.expect("fleet request").now;
assert!(fleet.players > 0, "no players reported");
assert!(fleet.online_servers > 0);
// Capacity is the sum of slots across listed servers, so it bounds players.
assert!(
fleet.players <= fleet.capacity,
"players {} exceed capacity {}",
fleet.players,
fleet.capacity
);
println!(
"{} players across {} servers (capacity {}, queue {})",
fleet.players, fleet.online_servers, fleet.capacity, fleet.queue
);
}
#[tokio::test]
#[ignore = "hits the live reforgermods.net API"]
async fn official_servers_are_joinable() {
// Official ids are not UUIDs; rejecting them would make 364 servers
// unjoinable, and the failure is invisible until someone clicks Join.
let page = client()
.servers(&ServerQuery {
official: Some(true),
per_page: 10,
..Default::default()
})
.await
.expect("official list");
assert!(!page.data.is_empty());
for server in &page.data {
let id = ServerId::parse(&server.id)
.unwrap_or_else(|e| panic!("official id {:?} not joinable: {e}", server.id));
assert!(id.is_official_form(), "{}", server.id);
let uri = steam_join_uri(&id);
assert!(uri.starts_with("steam://run/1874880//"));
}
println!("{} official servers all produce join URIs", page.data.len());
}
+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.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>reforgermods.net launcher</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3227
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "reforgermods-launcher",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"check": "tsc --noEmit",
"test": "vitest run",
"tauri": "tauri"
},
"dependencies": {
"@preact/signals": "^2.11.2",
"@tauri-apps/api": "^2.11.1",
"preact": "^10.29.8"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.6",
"@tauri-apps/cli": "^2.11.5",
"jsdom": "^29.1.1",
"typescript": "^5.9.3",
"vite": "^7.1.9",
"vitest": "^5.0.1"
}
}
+94
View File
@@ -0,0 +1,94 @@
# Maintainer: reforgermods.net
#
# Builds the launcher from a source tarball produced by scripts/build-arch.sh.
# The tarball is the working tree minus node_modules/target/dist, so this
# packages exactly what is checked out — not a tagged release.
pkgname=reforgermods-launcher
pkgver=0.1.0
pkgrel=1
pkgdesc="Server browser and direct launcher for Arma Reforger (reforgermods.net)"
arch=('x86_64')
url="https://reforgermods.net"
license=('LicenseRef-Proprietary')
# webkit2gtk-4.1 pulls gtk3, libsoup3, glib2 and the rest of the WebKit stack.
# The binary links rustls rather than OpenSSL, so there is no openssl dependency.
depends=('webkit2gtk-4.1' 'gtk3' 'libsoup3' 'glibc' 'gcc-libs')
# cmake builds aws-lc-sys, the C crypto library behind rustls' default
# provider. Without it the crate's build script produces no library and the
# link fails late with a wall of undefined aws_lc_* symbols.
makedepends=('rust' 'nodejs' 'npm' 'cmake')
# !lto: makepkg's default OPTIONS put -flto=auto into CFLAGS/LDFLAGS. That
# makes aws-lc-sys compile its C crypto into GCC LTO bitcode, which the
# linker rustc drives cannot resolve — the build dies at the very last step
# with a page of undefined aws_lc_* symbols. Rust-level LTO is unaffected:
# that comes from [profile.release] in the workspace manifest.
# !debug: the release profile already strips, so a -debug split package would
# be empty. One artifact to hand over is the point.
options=('!lto' '!debug')
source=("$pkgname-$pkgver.tar.gz")
sha256sums=('SKIP')
build() {
cd "$srcdir/$pkgname-$pkgver"
# The frontend is embedded into the binary by tauri-build, so it has to exist
# before cargo runs.
npm ci
npm run build
# --features custom-protocol is what makes this a production build: without
# it tauri-build sets cfg(dev) and the app loads its UI from the Vite dev
# server on localhost:1420 instead of the embedded assets. See the comment on
# that feature in src-tauri/Cargo.toml.
cargo build --release --locked -p reforgermods-launcher --features custom-protocol
# Assert the result is not a dev build. You cannot tell by inspecting the
# binary — tauri embeds the whole config, devUrl included, either way, and
# brotli-compresses the assets. The signal is the cargo:rustc-cfg=dev that
# tauri-build emits, recorded in the build script's output file. A dev build
# packages and installs perfectly and then shows ERR_CONNECTION_REFUSED on
# the tester's machine, which is exactly the report you cannot debug
# remotely.
local build_out
build_out=$(ls -t target/release/build/reforgermods-launcher-*/output | head -1)
if [[ -z "$build_out" ]]; then
echo "ERROR: could not find the build script output to verify the build mode." >&2
return 1
fi
if grep -q "rustc-cfg=dev" "$build_out"; then
echo "ERROR: this is a DEV build; --features custom-protocol did not take effect." >&2
return 1
fi
echo "build mode: production (frontend embedded, no dev-server dependency)"
}
check() {
cd "$srcdir/$pkgname-$pkgver"
# The launcher crate is included for the Linux-only startup workarounds in
# main.rs, which are exactly the code a Windows-first project is least likely
# to notice breaking.
cargo test --release --locked -p rfm-core -p reforgermods-launcher
}
package() {
cd "$srcdir/$pkgname-$pkgver"
install -Dm755 target/release/reforgermods-launcher \
"$pkgdir/usr/bin/reforgermods-launcher"
install -Dm644 packaging/arch/reforgermods-launcher.desktop \
"$pkgdir/usr/share/applications/reforgermods-launcher.desktop"
for size in 32 128; do
install -Dm644 "src-tauri/icons/${size}x${size}.png" \
"$pkgdir/usr/share/icons/hicolor/${size}x${size}/apps/reforgermods-launcher.png"
done
install -Dm644 src-tauri/icons/128x128@2x.png \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/reforgermods-launcher.png"
install -Dm644 src-tauri/icons/icon.png \
"$pkgdir/usr/share/icons/hicolor/512x512/apps/reforgermods-launcher.png"
}
@@ -0,0 +1,13 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=reforgermods.net launcher
GenericName=Arma Reforger Server Browser
Comment=Server browser and direct launcher for Arma Reforger.
Exec=reforgermods-launcher
Icon=reforgermods-launcher
Terminal=false
Categories=Game;Network;
Keywords=Arma;Reforger;server;browser;launcher;
StartupNotify=true
StartupWMClass=reforgermods-launcher
+52
View File
@@ -0,0 +1,52 @@
@echo off
REM Authenticode signing for one artifact, called by Tauri during bundling.
REM
REM Tauri invokes this once per bundled file, with the path as %1, and it must run
REM *during* the bundle rather than after it: the updater artifact is a zip of the
REM installer plus a minisign signature over that zip. Sign the installer after
REM bundling and the zip still holds the unsigned copy, while the signature it
REM ships describes a file nobody will ever download. Wiring this through
REM bundle.windows.signCommand is what keeps those two in step.
REM
REM Configuration comes from the environment so nothing about the signing identity
REM is committed. The credentials themselves are read from the environment by the
REM Azure SDK inside trusted-signing-cli (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET,
REM AZURE_TENANT_ID) and are never referenced here.
REM
REM RFM_SIGN_ENDPOINT e.g. https://eus.codesigning.azure.net
REM RFM_SIGN_ACCOUNT Trusted Signing account name
REM RFM_SIGN_PROFILE certificate profile name
REM
REM This deliberately fails the build when it is reachable but misconfigured. A
REM sign step that quietly does nothing produces an unsigned installer that looks
REM signed in the release notes, which is the one outcome worth crashing over.
setlocal
if "%~1"=="" (
echo sign.cmd: no file to sign was given 1>&2
exit /b 1
)
if "%RFM_SIGN_ENDPOINT%"=="" goto :missing
if "%RFM_SIGN_ACCOUNT%"=="" goto :missing
if "%RFM_SIGN_PROFILE%"=="" goto :missing
where trusted-signing-cli >nul 2>nul
if errorlevel 1 (
echo sign.cmd: trusted-signing-cli is not on PATH 1>&2
echo sign.cmd: install it with `cargo install trusted-signing-cli` 1>&2
exit /b 1
)
echo sign.cmd: signing %~nx1
trusted-signing-cli -e "%RFM_SIGN_ENDPOINT%" -a "%RFM_SIGN_ACCOUNT%" -c "%RFM_SIGN_PROFILE%" "%~1"
if errorlevel 1 (
echo sign.cmd: signing failed for %~nx1 1>&2
exit /b 1
)
exit /b 0
:missing
echo sign.cmd: RFM_SIGN_ENDPOINT, RFM_SIGN_ACCOUNT and RFM_SIGN_PROFILE must all be set 1>&2
exit /b 1
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Build a native Arch Linux package (.pkg.tar.zst) from this checkout.
#
# scripts/build-arch.sh [--no-check]
#
# The build runs inside an `archlinux:base-devel` container, so the binary is
# linked against Arch's own glibc and WebKit rather than this machine's. That
# matters: a Tauri app built on Debian links libwebkit2gtk-4.1 with Debian's
# soname and ABI, and handing that to an Arch user is how you get a blank
# window or a missing-symbol crash. Building on Arch removes the question.
#
# Output: target/arch/reforgermods-launcher-<version>-1-x86_64.pkg.tar.zst,
# installed on the target machine with `sudo pacman -U <file>`. pacman resolves
# webkit2gtk-4.1 and the rest, so there is nothing else to install by hand.
#
# Docker is only a clean Arch userspace here — nothing is deployed and no
# daemon is left running. --no-check skips the rfm-core test suite.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${RFM_ARCH_IMAGE:-rfm-arch-build}"
PKGBUILD="$REPO/packaging/arch/PKGBUILD"
STAGE="$REPO/target/arch"
MAKEPKG_ARGS=(--force --noconfirm --clean)
if [[ "${1:-}" == "--no-check" ]]; then
MAKEPKG_ARGS+=(--nocheck)
fi
command -v docker >/dev/null || { echo "docker is required" >&2; exit 1; }
docker info >/dev/null 2>&1 || { echo "the docker daemon is not reachable" >&2; exit 1; }
# 1. One version, agreed in both places.
#
# tauri.conf.json is what the app reports about itself, so it is the source of
# truth; the PKGBUILD carries its own copy because makepkg needs it literally.
# A silent disagreement here ships a package whose pacman version does not
# match the version in the window, so it is checked rather than assumed.
CONF_VERSION="$(sed -n 's/^ "version": "\(.*\)",$/\1/p' "$REPO/src-tauri/tauri.conf.json")"
PKG_VERSION="$(sed -n 's/^pkgver=\(.*\)$/\1/p' "$PKGBUILD")"
if [[ -z "$CONF_VERSION" || -z "$PKG_VERSION" ]]; then
echo "ERROR: could not read the version from tauri.conf.json or the PKGBUILD." >&2
exit 1
fi
if [[ "$CONF_VERSION" != "$PKG_VERSION" ]]; then
echo "ERROR: version mismatch — tauri.conf.json says $CONF_VERSION, PKGBUILD says $PKG_VERSION." >&2
echo "Update pkgver in packaging/arch/PKGBUILD (and reset pkgrel to 1)." >&2
exit 1
fi
VERSION="$CONF_VERSION"
# 2. The build image: Arch plus the toolchains, and a user whose uid matches
# this one so everything written to the bind mount stays owned by the caller.
if [[ -n "${RFM_ARCH_REBUILD_IMAGE:-}" ]] || ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "==> building the $IMAGE container image"
docker build -t "$IMAGE" \
--build-arg "UID=$(id -u)" --build-arg "GID=$(id -g)" - <<'DOCKERFILE'
FROM archlinux:base-devel
ARG UID=1000
ARG GID=1000
RUN pacman -Syu --noconfirm --needed rust nodejs npm webkit2gtk-4.1 cmake \
&& pacman -Scc --noconfirm
RUN groupadd -g "$GID" builder 2>/dev/null || true \
&& useradd -u "$UID" -g "$GID" -m -d /home/builder builder 2>/dev/null || true \
&& mkdir -p /cache/cargo /cache/npm /build \
&& chown -R "$UID:$GID" /cache /build /home/builder
USER $UID:$GID
ENV CARGO_HOME=/cache/cargo npm_config_cache=/cache/npm
WORKDIR /build
DOCKERFILE
fi
# 3. The source tarball makepkg consumes: the working tree, minus everything
# that is generated or host-specific. target/ especially — those are Debian
# build artifacts and cargo would happily reuse the stale ones.
echo "==> staging sources for $VERSION"
rm -rf "$STAGE"
mkdir -p "$STAGE"
tar --create --gzip --file "$STAGE/reforgermods-launcher-$VERSION.tar.gz" \
--directory "$REPO" \
--transform "s,^\.,reforgermods-launcher-$VERSION," \
--exclude=./.git \
--exclude=./node_modules \
--exclude=./target \
--exclude=./dist \
--exclude=./src-tauri/target \
--exclude=./src-tauri/gen/schemas \
.
cp "$PKGBUILD" "$STAGE/PKGBUILD"
# 4. Named volumes keep the cargo registry and npm cache across runs, so only
# the first build pays for downloading the dependency tree.
docker volume create rfm-arch-cargo >/dev/null
docker volume create rfm-arch-npm >/dev/null
docker run --rm --user root \
-v rfm-arch-cargo:/cache/cargo -v rfm-arch-npm:/cache/npm \
"$IMAGE" chown "$(id -u):$(id -g)" /cache/cargo /cache/npm
echo "==> running makepkg in $IMAGE"
docker run --rm \
-v "$STAGE:/build" \
-v rfm-arch-cargo:/cache/cargo \
-v rfm-arch-npm:/cache/npm \
"$IMAGE" makepkg "${MAKEPKG_ARGS[@]}"
PKG="$(ls -t "$STAGE"/*.pkg.tar.zst 2>/dev/null | head -1)"
if [[ -z "$PKG" ]]; then
echo "ERROR: makepkg produced no package." >&2
exit 1
fi
# 5. Checksum, for handing the file over.
#
# The package is already known to be a production build rather than a dev one:
# build() asserts that before packaging, which is the only point where the
# evidence still exists.
sha256sum "$PKG" | sed "s,$STAGE/,," > "$STAGE/SHA256SUMS"
echo
echo "built: $PKG"
ls -lh "$PKG"
echo
echo "install on Arch with: sudo pacman -U $(basename "$PKG")"
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Cross-compile a Windows executable from Linux — no Visual Studio, no root.
#
# scripts/build-windows.sh [--debug]
#
# Produces target/x86_64-pc-windows-msvc/<profile>/reforgermods-launcher.exe,
# a standalone binary (the frontend is embedded at compile time). It needs the
# WebView2 runtime on the target machine, which ships with current Windows.
#
# Release is the default, and for a distributable binary it is the only correct
# choice: tauri-build sets `cfg(dev)` for debug profiles, and a `dev` build
# loads the UI from the Vite dev server on localhost:1420 rather than from the
# embedded assets. Run a debug build on a machine without `npm run dev` going
# and you get a blank window. `--debug` exists for attaching a debugger, and
# the script checks the result and says so.
#
# How it works: cargo-xwin downloads the MSVC CRT and Windows SDK from
# Microsoft into ~/.cache/cargo-xwin (~1.5 GB, once), and LLVM's clang-cl and
# lld-link stand in for cl.exe and link.exe. The LLVM pieces are fetched as
# Debian packages and unpacked into a local prefix, so nothing is installed
# system-wide and no sudo is required.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TOOLS="${RFM_WIN_TOOLS:-$HOME/.cache/rfm-win-tools}"
LLVM_VERSION="${RFM_LLVM_VERSION:-19}"
PROFILE_ARGS=(--release)
PROFILE_DIR="release"
if [[ "${1:-}" == "--debug" ]]; then
PROFILE_ARGS=()
PROFILE_DIR="debug"
fi
PREFIX="$TOOLS/root/usr/lib/llvm-$LLVM_VERSION/bin"
# 1. LLVM cross-linking tools, unpacked locally.
if [[ ! -x "$PREFIX/lld-link" || ! -e "$PREFIX/clang-cl" ]]; then
echo "==> fetching LLVM $LLVM_VERSION tools into $TOOLS"
mkdir -p "$TOOLS"
(
cd "$TOOLS"
# clang provides clang-cl; lld provides lld-link; libclang-cpp is clang's runtime.
apt-get download "clang-$LLVM_VERSION" "lld-$LLVM_VERSION" "libclang-cpp$LLVM_VERSION"
for deb in *.deb; do dpkg-deb -x "$deb" ./root; done
)
# clang-cl is a driver mode of clang, not always shipped as its own file.
[[ -e "$PREFIX/clang-cl" ]] || ln -s clang "$PREFIX/clang-cl"
fi
export PATH="$PREFIX:/usr/lib/llvm-$LLVM_VERSION/bin:$PATH"
export LD_LIBRARY_PATH="$TOOLS/root/usr/lib/llvm-$LLVM_VERSION/lib:${LD_LIBRARY_PATH:-}"
for tool in clang-cl lld-link llvm-rc llvm-lib; do
command -v "$tool" >/dev/null || { echo "missing $tool" >&2; exit 1; }
done
# 2. cargo-xwin, which supplies the MSVC CRT and Windows SDK.
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
# cargo-xwin keeps its own clang-cl symlink and refuses to start if a stale one
# points somewhere else (for example a previous checkout's tools prefix).
XWIN_LINK="${XWIN_CACHE_DIR:-$HOME/.cache/cargo-xwin}/clang-cl"
if [[ -L "$XWIN_LINK" && "$(readlink -f "$XWIN_LINK")" != "$(readlink -f "$PREFIX/clang-cl")" ]]; then
rm -f "$XWIN_LINK"
fi
# 3. The frontend, embedded into the binary by tauri-build.
echo "==> building frontend"
cd "$REPO"
npm run build
echo "==> cross-compiling for x86_64-pc-windows-msvc"
# --features custom-protocol is what makes this a production build; see the
# comment on that feature in src-tauri/Cargo.toml.
cargo xwin build --target x86_64-pc-windows-msvc -p reforgermods-launcher \
--features custom-protocol "${PROFILE_ARGS[@]}"
EXE="$REPO/target/x86_64-pc-windows-msvc/$PROFILE_DIR/reforgermods-launcher.exe"
echo
echo "built: $EXE"
ls -lh "$EXE"
# Assert the binary is not a dev build.
#
# You cannot check this by grepping the exe: Tauri brotli-compresses the
# embedded assets, and it embeds the whole config (devUrl included) either way.
# The signal is `cargo:rustc-cfg=dev` from tauri-build, which cargo records in
# the build script's output file. A dev build looks completely fine here and
# fails with ERR_CONNECTION_REFUSED on the target machine, so it is worth
# asserting rather than assuming.
BUILD_OUT=$(ls -t "$REPO/target/x86_64-pc-windows-msvc/$PROFILE_DIR"/build/reforgermods-launcher-*/output 2>/dev/null | head -1)
if [[ -z "$BUILD_OUT" ]]; then
echo "WARNING: could not find the build script output to verify the build mode." >&2
elif grep -q "rustc-cfg=dev" "$BUILD_OUT"; then
echo
echo "ERROR: this is a DEV build. It loads its UI from the Vite dev server on" >&2
echo "localhost:1420 and will show ERR_CONNECTION_REFUSED anywhere else." >&2
echo "The --features custom-protocol flag did not take effect." >&2
exit 1
else
echo "build mode: production (frontend embedded, no dev-server dependency)"
fi
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Derive the launcher's icons from the reforgermods.net brand assets.
No image libraries, so this is reproducible from a clean checkout with nothing
but a Python interpreter. Run from the repo root:
python3 scripts/gen_icons.py
Sources (from the reforgermods.net site assets, copied into brand/):
reforger-mods-app-icon.png -> application / taskbar icon, all sizes
reforger-mods-icon-dark.png -> transparent mark used in the app header
Downsampling is a box filter on premultiplied alpha. Premultiplying matters:
averaging straight RGBA makes fully transparent pixels drag their colour into
the edge, which shows up as a dark halo around the mark at 16px.
"""
import struct
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BRAND = ROOT / "brand"
ICONS = ROOT / "src-tauri" / "icons"
UI_ASSETS = ROOT / "src" / "assets"
# ----------------------------------------------------------------- decoding
def read_png(path):
"""Decodes an 8-bit RGBA PNG into (width, height, rows of bytes)."""
data = path.read_bytes()
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError(f"{path} is not a PNG")
width = height = None
idat = bytearray()
pos = 8
while pos < len(data):
length, tag = struct.unpack_from(">I4s", data, pos)
body = data[pos + 8 : pos + 8 + length]
if tag == b"IHDR":
width, height, depth, colour = struct.unpack(">IIBB", body[:10])
if depth != 8 or colour != 6:
raise ValueError(f"{path}: expected 8-bit RGBA, got depth={depth} colour={colour}")
elif tag == b"IDAT":
idat += body
elif tag == b"IEND":
break
pos += 12 + length
raw = zlib.decompress(bytes(idat))
stride = width * 4
rows = []
previous = bytearray(stride)
pos = 0
for _ in range(height):
filter_type = raw[pos]
pos += 1
line = bytearray(raw[pos : pos + stride])
pos += stride
unfilter(filter_type, line, previous, 4)
rows.append(bytes(line))
previous = line
return width, height, rows
def unfilter(filter_type, line, previous, bpp):
"""Reverses one PNG scanline filter in place (RFC 2083 section 6)."""
if filter_type == 0:
return
for i in range(len(line)):
a = line[i - bpp] if i >= bpp else 0
b = previous[i]
if filter_type == 1:
line[i] = (line[i] + a) & 0xFF
elif filter_type == 2:
line[i] = (line[i] + b) & 0xFF
elif filter_type == 3:
line[i] = (line[i] + ((a + b) >> 1)) & 0xFF
elif filter_type == 4:
c = previous[i - bpp] if i >= bpp else 0
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
pred = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
line[i] = (line[i] + pred) & 0xFF
else:
raise ValueError(f"unknown filter {filter_type}")
# --------------------------------------------------------------- resampling
def resize(width, height, rows, size):
"""Box-filter downsample to size x size, averaging premultiplied alpha."""
out = []
for oy in range(size):
y0, y1 = oy * height // size, max(oy * height // size + 1, (oy + 1) * height // size)
row = bytearray()
for ox in range(size):
x0, x1 = ox * width // size, max(ox * width // size + 1, (ox + 1) * width // size)
r = g = b = a = 0
count = 0
for y in range(y0, y1):
line = rows[y]
for x in range(x0, x1):
i = x * 4
alpha = line[i + 3]
# Premultiply so transparent pixels contribute no colour.
r += line[i] * alpha
g += line[i + 1] * alpha
b += line[i + 2] * alpha
a += alpha
count += 1
if a == 0:
row += bytes(4)
else:
row += bytes((r // a, g // a, b // a, a // count))
out.append(bytes(row))
return out
# ----------------------------------------------------------------- encoding
def write_png(rows, size):
def chunk(tag, body):
payload = tag + body
return struct.pack(">I", len(body)) + payload + struct.pack(">I", zlib.crc32(payload))
raw = b"".join(b"\x00" + r for r in rows)
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b"")
)
def write_ico(entries):
"""Packs PNG-encoded images into an .ico container."""
header = struct.pack("<HHH", 0, 1, len(entries))
offset = 6 + 16 * len(entries)
directory, blobs = b"", b""
for size, blob in entries:
directory += struct.pack(
"<BBBBHHII", size % 256, size % 256, 0, 0, 1, 32, len(blob), offset
)
blobs += blob
offset += len(blob)
return header + directory + blobs
# --------------------------------------------------------------------- main
def main():
ICONS.mkdir(parents=True, exist_ok=True)
UI_ASSETS.mkdir(parents=True, exist_ok=True)
app_w, app_h, app_rows = read_png(BRAND / "reforger-mods-app-icon.png")
cache = {}
def app_at(size):
if size not in cache:
cache[size] = write_png(resize(app_w, app_h, app_rows, size), size)
return cache[size]
for name, size in [
("32x32.png", 32),
("128x128.png", 128),
("128x128@2x.png", 256),
("icon.png", 512),
]:
(ICONS / name).write_bytes(app_at(size))
print(f"wrote src-tauri/icons/{name} ({size}x{size})")
(ICONS / "icon.ico").write_bytes(write_ico([(s, app_at(s)) for s in (16, 32, 48, 256)]))
print("wrote src-tauri/icons/icon.ico (16, 32, 48, 256)")
# Transparent mark for the in-app header. 64px covers an 18px slot at 3x.
mark_w, mark_h, mark_rows = read_png(BRAND / "reforger-mods-icon-dark.png")
blob = write_png(resize(mark_w, mark_h, mark_rows, 64), 64)
(UI_ASSETS / "brand-mark.png").write_bytes(blob)
print(f"wrote src/assets/brand-mark.png (64x64, {len(blob)} bytes)")
if __name__ == "__main__":
main()
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Build the two release manifests from a directory of artifacts.
scripts/release-manifest.py --version 0.1.0 --artifacts dist/release \
--base-url https://dl.reforgermods.net --out dist/release
Writes two files, because two different consumers need two different shapes and
conflating them is how a download page and an updater drift apart:
releases.json what the website's /launcher/ page reads. Every download it
should offer, with size and SHA-256 so a visitor can verify.
Consumed by public/static/launcher.js in reforgermods-web.
latest.json what tauri-plugin-updater polls. Only the platforms that have
an updater artifact and a signature, in Tauri's own schema.
Both are generated from the same scan of the same directory, so the version, the
URLs and the filenames cannot disagree between them.
Artifacts are recognised by suffix rather than listed, so a build that fails to
produce one is a manifest that is missing it — not a manifest with a dead link.
"""
import argparse
import hashlib
import json
import pathlib
import sys
from datetime import datetime, timezone
# Suffix -> (platform id understood by launcher.js, kind, human label).
# Order matters: the longest, most specific suffix has to be tested first, or
# "-setup.exe" is matched by the portable ".exe" rule.
ARTIFACT_RULES = [
(".nsis.zip", "windows", "updater", "Updater package"),
("-setup.exe", "windows", "installer", "Installer, Windows 10 or 11 64-bit"),
# An NSIS bundle is signed in place: the updater downloads the same
# -setup.exe a visitor does, so that file is both a download and the updater
# artifact. Any artifact with a sibling .sig is treated as one, below.
(".pkg.tar.zst", "arch", "pacman", "Arch Linux package"),
(".deb", "debian", "deb", "Debian / Ubuntu package"),
(".AppImage", "appimage", "appimage", "AppImage, no install"),
(".rpm", "fedora", "rpm", "Fedora package"),
(".exe", "windows", "portable", "Portable, no installer"),
]
# The updater target triple Tauri looks for in latest.json.
UPDATER_TARGETS = {"windows": "windows-x86_64"}
def classify(name):
for suffix, platform, kind, label in ARTIFACT_RULES:
if name.endswith(suffix):
return platform, kind, label
return None
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--version", required=True)
parser.add_argument("--artifacts", required=True, type=pathlib.Path)
parser.add_argument("--base-url", required=True, help="e.g. https://dl.reforgermods.net")
parser.add_argument("--out", required=True, type=pathlib.Path)
parser.add_argument("--notes-url", default="https://reforgermods.net/launcher/changelog/")
parser.add_argument(
"--notes",
default="",
help="Release notes shown by the updater. Kept short: it is a dialog, not a changelog.",
)
parser.add_argument(
"--published-at",
default=None,
help="RFC3339 timestamp. Defaults to now, in UTC.",
)
args = parser.parse_args()
version = args.version.lstrip("v")
base = args.base_url.rstrip("/")
prefix = f"{base}/{version}"
published = args.published_at or datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
if not args.artifacts.is_dir():
sys.exit(f"not a directory: {args.artifacts}")
downloads, updater_platforms, skipped = [], {}, []
# Signatures sit beside their artifact as <artifact>.sig; they are read, never
# listed as downloads.
for path in sorted(p for p in args.artifacts.iterdir() if p.is_file()):
if path.suffix == ".sig" or path.name in ("releases.json", "latest.json", "SHA256SUMS"):
continue
classified = classify(path.name)
if classified is None:
skipped.append(path.name)
continue
platform, kind, label = classified
url = f"{prefix}/{path.name}"
# A sibling .sig makes this the platform's updater artifact, whether or
# not it is also a download. An unsigned one is never offered: the client
# rejects it, so publishing it means every launch checks, fails, retries.
signature = path.with_name(path.name + ".sig")
target = UPDATER_TARGETS.get(platform)
if signature.is_file():
if target is None:
skipped.append(f"{path.name} (signed, but no updater target for {platform})")
elif target in updater_platforms:
skipped.append(f"{path.name} (a {target} updater artifact was already chosen)")
else:
updater_platforms[target] = {
"signature": signature.read_text(encoding="utf-8").strip(),
"url": url,
}
elif kind == "updater":
skipped.append(f"{path.name} (no {signature.name}; not offered to the updater)")
if kind == "updater":
# A dedicated updater package is not a human download.
continue
downloads.append(
{
"platform": platform,
"kind": kind,
"label": label,
"url": url,
"size": path.stat().st_size,
"sha256": sha256(path),
}
)
# launcher.js offers the first entry whose platform matches as the primary
# download, so an installer has to come before a portable build of the same
# platform. Within a platform, installer first; otherwise by platform id for
# a stable file.
kind_order = {"installer": 0, "pacman": 0, "deb": 0, "rpm": 0, "appimage": 1, "portable": 2}
# Windows leads because that is where the players are; the rest follow in the
# order the download table should read.
platform_order = {"windows": 0, "debian": 1, "arch": 2, "fedora": 3, "appimage": 4}
downloads.sort(key=lambda d: (platform_order.get(d["platform"], 9), kind_order.get(d["kind"], 9)))
args.out.mkdir(parents=True, exist_ok=True)
releases = {
"version": version,
"publishedAt": published,
"notesUrl": args.notes_url,
"downloads": downloads,
}
(args.out / "releases.json").write_text(json.dumps(releases, indent=2) + "\n", encoding="utf-8")
latest = {
"version": version,
"notes": args.notes or f"reforgermods.net launcher {version}",
"pub_date": published,
"platforms": updater_platforms,
}
(args.out / "latest.json").write_text(json.dumps(latest, indent=2) + "\n", encoding="utf-8")
print(f"version {version}, published {published}")
for download in downloads:
print(f" download {download['platform']:9} {download['kind']:9} {download['size']:>10,} B {download['url']}")
for target, entry in updater_platforms.items():
print(f" updater {target} {entry['url']}")
for name in skipped:
print(f" skipped {name}")
if not downloads:
sys.exit("no downloadable artifacts were found: refusing to write an empty manifest")
if not updater_platforms:
# Not fatal: a Linux-only or unsigned build is a legitimate state. It is
# called out because it means the updater will find nothing.
print(" WARNING: latest.json has no platforms; the updater will never fire")
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# The launcher's version, in one place.
#
# 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 has to be repeated in four places — Cargo's workspace manifest, package.json,
# tauri.conf.json and the PKGBUILD — because no two of those tools can read each
# other's format. tauri.conf.json could inherit from Cargo.toml by omitting the
# field, but the src-tauri crate declares `version.workspace = true` and Tauri
# parses that manifest itself rather than resolving workspace inheritance, so the
# value has to be written out. `check` is what makes the repetition safe.
#
# This matters more than tidiness: an updater whose manifest version disagrees
# with the installed version either loops forever or never fires
# (docs/distribution.md section 6).
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO"
canonical() {
# The first `version = "x.y.z"` under [workspace.package].
awk '/^\[workspace\.package\]/{f=1;next} /^\[/{f=0} f && /^version *=/{gsub(/[^0-9a-zA-Z.\-+]/,"",$3);print $3;exit}' Cargo.toml
}
read_package_json() { python3 -c 'import json;print(json.load(open("package.json"))["version"])'; }
read_tauri_conf() { python3 -c 'import json;print(json.load(open("src-tauri/tauri.conf.json"))["version"])'; }
read_pkgbuild() { sed -n 's/^pkgver=\(.*\)$/\1/p' packaging/arch/PKGBUILD; }
SEMVER='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'
cmd_show() { canonical; }
cmd_check() {
local want tag="${1:-}" status=0
want="$(canonical)"
if [[ ! "$want" =~ $SEMVER ]]; then
echo "Cargo.toml [workspace.package] version is not semver: '$want'" >&2
return 1
fi
local name value
for name in package.json src-tauri/tauri.conf.json packaging/arch/PKGBUILD; do
case "$name" in
package.json) value="$(read_package_json)" ;;
src-tauri/tauri.conf.json) value="$(read_tauri_conf)" ;;
packaging/arch/PKGBUILD) value="$(read_pkgbuild)" ;;
esac
if [[ "$value" != "$want" ]]; then
echo "$name: $value, want $want (run: scripts/version.sh set $want)" >&2
status=1
fi
done
# A tag is checked only when one is given, so the same command serves CI on a
# tag push and a developer on a branch. Both `1.2.3` and `v1.2.3` are accepted.
if [[ -n "$tag" && "${tag#v}" != "$want" ]]; then
echo "tag $tag does not match version $want" >&2
status=1
fi
if [[ $status -eq 0 ]]; then
echo "version $want is consistent${tag:+ with tag $tag}"
fi
return $status
}
cmd_set() {
local want="${1:?usage: scripts/version.sh set <version>}"
want="${want#v}"
if [[ ! "$want" =~ $SEMVER ]]; then
echo "not a semver version: '$want'" >&2
return 1
fi
python3 - "$want" <<'PY'
import io, json, re, sys
want = sys.argv[1]
# Cargo.toml: only the [workspace.package] version, never a dependency's.
path = "Cargo.toml"
text = io.open(path, encoding="utf-8").read()
def bump_workspace_version(match):
return match.group(1) + '"' + want + '"'
new, count = re.subn(r'(\[workspace\.package\]\n(?:[^\[]*?\n)?version *= *)"[^"]*"', bump_workspace_version, text)
assert count == 1, f"{path}: matched {count} workspace versions, expected 1"
io.open(path, "w", encoding="utf-8").write(new)
# package.json / tauri.conf.json: rewrite the single field, preserving formatting
# (a json.dump round trip would reformat files a human maintains).
for path, key in (("package.json", "version"), ("src-tauri/tauri.conf.json", "version")):
text = io.open(path, encoding="utf-8").read()
new, count = re.subn(r'("%s"\s*:\s*)"[^"]*"' % key, lambda m: m.group(1) + '"' + want + '"', text, count=1)
assert count == 1, f"{path}: no {key} field"
json.loads(new)
io.open(path, "w", encoding="utf-8").write(new)
path = "packaging/arch/PKGBUILD"
text = io.open(path, encoding="utf-8").read()
new, count = re.subn(r'(?m)^pkgver=.*$', "pkgver=" + want, text, count=1)
assert count == 1, f"{path}: no pkgver"
io.open(path, "w", encoding="utf-8").write(new)
print("set version", want)
PY
cmd_check
}
case "${1:-show}" in
show) cmd_show ;;
check) shift; cmd_check "$@" ;;
set) shift; cmd_set "$@" ;;
*) echo "usage: scripts/version.sh [show|check [tag]|set <version>]" >&2; exit 2 ;;
esac
+64
View File
@@ -0,0 +1,64 @@
[package]
name = "reforgermods-launcher"
description = "reforgermods.net launcher: a fast desktop server browser and direct launcher for Arma Reforger."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[lib]
# Tauri builds the app as a library so the mobile targets can link it; the
# desktop binary in main.rs is a thin wrapper around run().
name = "reforgermods_launcher_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[features]
# Tauri decides dev-vs-production from this feature, NOT from the cargo profile.
# `tauri`'s build script computes `dev = !custom_protocol`, and in dev mode
# `generate_context!` makes the app load its UI from `build.devUrl`
# (http://localhost:1420) instead of the embedded frontend. Without this a
# release build still ships as a dev build and shows ERR_CONNECTION_REFUSED on
# any machine that is not running `npm run dev`.
#
# `cargo tauri build` passes this automatically; a direct `cargo build` must be
# given `--features custom-protocol`. See scripts/build-windows.sh.
custom-protocol = ["tauri/custom-protocol"]
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
rfm-core = { path = "../crates/rfm-core" }
tauri = { version = "2.11.6", features = [] }
tauri-plugin-opener = "2.5.5"
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
discord-rich-presence = "1.1.0"
# Windows only, on purpose. The updater replaces the installed application in
# place, which is correct for an NSIS per-user install and wrong for the Arch
# package: a distro package is updated by the distro's tooling, not by the app
# rewriting its own files under /usr/bin (docs/distribution.md section 7).
# Scoping it here rather than with cfg! keeps the dependency out of the Linux
# build entirely.
[target.'cfg(target_os = "windows")'.dependencies]
# default-features = false is not tidiness: the plugin's defaults include
# `rustls-tls`, and because Cargo unions features across a shared dependency that
# switches the *whole binary's* reqwest onto rustls. That pulls in ring, undoing
# the deliberate schannel choice in rfm-core/Cargo.toml — no bundled C crypto to
# build, and the machine's own trust store, which is what works behind corporate
# TLS inspection. It also does not cross-compile: ring's build script fails under
# cargo-xwin, so the mistake shows up as a broken Windows build rather than a
# silently different TLS stack.
#
# `zip` is kept although the current NSIS bundle is signed in place and ships as
# a plain -setup.exe: Tauri emits a zipped artifact for some bundle types, and a
# plugin that cannot unpack one fails on the user's machine at install time,
# which is the worst place to discover a missing feature flag.
tauri-plugin-updater = { version = "2.12.0", default-features = false, features = [
"native-tls",
"system-proxy",
"zip",
] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "Minimum permissions for the launcher window. The window is frameless, so the UI may drag it and operate its own caption buttons — and nothing else. The UI may only invoke this app's own commands; it is granted no filesystem, shell, HTTP or opener access. URIs are opened from Rust, after the server id has been validated.",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

+518
View File
@@ -0,0 +1,518 @@
//! The Tauri command surface.
//!
//! This is the only boundary the UI can call across, so it is kept deliberately
//! small. Commands orchestrate; they contain no HTTP, no URI building and no
//! persistence rules of their own — those live in `rfm-core` where they are
//! unit-tested.
//!
//! Untrusted API text never reaches an OS call here. The two commands that hand
//! a URL to the operating system ([`join_server`] and [`open_server_page`]) both
//! build it from a validated [`rfm_core::ServerId`], never from an API-supplied
//! URL field.
use serde::Serialize;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
use rfm_core::api::models::{
DatasetMeta, FleetNow, LauncherStatus, PingSite, ServerDetail, ServerHistory, ServerSummary,
};
use rfm_core::api::{ApiError, HistoryRange, ServerQuery};
use rfm_core::launch::{steam_join_uri, steam_launch_uri, LaunchError};
use rfm_core::modscan::FilesystemInventory;
use rfm_core::ping::{self, PingResult};
use rfm_core::presence::Activity;
use rfm_core::readiness::{self, ReadinessReport, Unavailable};
use rfm_core::store::{now_unix, LaunchRecord, LocalState, RecentServer, Settings, StoreError};
use rfm_core::ServerId;
use std::collections::HashMap;
use crate::state::AppState;
/// An error the UI can branch on.
///
/// `kind` is a stable slug; `message` is display text. Nothing here leaks a file
/// path or an internal type name.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandError {
pub kind: String,
pub message: String,
/// Set for rate limiting, so the UI can say how long to wait.
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_after_secs: Option<u64>,
}
impl From<ApiError> for CommandError {
fn from(err: ApiError) -> Self {
let retry_after_secs = match &err {
ApiError::RateLimited { retry_after } => *retry_after,
_ => None,
};
Self {
kind: err.kind().to_string(),
message: err.to_string(),
retry_after_secs,
}
}
}
impl From<LaunchError> for CommandError {
fn from(err: LaunchError) -> Self {
Self {
kind: "invalid_server_id".into(),
message: err.to_string(),
retry_after_secs: None,
}
}
}
impl From<StoreError> for CommandError {
fn from(err: StoreError) -> Self {
Self {
kind: "storage".into(),
// Deliberately not including the path: it names the user's profile.
message: "Could not save launcher settings.".into(),
retry_after_secs: None,
}
.tap_log(&err)
}
}
impl CommandError {
/// Logs the underlying cause to stderr while returning the sanitised error.
fn tap_log(self, cause: &dyn std::error::Error) -> Self {
eprintln!("[reforgermods-launcher] {}: {cause}", self.kind);
self
}
fn other(kind: &str, message: impl Into<String>) -> Self {
Self {
kind: kind.into(),
message: message.into(),
retry_after_secs: None,
}
}
}
type CommandResult<T> = Result<T, CommandError>;
/// One page of the server browser.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerListResult {
pub servers: Vec<ServerSummary>,
/// Total servers the API matched, before client-side filtering.
pub total_servers: u32,
pub total_pages: u32,
pub current_page: u32,
/// How many of the fetched servers the client-side filters removed, so the
/// UI can say so instead of silently showing fewer rows.
pub filtered_out: u32,
pub dataset: DatasetMeta,
}
/// Diagnostic view of the install identity, for debug mode.
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallIdentity {
pub id: String,
/// True when the identifier survives a reinstall.
pub machine_derived: bool,
}
/// Everything the UI needs from local storage in one call at startup.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalStateDto {
pub settings: Settings,
pub favorites: Vec<String>,
pub recents: Vec<RecentServer>,
pub history: Vec<LaunchRecord>,
}
impl From<&LocalState> for LocalStateDto {
fn from(s: &LocalState) -> Self {
Self {
settings: s.settings.clone(),
favorites: s.favorites.clone(),
recents: s.recents.clone(),
history: s.history.clone(),
}
}
}
/// `GET /v2/servers` with the browser's current filters.
#[tauri::command]
pub async fn list_servers(
state: State<'_, AppState>,
query: ServerQuery,
) -> CommandResult<ServerListResult> {
let page = state.client().servers(&query).await?;
state.remember_dataset(page.dataset.clone());
state.publish_presence(Activity::Browsing {
online_servers: page.dataset.online_server_count,
});
let fetched = page.data.len() as u32;
let servers: Vec<ServerSummary> = page
.data
.into_iter()
.filter(|s| query.retains(s.players))
.collect();
Ok(ServerListResult {
filtered_out: fetched - servers.len() as u32,
servers,
total_servers: page.meta.total_servers,
total_pages: page.meta.total_pages,
current_page: page.meta.current_page,
dataset: page.dataset,
})
}
/// `GET /v2/servers/{id}`, and record the server in recents.
#[tauri::command]
pub async fn get_server(state: State<'_, AppState>, id: String) -> CommandResult<ServerDetail> {
let id = ServerId::parse(&id)?;
let response = state.client().server(&id).await?;
state.remember_dataset(response.dataset.clone());
let name = response.server.summary.name.clone();
state.publish_presence(Activity::Viewing {
server_name: &name,
players: response.server.summary.players,
max_players: response.server.summary.max_players,
});
// A failure to persist a recents entry must not fail the lookup the user asked for.
let _ = state
.store
.write(|s| s.touch_recent(id.as_str(), &name, now_unix()));
Ok(response.server)
}
/// `GET /v2/servers/{id}/history` for one range.
#[tauri::command]
pub async fn get_server_history(
state: State<'_, AppState>,
id: String,
range: HistoryRange,
) -> CommandResult<ServerHistory> {
let id = ServerId::parse(&id)?;
Ok(state.client().server_history(&id, range).await?)
}
/// Compares a server's required mods against the local Arma Reforger addon store.
///
/// Reporting only. There is no verified way to install Reforger Workshop content
/// from outside the game, so this command has no counterpart that acts on the
/// result — see `docs/mod-readiness.md`.
///
/// The server detail comes from the client's cache in the common case, because
/// the inspector has just fetched it, so this costs a directory scan and no HTTP.
#[tauri::command]
pub async fn get_mod_readiness(
state: State<'_, AppState>,
id: String,
) -> CommandResult<ReadinessReport> {
let id = ServerId::parse(&id)?;
let detail = state.client().server(&id).await?;
let mods = detail.server.mods;
// Reading a few hundred small files is blocking IO; keep it off the async
// runtime's worker threads.
tokio::task::spawn_blocking(move || match FilesystemInventory::discover() {
Ok(inventory) => readiness::report(&mods, &inventory),
Err(_) => ReadinessReport::unavailable(Unavailable::NoAddonsDirectory),
})
.await
.map_err(|err| {
CommandError::other("scan_failed", "Could not check locally installed mods.").tap_log(&err)
})
}
/// Measures round-trip latency to a server by ICMP echo.
///
/// The address comes from the API and is validated by
/// [`rfm_core::ping::parse_target`] before a packet is sent: literal public IPs
/// only, so remote data cannot aim the launcher at a hostname of its choosing
/// or at the user's own network.
///
/// Never errors on an unreachable host — it returns a result that states why
/// there is no figure, because "no reply" is information, not a failure.
#[tauri::command]
pub async fn ping_server(state: State<'_, AppState>, id: String) -> CommandResult<PingResult> {
let id = ServerId::parse(&id)?;
// Served from cache in the common case: the inspector just fetched it.
let detail = state.client().server(&id).await?;
Ok(ping::measure(
detail.server.host.as_deref(),
ping::DEFAULT_ATTEMPTS,
ping::DEFAULT_TIMEOUT,
)
.await)
}
/// Measures latency to every published ping site, keyed by site id.
///
/// This is what makes a ping column in the browser possible. `/v2/servers` does
/// not publish a 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 whole list.
///
/// The figure is therefore latency to the server's *region*, not to the server.
/// Measured against real servers the two agree within roughly 20 ms, which is
/// enough to sort by and is what the game's own browser shows. The inspector
/// still measures the selected server exactly.
///
/// Sites are probed concurrently; one unreachable site cannot hold up the rest.
#[tauri::command]
pub async fn ping_regions(
state: State<'_, AppState>,
) -> CommandResult<HashMap<String, PingResult>> {
let sites = state.client().ping_sites().await?.sites;
let probes: Vec<_> = sites
.into_iter()
.filter_map(|site| {
let address = site.address?;
Some(tokio::spawn(async move {
let result =
ping::measure_address(&address, ping::DEFAULT_ATTEMPTS, ping::DEFAULT_TIMEOUT)
.await;
(site.id, result)
}))
})
.collect();
let mut out = HashMap::with_capacity(probes.len());
for probe in probes {
if let Ok((id, result)) = probe.await {
out.insert(id, result);
}
}
Ok(out)
}
/// Most servers resolvable in one call. Each costs a request, and the public
/// limit is 60/minute; favorites and recents are both bounded below this.
const MAX_RESOLVE: usize = 40;
/// Requests in flight while resolving. Enough to feel instant, low enough not
/// to burst the rate limiter.
const RESOLVE_CONCURRENCY: usize = 4;
/// Resolves a list of server ids to current summaries.
///
/// Backs the favorites and recents views, which hold ids rather than servers and
/// so cannot render a live table from local state alone. Results keep the input
/// order; ids that no longer resolve are dropped rather than failing the call,
/// because a favorite disappearing from the index is normal and the rest of the
/// list is still useful.
///
/// `/v2/servers` cannot filter by id, so this is one detail request per server.
/// That is affordable only because both lists are small — hence [`MAX_RESOLVE`].
#[tauri::command]
pub async fn get_servers(
state: State<'_, AppState>,
ids: Vec<String>,
) -> CommandResult<Vec<ServerSummary>> {
let wanted: Vec<ServerId> = ids
.iter()
.filter_map(|id| ServerId::parse(id).ok())
.take(MAX_RESOLVE)
.collect();
let limit = std::sync::Arc::new(tokio::sync::Semaphore::new(RESOLVE_CONCURRENCY));
let mut tasks = Vec::with_capacity(wanted.len());
for id in wanted {
let client = state.client();
let limit = limit.clone();
tasks.push(tokio::spawn(async move {
let _permit = limit.acquire().await.ok()?;
client.server(&id).await.ok().map(|r| r.server.summary)
}));
}
let mut out = Vec::with_capacity(tasks.len());
for task in tasks {
if let Ok(Some(summary)) = task.await {
out.push(summary);
}
}
Ok(out)
}
/// Players online across the whole fleet, for the header.
#[tauri::command]
pub async fn get_fleet_now(state: State<'_, AppState>) -> CommandResult<FleetNow> {
Ok(state.client().fleet_now().await?.now)
}
/// Ping sites, used to label regions in the filter bar.
#[tauri::command]
pub async fn list_ping_sites(state: State<'_, AppState>) -> CommandResult<Vec<PingSite>> {
Ok(state.client().ping_sites().await?.sites)
}
/// The compact API status for the header. Never fails; an unreachable API
/// produces an offline reading.
#[tauri::command]
pub async fn get_status(state: State<'_, AppState>) -> CommandResult<LauncherStatus> {
let hint = state.last_dataset();
// `launcher_status` absorbs every failure into an offline/unknown reading,
// so this is infallible; the Result is only Tauri's async command contract.
Ok(state.client().launcher_status(hint).await)
}
/// Joins a server directly through Steam.
///
/// The id is validated first, so the URI handed to the OS contains nothing but
/// hex digits and dashes. `name` is only ever written to the local history file.
#[tauri::command]
pub async fn join_server(
app: tauri::AppHandle,
state: State<'_, AppState>,
id: String,
name: String,
) -> CommandResult<String> {
let id = ServerId::parse(&id)?;
// steam://run/<appid>//<payload>/ — the empty segment marks the payload as a
// launch argument. See `rfm_core::launch` for why the single-slash form
// starts the game without joining.
let uri = steam_join_uri(&id);
state.publish_presence(Activity::Joining { server_name: &name });
let result = app.opener().open_url(&uri, None::<&str>);
let succeeded = result.is_ok();
let _ = state
.store
.write(|s| s.record_launch(id.as_str(), &name, succeeded, now_unix()));
match result {
Ok(()) => Ok(uri),
Err(err) => Err(CommandError::other(
"launch_failed",
"Could not hand the join request to Steam. Is Steam installed?",
)
.tap_log(&err)),
}
}
/// Starts Arma Reforger without joining a server.
#[tauri::command]
pub async fn launch_game(app: tauri::AppHandle) -> CommandResult<()> {
app.opener()
.open_url(steam_launch_uri(), None::<&str>)
.map_err(|err| {
CommandError::other(
"launch_failed",
"Could not start Arma Reforger through Steam.",
)
.tap_log(&err)
})
}
/// Opens a server's page on reforgermods.net in the default browser.
///
/// The URL is built from the validated id rather than from the API's `modURL`
/// field, so a hostile API response cannot choose the destination.
#[tauri::command]
pub async fn open_server_page(app: tauri::AppHandle, id: String) -> CommandResult<()> {
let id = ServerId::parse(&id)?;
let url = format!("https://reforgermods.net/servers/{id}");
app.opener().open_url(&url, None::<&str>).map_err(|err| {
CommandError::other("open_failed", "Could not open the reforgermods.net page.")
.tap_log(&err)
})
}
/// Opens a Discord invite in the default browser.
///
/// Takes the invite **code**, never a URL: the code is re-validated here and the
/// URL is rebuilt, so text in a server name cannot choose the destination even
/// if the UI's own extraction were wrong.
#[tauri::command]
pub async fn open_discord_invite(app: tauri::AppHandle, code: String) -> CommandResult<()> {
let valid = (2..=64).contains(&code.len())
&& code
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
if !valid {
return Err(CommandError::other(
"invalid_invite",
"Not a valid Discord invite.",
));
}
let url = format!("https://discord.gg/{code}");
app.opener()
.open_url(&url, None::<&str>)
.map_err(|err| CommandError::other("open_failed", "Could not open Discord.").tap_log(&err))
}
/// Settings, favorites, recents and history, read once at startup.
#[tauri::command]
pub fn get_local_state(state: State<'_, AppState>) -> LocalStateDto {
state.store.read(|state| LocalStateDto::from(state))
}
/// Adds or removes a favorite. Returns the new state.
#[tauri::command]
pub fn toggle_favorite(state: State<'_, AppState>, id: String) -> CommandResult<bool> {
state
.store
.write(|s| s.toggle_favorite(&id))?
.ok_or_else(|| CommandError::other("invalid_server_id", "Not a valid server id."))
}
/// Replaces application settings, re-pointing the API client when the base URL
/// changed. Returns the settings as stored, after clamping.
#[tauri::command]
pub fn update_settings(state: State<'_, AppState>, settings: Settings) -> CommandResult<Settings> {
let stored = state.store.write(|s| {
s.settings = settings;
s.settings.sanitise();
s.settings.clone()
})?;
// Applies a presence opt-out immediately rather than at the next refresh.
if !stored.discord_presence {
state.publish_presence(Activity::Browsing { online_servers: 0 });
}
Ok(stored)
}
/// Diagnostic view of the installation identity. Debug mode only.
///
/// Returns an empty id outside debug mode so the value is not exposed to the
/// UI — and therefore not to anything reading the window — in normal use.
#[tauri::command]
pub fn get_install_identity(state: State<'_, AppState>) -> InstallIdentity {
if !crate::debug_mode() {
return InstallIdentity::default();
}
InstallIdentity {
id: state.store.install_id(),
machine_derived: rfm_core::identity::is_machine_derived(),
}
}
/// Whether this session is running in debug mode.
///
/// Gates developer-facing detail in the UI, such as the install identifier.
/// Enabled with `RFM_DEBUG=1`.
#[tauri::command]
pub fn is_debug_mode() -> bool {
crate::debug_mode()
}
/// Clears recents and launch history. Favorites are left alone.
#[tauri::command]
pub fn clear_history(state: State<'_, AppState>) -> CommandResult<()> {
state.store.write(|s| {
s.recents.clear();
s.history.clear();
})?;
Ok(())
}
+108
View File
@@ -0,0 +1,108 @@
//! reforgermods.net launcher: the Tauri application shell.
//!
//! This crate is intentionally thin. It owns the window, the command surface and
//! the wiring between them; all logic lives in `rfm-core`, which has no Tauri
//! dependency and is tested directly.
mod commands;
mod presence;
mod state;
#[cfg(target_os = "windows")]
mod update;
use rfm_core::store::Store;
use state::AppState;
use tauri::Manager as _;
/// True when `RFM_DEBUG` is set to something truthy.
///
/// Gates developer-facing detail; nothing about product behaviour depends on it.
pub fn debug_mode() -> bool {
std::env::var("RFM_DEBUG")
.map(|v| matches!(v.trim(), "1" | "true" | "yes"))
.unwrap_or(false)
}
/// File the launcher persists settings, favorites, recents and history to.
const STATE_FILE: &str = "launcher-state.json";
/// Builds and runs the application.
pub fn run() {
#[allow(unused_mut)]
let mut builder = tauri::Builder::default()
// Used from Rust only, to hand steam:// and https:// URIs to the OS.
// The UI is not granted permission to call it.
.plugin(tauri_plugin_opener::init());
// Windows only; on Linux the distro package owns updates. See update.rs.
#[cfg(target_os = "windows")]
{
builder = builder
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(update::PendingUpdate::default());
}
let app = builder
.setup(|app| {
let store = Store::load(state_file_path(app.handle()));
let state = AppState::new(store).map_err(|e| {
// A client that cannot be built even against the production
// default means no TLS backend; there is nothing to recover to.
format!("could not initialise the API client: {e}")
})?;
app.manage(state);
// Background: never blocks the window appearing, and the download is
// applied when the session ends rather than during it.
#[cfg(target_os = "windows")]
update::spawn_check(app.handle().clone());
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::list_servers,
commands::get_server,
commands::get_servers,
commands::get_server_history,
commands::get_mod_readiness,
commands::ping_server,
commands::ping_regions,
commands::get_fleet_now,
commands::list_ping_sites,
commands::get_status,
commands::join_server,
commands::launch_game,
commands::open_server_page,
commands::open_discord_invite,
commands::get_local_state,
commands::toggle_favorite,
commands::update_settings,
commands::get_install_identity,
commands::is_debug_mode,
commands::clear_history,
])
.build(tauri::generate_context!())
.expect("error while building the reforgermods.net launcher");
// `run` with a callback rather than `Builder::run`, so the exit event can
// hand off to a staged installer. The callback runs for every event; only
// Exit is of interest, and it is the last one.
app.run(|_handle, event| {
if let tauri::RunEvent::Exit = event {
#[cfg(target_os = "windows")]
update::install_staged(_handle);
}
});
}
/// Where the state file lives.
///
/// Uses Tauri's resolved per-app config directory when available, falling back to
/// the working directory so a sandboxed environment without a config dir still
/// starts rather than panicking.
fn state_file_path(app: &tauri::AppHandle) -> std::path::PathBuf {
match app.path().app_config_dir() {
Ok(dir) => dir.join(STATE_FILE),
Err(_) => std::path::PathBuf::from(STATE_FILE),
}
}
+108
View File
@@ -0,0 +1,108 @@
// Release builds must not open a console window on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
/// Works around a WebKitGTK startup crash on 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.
/// ```
///
/// Setting `WEBKIT_DISABLE_DMABUF_RENDERER=1` falls back to a software
/// composite path, which costs some GPU acceleration and produces a window that
/// actually appears. Reported against Arch with an Nvidia card; it is a WebKit
/// and driver interaction rather than anything this app controls.
///
/// Deliberately narrow, because the fallback is not free for anyone who does not
/// need it:
///
/// - an existing value is never overridden, so a user debugging this keeps control;
/// - only when the Nvidia driver is actually loaded;
/// - only in a Wayland session, since the X11 path does not use this renderer;
/// - not when GDK is already pinned to X11, where it is equally unused.
#[cfg(target_os = "linux")]
fn disable_dmabuf_renderer_on_nvidia_wayland() {
let decision = should_disable_dmabuf(
std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_some(),
std::env::var_os("WAYLAND_DISPLAY").is_some(),
std::env::var_os("GDK_BACKEND")
.as_deref()
.and_then(|b| b.to_str()),
std::path::Path::new("/proc/driver/nvidia/version").exists()
|| std::path::Path::new("/sys/module/nvidia").exists(),
);
if !decision {
return;
}
// SAFETY: this runs at the top of main, before Tauri, GTK or any thread
// exists, so there is no concurrent reader of the environment. It must also
// happen here rather than later: WebKit reads this once, during init.
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
/// The decision behind [`disable_dmabuf_renderer_on_nvidia_wayland`], separated
/// from the environment so each condition can be tested.
#[cfg(target_os = "linux")]
fn should_disable_dmabuf(
already_set: bool,
wayland_session: bool,
gdk_backend: Option<&str>,
nvidia_loaded: bool,
) -> bool {
if already_set || !wayland_session || !nvidia_loaded {
return false;
}
// A GDK_BACKEND naming x11 alone means the Wayland renderer is not in play.
// A list such as "wayland,x11" still prefers Wayland, so only the exact pin
// counts as opting out.
gdk_backend != Some("x11")
}
fn main() {
#[cfg(target_os = "linux")]
disable_dmabuf_renderer_on_nvidia_wayland();
reforgermods_launcher_lib::run()
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::should_disable_dmabuf;
#[test]
fn nvidia_under_wayland_is_the_case_that_needs_the_workaround() {
assert!(should_disable_dmabuf(false, true, None, true));
assert!(should_disable_dmabuf(false, true, Some("wayland"), true));
// Still Wayland-first, so the renderer is still the one that breaks.
assert!(should_disable_dmabuf(
false,
true,
Some("wayland,x11"),
true
));
}
#[test]
fn an_explicit_setting_is_never_overridden() {
// Someone debugging this has set it to 0 on purpose; honour that.
assert!(!should_disable_dmabuf(true, true, None, true));
}
#[test]
fn other_sessions_and_drivers_keep_accelerated_rendering() {
assert!(
!should_disable_dmabuf(false, false, None, true),
"X11 session"
);
assert!(
!should_disable_dmabuf(false, true, None, false),
"not Nvidia"
);
assert!(
!should_disable_dmabuf(false, true, Some("x11"), true),
"pinned to XWayland"
);
}
}
+163
View File
@@ -0,0 +1,163 @@
//! Discord Rich Presence transport.
//!
//! Discord's IPC is a blocking local socket, and Discord may not be running at
//! all, so this owns one background thread and talks to it over a channel.
//! Nothing on the UI path ever blocks on Discord, and a missing or restarted
//! Discord is a no-op rather than an error the user sees.
//!
//! Payload text is built in `rfm_core::presence`, where the length and
//! sanitisation rules are tested.
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
use discord_rich_presence::error::Error as DiscordError;
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
use rfm_core::presence::Presence;
/// Discord application id. Determines the name shown as the game being played.
///
/// Empty by default: without a real application this build must not attempt to
/// connect. Set `RFM_DISCORD_APP_ID` at runtime, or replace this constant once
/// the application exists. See `docs/discord-presence.md`.
const DEFAULT_APP_ID: &str = "1552757014474784898";
/// Environment override for the application id.
const APP_ID_ENV: &str = "RFM_DISCORD_APP_ID";
/// Artwork key uploaded to the Discord application's Rich Presence assets.
const LARGE_IMAGE_KEY: &str = "logo";
/// How long to wait before retrying a failed connection. Discord being closed
/// is the normal case, so this stays slow and quiet.
const RECONNECT_DELAY: Duration = Duration::from_secs(30);
/// Messages the worker accepts.
enum Message {
/// Publish this presence.
Update(Presence),
/// Stop publishing and disconnect.
Clear,
}
/// Handle to the presence worker.
pub struct PresenceHandle {
tx: Option<Sender<Message>>,
}
impl PresenceHandle {
/// Starts the worker, or returns an inert handle when presence is disabled.
///
/// Disabled means: no application id configured. The launcher still runs
/// normally; nothing is published.
pub fn start() -> Self {
let app_id = std::env::var(APP_ID_ENV)
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| DEFAULT_APP_ID.to_string());
if app_id.trim().is_empty() {
return Self { tx: None };
}
let (tx, rx) = mpsc::channel();
std::thread::Builder::new()
.name("discord-presence".into())
.spawn(move || worker(app_id, rx))
.ok();
Self { tx: Some(tx) }
}
/// True when presence is configured and a worker is running.
pub fn is_enabled(&self) -> bool {
self.tx.is_some()
}
/// Publishes `presence`. Never blocks and never fails the caller.
pub fn update(&self, presence: Presence) {
self.send(Message::Update(presence));
}
/// Clears the presence, e.g. when the user turns it off.
pub fn clear(&self) {
self.send(Message::Clear);
}
fn send(&self, message: Message) {
if let Some(tx) = &self.tx {
// A dead worker means Discord support is gone for this session;
// that is not worth surfacing.
let _ = tx.send(message);
}
}
}
/// Owns the IPC client for the life of the process.
fn worker(app_id: String, rx: Receiver<Message>) {
let mut client = DiscordIpcClient::new(&app_id);
let mut connected = false;
let mut last_attempt: Option<Instant> = None;
// Re-sent after a reconnect, so a Discord restart does not blank the status.
let mut current: Option<Presence> = None;
while let Ok(message) = rx.recv() {
match message {
Message::Update(presence) => current = Some(presence),
Message::Clear => {
// `take` both clears and tells us whether anything was live, so
// a clear with nothing published costs no IPC round trip.
if current.take().is_some() && connected {
let _ = client.clear_activity();
}
}
}
// Nothing to publish means no reason to hold or open a connection.
let Some(presence) = current.clone() else {
continue;
};
if !connected {
// Back off: Discord not running is the normal case, not an error.
let due = last_attempt.is_none_or(|at| at.elapsed() >= RECONNECT_DELAY);
if !due {
continue;
}
last_attempt = Some(Instant::now());
connected = client.connect().is_ok();
if !connected {
continue;
}
}
if publish(&mut client, &presence).is_err() {
// Discord closed or restarted; drop the connection and let the next
// update reconnect after the backoff.
connected = false;
let _ = client.close();
}
}
if connected {
let _ = client.clear_activity();
let _ = client.close();
}
}
/// Sends one activity.
fn publish(client: &mut DiscordIpcClient, presence: &Presence) -> Result<(), DiscordError> {
client.set_activity(
activity::Activity::new()
.details(presence.details.as_str())
.state(presence.state.as_str())
.assets(
activity::Assets::new()
.large_image(LARGE_IMAGE_KEY)
.large_text(presence.large_text.as_str()),
)
.buttons(vec![activity::Button::new(
"Get the launcher",
"https://reforgermods.net",
)]),
)
}
+77
View File
@@ -0,0 +1,77 @@
//! Shared application state.
use std::sync::{Mutex, RwLock};
use rfm_core::api::models::DatasetMeta;
use rfm_core::presence::Activity;
use rfm_core::store::Store;
use crate::presence::PresenceHandle;
use rfm_core::{ApiClient, ApiError};
/// State held for the lifetime of the application.
pub struct AppState {
/// Behind an `RwLock` because changing the API base URL in settings swaps the
/// client, while every request only needs read access.
client: RwLock<ApiClient>,
/// Persisted settings, favorites, recents and launch history.
pub store: Store,
/// Discord Rich Presence publisher. Inert when no application id is set.
presence: PresenceHandle,
/// Snapshot freshness from the most recent server response.
///
/// Every `/v2/servers` response carries this, so the header can show
/// "server data updated Ns ago" without its own polling loop.
last_dataset: Mutex<Option<DatasetMeta>>,
}
impl AppState {
/// Builds state with a client for the configured API origin, identifying
/// with the store's anonymous install id.
pub fn new(store: Store) -> Result<Self, ApiError> {
let client = ApiClient::new(&store.install_id())?;
Ok(Self {
client: RwLock::new(client),
store,
presence: PresenceHandle::start(),
last_dataset: Mutex::new(None),
})
}
/// A clone of the current client, so requests do not hold the lock while
/// awaiting the network.
pub fn client(&self) -> ApiClient {
self.client
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// Publishes `activity` to Discord, if the user has it enabled.
///
/// Silently does nothing when presence is off or unconfigured; this is a
/// decorative feature and must never interfere with a user action.
pub fn publish_presence(&self, activity: Activity<'_>) {
if !self.presence.is_enabled() {
return;
}
if self.store.read(|s| s.settings.discord_presence) {
self.presence.update(activity.to_presence());
} else {
self.presence.clear();
}
}
/// Records snapshot freshness seen on a server response.
pub fn remember_dataset(&self, dataset: DatasetMeta) {
*self.last_dataset.lock().unwrap_or_else(|e| e.into_inner()) = Some(dataset);
}
/// The most recently seen snapshot freshness, if any.
pub fn last_dataset(&self) -> Option<DatasetMeta> {
self.last_dataset
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Background updates, Windows only.
//!
//! The launcher is an API client, which is what makes this worth shipping in the
//! first release rather than later: when `/v2/servers` changes shape, anyone on
//! an old build gets a broken server browser and blames the site. An updater
//! turns that from a support problem into a non-event.
//!
//! The shape is deliberate, and it is the reason this is not simply
//! `download_and_install`:
//!
//! 1. check and download in the background, so nothing blocks startup;
//! 2. hold the downloaded package until the session ends;
//! 3. run the installer as the app exits, without relaunching it.
//!
//! Nobody is interrupted mid-session, and nobody is asked to make a decision
//! about a patch release. The update is simply there the next time they open the
//! launcher. `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 — another reason the install is pinned to exactly this point.
//!
//! There is no updater on Linux. The Arch package is owned by pacman, and an app
//! that rewrites its own files under `/usr/bin` fights the package manager (see
//! `docs/distribution.md` section 7). The dependency is scoped to Windows in
//! `Cargo.toml`, so this module does not exist in a Linux build.
use std::sync::Mutex;
use tauri::Manager as _;
use tauri_plugin_updater::{Update, UpdaterExt as _};
/// An update that has been downloaded and is waiting for the session to end.
///
/// `Update` carries the verified release metadata and `bytes` is the installer
/// payload, already signature-checked by the plugin against the public key in
/// `tauri.conf.json`. Holding both means the install at exit is a local
/// operation: no network, nothing that can hang a closing app.
#[derive(Default)]
pub struct PendingUpdate(Mutex<Option<Staged>>);
struct Staged {
update: Update,
bytes: Vec<u8>,
}
/// Reports a failure only to a developer. An updater that cannot reach the
/// network is not the user's problem and must not produce a dialog; `RFM_DEBUG=1`
/// is how it becomes visible, matching how the rest of the app gates detail.
fn note(context: &str, error: &dyn std::fmt::Display) {
if crate::debug_mode() {
eprintln!("updater: {context}: {error}");
}
}
/// Checks for an update and downloads it, in the background.
///
/// Silent on every failure by design: no endpoint, no network, a malformed
/// manifest or a bad signature all leave the app exactly as it was.
pub fn spawn_check(app: tauri::AppHandle) {
// A dev build's version always matches the manifest, so the check can only
// cost a request. Skipped rather than relied upon to no-op.
if cfg!(debug_assertions) {
return;
}
tauri::async_runtime::spawn(async move {
let updater = match app.updater() {
Ok(updater) => updater,
Err(error) => return note("could not build the updater", &error),
};
let update = match updater.check().await {
Ok(Some(update)) => update,
// Already current: the overwhelmingly common case.
Ok(None) => return,
Err(error) => return note("check failed", &error),
};
let bytes = match update.download(|_, _| {}, || {}).await {
Ok(bytes) => bytes,
Err(error) => return note("download failed", &error),
};
if let Some(state) = app.try_state::<PendingUpdate>() {
*state.0.lock().unwrap() = Some(Staged { update, bytes });
}
});
}
/// Runs a staged installer, if there is one. Called from the exit event.
///
/// `restart_after_install(false)` is the whole point: the default relaunches the
/// app, and relaunching something the user has just closed is worse than the
/// stale build it fixes.
pub fn install_staged(app: &tauri::AppHandle) {
let Some(state) = app.try_state::<PendingUpdate>() else {
return;
};
// Taken, not borrowed: if the installer somehow returns, a second attempt
// during the same shutdown is not wanted.
let Some(staged) = state.0.lock().unwrap().take() else {
return;
};
if let Err(error) = staged
.update
.restart_after_install(false)
.install(staged.bytes)
{
note("install failed", &error);
}
}
+74
View File
@@ -0,0 +1,74 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "reforgermods.net launcher",
"version": "0.1.0",
"identifier": "net.reforgermods.launcher",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "reforgermods.net launcher",
"width": 1400,
"height": 880,
"minWidth": 1000,
"minHeight": 620,
"resizable": true,
"center": true,
"decorations": false,
"theme": "Dark",
"dragDropEnabled": false,
"backgroundColor": "#050d10"
}
],
"security": {
"csp": {
"default-src": "'self'",
"script-src": "'self'",
"style-src": "'self' 'unsafe-inline'",
"font-src": "'self'",
"connect-src": "'self' ipc: http://ipc.localhost",
"img-src": "'self' data: https://ar-gcp-cdn.bistudio.com",
"object-src": "'none'",
"base-uri": "'self'",
"frame-src": "'none'"
},
"capabilities": ["main"]
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": true,
"publisher": "reforgermods.net",
"shortDescription": "Server browser and direct launcher for Arma Reforger.",
"longDescription": "A fast, lightweight desktop companion for Arma Reforger built on the reforgermods.net API: live server browser, filtering, favorites and direct joining through Steam.",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
],
"windows": {
"nsis": {
"installMode": "currentUser"
},
"webviewInstallMode": {
"type": "downloadBootstrapper"
}
}
},
"plugins": {
"updater": {
"endpoints": ["https://dl.reforgermods.net/updates/latest.json"],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEIxMDJFNDhENEZDNTE4M0IKUldRN0dNVlBqZVFDc1dBTHlSY2tsakRyNjYvZHdpdWNHQzRud3dzeGhlTTFidmwrekNaUlJYdEYK",
"windows": {
"installMode": "passive"
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"windows": {
"signCommand": "cmd /C ..\\packaging\\windows\\sign.cmd \"%1\""
}
}
}
+469
View File
@@ -0,0 +1,469 @@
/**
* @vitest-environment jsdom
*
* Smoke test for the shell: with the Tauri IPC layer stubbed, the application
* must mount, render its chrome, and populate the table from a server page.
* This is the check that a component throws at render time — something neither
* `tsc` nor the Rust tests can catch.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render } from "preact";
import type { ServerSummary } from "./lib/types";
const server: ServerSummary = {
id: "73e8fb7f-8789-4d88-8aa8-ea69b9aa092f",
name: "[OGR2] Old Guard Revival 2 | Ruha Finland",
scenarioId: "{FAC303E9F1B7CAB2}Missions/OGR_RuhaN-S.conf",
scenarioName: "[OGR] Ruha Conflict North South",
scenarioThumbnailUrl:
"https://ar-gcp-cdn.bistudio.com/image/b64d/02af30860106e7097a8238852468fb85d249062ae56b43b1d6c34e70c20f/45316.jpg",
gameVersion: "1.8.0.13",
official: false,
passwordProtected: false,
battlEye: true,
crossPlay: true,
players: 12,
maxPlayers: 128,
queue: 0,
queueMax: 25,
pingSiteId: "new_york",
region: "new_york",
platform: "LINUX",
platforms: ["pc", "xbox", "psn"],
modCount: 122,
online: true,
firstSeen: null,
lastSeen: null,
analytics: null,
joinUrl: null,
};
/** Stub the Tauri bridge; nothing in a test environment can reach a backend. */
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(async (command: string) => {
switch (command) {
case "list_servers":
return {
servers: [server],
totalServers: 4999,
totalPages: 50,
currentPage: 1,
filteredOut: 0,
dataset: {
warming: false,
stale: false,
snapshotAgeSeconds: 18,
lastCollectionAt: "2026-09-24T06:54:58Z",
serverCount: 94223,
onlineServerCount: 4999,
offlineServerCount: 89224,
},
};
case "get_local_state":
return {
settings: {
perPage: 100,
autoRefresh: true,
refreshIntervalSecs: 60,
discordPresence: true,
},
favorites: [server.id],
recents: [{ id: server.id, name: server.name, lastSeenAt: 1790149200 }],
history: [],
};
case "get_status":
return {
apiReachable: true,
dataset: null,
reportedStatus: null,
note: "This API deployment does not serve /v2/status.",
};
case "get_server":
return {
...server,
present: true,
hostType: "CommunityDs",
joinable: true,
host: "208.92.232.141",
port: 2001,
fps: 60,
activity: {
peak24h: 95,
avg24h: 33,
median24h: 22,
low24h: 0,
peak7d: 128,
samples24h: 1286,
trackingSince: null,
},
modSummary: {
count: 2,
knownSize: 8_772_055_531,
knownSizeText: "8.17 GiB",
unresolvedCount: 1,
},
mods: [
{
id: "64610AFB74AA9842",
name: "WCS_Core",
version: "8.2.0",
size: 8_772_055_531,
sizeText: null,
sizeKnown: true,
modUrl: null,
},
{
id: "1337C0DE5DABBEEF",
name: "RHS - Content Pack 01",
version: "0.16.5208",
size: 0,
sizeText: null,
sizeKnown: false,
modUrl: null,
},
],
};
case "get_servers":
return [server];
case "is_debug_mode":
return false;
case "get_install_identity":
return { id: "", machineDerived: false };
case "get_fleet_now":
return { onlineServers: 5089, players: 16352, capacity: 224986, queue: 694 };
case "ping_regions":
return { new_york: { summary: { bestMs: 70, medianMs: 72, received: 3, sent: 3 } } };
case "get_install_id":
return "8f14e45f-ceea-467a-9c2b-1f2a3b4c5d6e";
case "ping_server":
return { summary: { bestMs: 70, medianMs: 72, received: 3, sent: 3 } };
case "get_server_history":
return {
serverId: server.id,
range: "24h",
bucket: "5m",
points: [
{ t: 1790149200, avg: 6, min: 5, max: 7, cap: 128 },
{ t: 1790149500, avg: 40, min: 20, max: 95, cap: 128 },
{ t: 1790149800, avg: 12, min: 10, max: 14, cap: 128 },
],
};
case "get_mod_readiness":
return {
available: true,
required: 136,
upToDate: 39,
outdated: 0,
missing: 96,
missingBytes: 8_772_055_531,
unresolvedMods: 1,
installedTotal: 235,
};
case "list_ping_sites":
return [
{ id: "new_york", label: "New York", regionGroup: "North America", address: null },
];
default:
return null;
}
}),
}));
async function mount() {
const { App } = await import("./app");
const { start } = await import("./lib/state");
const root = document.createElement("div");
document.body.appendChild(root);
render(<App />, root);
await start();
// Let the resulting signal updates flush.
await new Promise((resolve) => setTimeout(resolve, 0));
return root;
}
/**
* The column headings, in order.
*
* Sortable headings are buttons wrapping a label and a direction arrow, so this
* reads the label rather than the cell's whole text content — which would
* otherwise pick up the arrow on whichever column is currently sorted.
*/
function headerLabels(root: HTMLElement): (string | null | undefined)[] {
return Array.from(root.querySelectorAll(".thead > *")).map(
(cell) => (cell.querySelector(".th-sort__label") ?? cell).textContent,
);
}
describe("application shell", () => {
beforeEach(() => {
document.body.innerHTML = "";
vi.resetModules();
});
it("renders the brand, navigation and status", async () => {
const root = await mount();
const text = root.textContent ?? "";
expect(text).toContain("reforger");
expect(text).toContain("launcher");
expect(text).toContain("Server browser");
expect(text).toContain("Favorites");
expect(text).toContain("Settings");
// Status resolved from the stubbed probe.
expect(text).toContain("API operational");
});
it("renders a fetched server into the table", async () => {
const root = await mount();
const text = root.textContent ?? "";
expect(text).toContain("Old Guard Revival 2");
expect(text).toContain("12 / 128");
expect(text).toContain("New York");
// 4,999 matched servers reported in the footer.
expect(text).toMatch(/4[.,  ]?999/);
});
it("exposes the search field with its shortcut hint", async () => {
const root = await mount();
const search = root.querySelector<HTMLInputElement>('input[aria-label="Search servers"]');
expect(search).not.toBeNull();
expect(root.textContent).toContain("Ctrl F");
});
it("renders the filter chips the API supports", async () => {
const root = await mount();
const chips = Array.from(root.querySelectorAll(".chip")).map((c) => c.textContent);
expect(chips).toEqual(["Official", "Modded", "BattlEye", "Password", "Offline"]);
// All start unset, so no filter is silently applied at launch.
for (const chip of root.querySelectorAll(".chip")) {
expect(chip.getAttribute("data-state")).toBe("unset");
}
});
it("shows the empty inspector until a server is selected", async () => {
const root = await mount();
expect(root.textContent).toContain("No server selected");
});
it("shows region latency in the table and offers sorting by it", async () => {
const root = await mount();
expect(headerLabels(root)).toEqual([
"Server", "", "Players", "Queue", "Ping", "Region", "Mods", "Platform",
]);
// The stub measures new_york, which is this server's region.
const cell = root.querySelector(".cell.ping");
expect(cell?.textContent).toBe("72");
const sortOptions = Array.from(
root.querySelectorAll<HTMLSelectElement>('select[aria-label="Sort order"]')[0]?.options ?? [],
).map((o) => o.value);
expect(sortOptions).toContain("ping");
});
it("sorts from the column headings, marking what only reaches this page", async () => {
const root = await mount();
const { clientSort, query } = await import("./lib/state");
const heading = (label: string) =>
Array.from(root.querySelectorAll<HTMLButtonElement>(".thead .th-sort")).find(
(b) => b.querySelector(".th-sort__label")?.textContent === label,
);
// Queue is not an ordering the API offers, so it reorders the loaded page
// and the footer has to say so.
heading("Queue")?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(clientSort.value).toEqual({ column: "queue", direction: "desc" });
expect(root.textContent).toContain("sorted by queue within this page");
// Clicking it again reverses it, still page-local.
heading("Queue")?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(clientSort.value).toEqual({ column: "queue", direction: "asc" });
// Players is a sort the API serves across every matching server, so the
// local reordering is dropped and the caveat goes with it.
heading("Players")?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(clientSort.value).toBeNull();
expect(query.value.sort).toBe("players");
expect(root.textContent).not.toContain("within this page");
// Reversing it is something the API has no parameter for, so that one does
// fall back to the page.
heading("Players")?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(clientSort.value).toEqual({ column: "players", direction: "asc" });
expect(root.textContent).toContain("sorted by players within this page");
});
it("renders favorites as a server table, not a plain list", async () => {
const root = await mount();
const { setView } = await import("./lib/state");
setView("favorites");
await new Promise((resolve) => setTimeout(resolve, 0));
// Same table chrome as the browser, with live columns.
expect(headerLabels(root)).toEqual([
"Server", "", "Players", "Queue", "Ping", "Region", "Mods", "Platform",
]);
expect(root.textContent).toContain("Old Guard Revival 2");
expect(root.textContent).toContain("12 / 128");
// The inspector travels with the table.
expect(root.textContent).toContain("No server selected");
});
it("keeps the install identifier out of the UI outside debug mode", async () => {
const root = await mount();
const { setView } = await import("./lib/state");
setView("settings");
await new Promise((resolve) => setTimeout(resolve, 0));
const text = root.textContent ?? "";
expect(text).not.toContain("Install identity");
expect(text).not.toContain("Reset identifier");
// The Discord toggle is user-facing and should still be there.
expect(text).toContain("Discord status");
});
it("shows a platform mark per supported platform", async () => {
const root = await mount();
const badges = Array.from(
root.querySelectorAll(".row .platforms__badge svg"),
).map((b) => b.getAttribute("aria-label"));
expect(badges).toEqual(["PC", "Xbox", "PlayStation"]);
expect(root.textContent).not.toContain("XPLAY");
});
it("aligns the header with the rows by sharing one scroll container", async () => {
// With the header outside the scroller, a visible scrollbar narrowed the
// rows and every column drifted left of its heading.
const root = await mount();
const table = root.querySelector(".table");
expect(table?.querySelector(".thead")).not.toBeNull();
expect(table?.querySelector(".rows")).not.toBeNull();
});
it("shows status as aligned marks, not pills trailing the name", async () => {
const root = await mount();
// The pills used to start wherever the name ended, forming a ragged column.
expect(root.querySelector(".row .name .tag")).toBeNull();
const marks = Array.from(
root.querySelectorAll(".row .marks .marks__i"),
).map((m) => m.getAttribute("title"));
// This server is community, open and BattlEye-enabled.
expect(marks).toEqual(["BattlEye enabled"]);
});
it("uses a dedicated favourite button per row", async () => {
const root = await mount();
const star = root.querySelector<HTMLButtonElement>(".row .star");
expect(star).not.toBeNull();
// The stubbed local state has this server favourited, so the star is on.
expect(star?.getAttribute("aria-pressed")).toBe("true");
expect(star?.getAttribute("data-on")).toBe("true");
expect(star?.getAttribute("title")).toBe("Remove from favorites");
// Drawn as a glyph, not a text character.
expect(star?.querySelector("svg")).not.toBeNull();
});
it("drops the population bar beside the player count", async () => {
const root = await mount();
expect(root.querySelector(".pop__bar")).toBeNull();
expect(root.querySelector(".row .cell.num")?.textContent).toBe("12 / 128");
});
it("shows the fleet player total beside the server count", async () => {
const root = await mount();
const context = root.querySelector(".titlebar__context")?.textContent ?? "";
expect(context).toMatch(/4[.,\s]?999\s*servers/);
expect(context).toContain("players online");
expect(context).toMatch(/16[.,\s]?352/);
});
it("colours the wordmark like the site", async () => {
const root = await mount();
expect(root.querySelector(".wordmark__reforger")?.textContent).toBe("reforger");
expect(root.querySelector(".wordmark__mods")?.textContent).toBe("mods");
expect(root.querySelector(".wordmark__net")?.textContent).toBe(".net");
});
it("no longer offers a scenario filter", async () => {
const root = await mount();
expect(
root.querySelector('select[aria-label="Scenario (filters the loaded page)"]'),
).toBeNull();
});
it("renders readiness and the history chart once a server is selected", async () => {
const root = await mount();
const { selectServer } = await import("./lib/state");
await selectServer(server.id);
await new Promise((resolve) => setTimeout(resolve, 0));
const text = root.textContent ?? "";
// The honest copy, end to end through the IPC boundary.
expect(text).toContain(
"96 of 136 mods missing, at least 8.17 GiB to download. " +
"Arma Reforger downloads these when you join.",
);
// Counts read as labelled stats rather than a sentence fragment.
const counts = Array.from(root.querySelectorAll(".readiness__counts div")).map(
(d) => [d.querySelector("dt")?.textContent, d.querySelector("dd")?.textContent],
);
expect(counts).toEqual([
["Installed", "39"],
["Missing", "96"],
["On disk", "235"],
]);
// Readiness needs action, and the state is carried by a dot, not a side edge.
expect(root.querySelector(".readiness")?.getAttribute("data-tone")).toBe("action");
expect(root.querySelector(".readiness__dot")).not.toBeNull();
// The chart drew from the stubbed series.
const line = root.querySelector(".chart__line");
expect(line).not.toBeNull();
expect(line?.getAttribute("d")).toMatch(/^M0,/);
expect(text).toContain("peak");
// And no action is offered that the launcher cannot perform.
expect(text).not.toContain("Prepare mods");
// Mod rows split name, version and size into aligned columns.
const first = root.querySelector(".modlist__row");
expect(first?.querySelector(".modlist__name")?.textContent).toBe("WCS_Core");
expect(first?.querySelector(".modlist__version")?.textContent).toBe("8.2.0");
expect(first?.querySelector(".modlist__size")?.textContent).toBe("8.2 GiB");
});
it("renders the scenario image and the measured ping", async () => {
const root = await mount();
const { selectServer } = await import("./lib/state");
await selectServer(server.id);
await new Promise((resolve) => setTimeout(resolve, 0));
const img = root.querySelector<HTMLImageElement>(".scenario__img");
expect(img).not.toBeNull();
expect(img?.getAttribute("src")).toContain("ar-gcp-cdn.bistudio.com");
// Measured latency, not a region estimate.
expect(root.textContent).toContain("72 ms");
// The published address, for people who want to see it.
expect(root.textContent).toContain("208.92.232.141:2001");
});
it("offers the history range switcher", async () => {
const root = await mount();
const { selectServer } = await import("./lib/state");
await selectServer(server.id);
await new Promise((resolve) => setTimeout(resolve, 0));
const ranges = Array.from(root.querySelectorAll(".rangeswitch button")).map(
(b) => b.textContent,
);
expect(ranges).toEqual(["6h", "24h", "7d", "30d"]);
const active = root.querySelector('.rangeswitch button[data-on="true"]');
expect(active?.textContent).toBe("24h");
});
});
+32
View File
@@ -0,0 +1,32 @@
/** Application shell: brand, header, navigation and the active view. */
import { Inspector } from "./components/Inspector";
import { TitleBar } from "./components/TitleBar";
import { Sidebar } from "./components/Sidebar";
import { BrowseView } from "./views/BrowseView";
import { FavoritesView, RecentView } from "./views/ListView";
import { SettingsView } from "./views/SettingsView";
import { view } from "./lib/state";
export function App() {
const current = view.value;
// The saved lists render the same table as the browser, so they get the same
// inspector; only settings is full width.
const showInspector = current !== "settings";
return (
<div class="shell">
<TitleBar />
<Sidebar />
<main class={`main${showInspector ? "" : " main--full"}`}>
{current === "browse" && <BrowseView />}
{current === "favorites" && <FavoritesView />}
{current === "recent" && <RecentView />}
{current === "settings" && <SettingsView />}
{showInspector && <Inspector />}
</main>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+227
View File
@@ -0,0 +1,227 @@
/**
* Compact filter row.
*
* Only filters `/v2/servers` actually supports appear here, plus the one
* client-side control (minimum players), which is labelled as page-local so it
* does not read as a server-side filter.
*/
import { useEffect, useRef } from "preact/hooks";
import {
clearFilters,
clientSort,
pingSites,
query,
searchInput,
setQuery,
setSearch,
} from "../lib/state";
import { humaniseRegion } from "../lib/format";
import type { ClientPlatform, ServerSort } from "../lib/types";
/** Cycles a tri-state filter: unset -> true -> false -> unset. */
function nextTriState(value: boolean | null): boolean | null {
if (value === null) return true;
if (value) return false;
return null;
}
function triLabel(value: boolean | null): "on" | "off" | "unset" {
return value === null ? "unset" : value ? "on" : "off";
}
interface ChipProps {
label: string;
value: boolean | null;
onChange: (next: boolean | null) => void;
titles: [unset: string, on: string, off: string];
}
function Chip({ label, value, onChange, titles }: ChipProps) {
const state = triLabel(value);
const title = state === "unset" ? titles[0] : state === "on" ? titles[1] : titles[2];
return (
<button
class="chip"
data-state={state}
title={title}
aria-pressed={value !== null}
onClick={() => onChange(nextTriState(value))}
>
{label}
</button>
);
}
export function FilterBar() {
const q = query.value;
const searchRef = useRef<HTMLInputElement>(null);
// Ctrl+K focuses search, Escape clears it. Registered once for the window so
// the shortcut works regardless of what currently has focus.
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f") {
event.preventDefault();
searchRef.current?.focus();
searchRef.current?.select();
} else if (event.key === "Escape" && document.activeElement === searchRef.current) {
searchRef.current?.blur();
if (searchInput.value) setSearch("");
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const sites = pingSites.value;
return (
<div class="toolbar">
<div class="search">
<input
ref={searchRef}
type="text"
spellcheck={false}
autocomplete="off"
placeholder="Search servers"
aria-label="Search servers"
value={searchInput.value}
onInput={(e) => setSearch((e.target as HTMLInputElement).value)}
/>
<span class="search__hint">Ctrl F</span>
</div>
<select
class="control"
aria-label="Sort order"
value={clientSort.value?.column === "ping" ? "ping" : q.sort}
onChange={(e) => {
const value = (e.target as HTMLSelectElement).value;
if (value === "ping") {
// The API cannot sort by latency — it does not know the user's — so
// fetch the busiest servers and reorder the page locally.
clientSort.value = { column: "ping", direction: "asc" };
setQuery({ sort: "players" });
} else {
// A dataset-wide sort supersedes any page-local one, including a
// header click that reversed a column.
clientSort.value = null;
setQuery({ sort: value as ServerSort });
}
}}
>
<option value="players">Most players</option>
<option value="ping">Lowest ping</option>
<option value="name">Name</option>
<option value="newest">Newest</option>
<option value="lastSeen">Last seen</option>
</select>
<select
class="control"
aria-label="Ping region"
value={q.region ?? ""}
onChange={(e) => {
const value = (e.target as HTMLSelectElement).value;
setQuery({ region: value === "" ? null : value });
}}
>
<option value="">All regions</option>
{sites.map((site) => (
<option key={site.id} value={site.id}>
{site.label || humaniseRegion(site.id)}
</option>
))}
</select>
<select
class="control"
aria-label="Client platform"
value={q.platform ?? ""}
onChange={(e) => {
const value = (e.target as HTMLSelectElement).value;
setQuery({ platform: value === "" ? null : (value as ClientPlatform) });
}}
>
<option value="">Any platform</option>
<option value="pc">PC</option>
<option value="xbox">Xbox</option>
<option value="psn">PlayStation</option>
</select>
<Chip
label="Official"
value={q.official}
onChange={(official) => setQuery({ official })}
titles={["Any", "Official only", "Community only"]}
/>
<Chip
label="Modded"
value={q.hasMods}
onChange={(hasMods) => setQuery({ hasMods })}
titles={["Any", "Modded only", "Vanilla only"]}
/>
<Chip
label="BattlEye"
value={q.battleye}
onChange={(battleye) => setQuery({ battleye })}
titles={["Any", "BattlEye on", "BattlEye off"]}
/>
<Chip
label="Password"
value={q.locked}
onChange={(locked) => setQuery({ locked })}
titles={["Any", "Locked only", "Open only"]}
/>
<button
class="chip"
data-state={q.includeOffline ? "on" : "unset"}
aria-pressed={q.includeOffline}
title="Include servers not currently listed"
onClick={() => setQuery({ includeOffline: !q.includeOffline })}
>
Offline
</button>
<span class="control-label">Min players</span>
<input
class="control"
type="number"
min={0}
max={256}
step={1}
aria-label="Minimum players"
title="Filters the loaded page"
value={q.minPlayers}
onInput={(e) => {
const raw = Number((e.target as HTMLInputElement).value);
setQuery({ minPlayers: Number.isFinite(raw) ? Math.max(0, Math.trunc(raw)) : 0 });
}}
/>
<div class="toolbar__spacer" />
{hasActiveFilters(q) && (
<button class="linkish" onClick={clearFilters}>
Clear filters
</button>
)}
</div>
);
}
function hasActiveFilters(q: typeof query.value): boolean {
return (
q.search.trim() !== "" ||
q.region !== null ||
q.platform !== null ||
q.official !== null ||
q.hasMods !== null ||
q.battleye !== null ||
q.locked !== null ||
q.includeOffline ||
q.minPlayers > 0
);
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Population history for the selected server.
*
* One inline SVG, no charting library: a min–max band, an average line, and a
* range switcher. The paths come from `lib/chart.ts`, which is pure and tested.
*/
import { historyLoading, historyRange, historySeries, setHistoryRange } from "../lib/state";
import { buildChart } from "../lib/chart";
import { HISTORY_RANGES } from "../lib/types";
/** Drawing box. `preserveAspectRatio="none"` stretches it to the panel width. */
const WIDTH = 300;
const HEIGHT = 56;
export function HistoryChart() {
const series = historySeries.value;
const loading = historyLoading.value;
const range = historyRange.value;
const geometry = series ? buildChart(series.points, WIDTH, HEIGHT) : null;
return (
<div class="section">
<div class="section__h">
<span>Population</span>
<div class="rangeswitch">
{HISTORY_RANGES.map((option) => (
<button
key={option.value}
data-on={option.value === range}
onClick={() => setHistoryRange(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
{geometry ? (
<>
<svg
class="chart"
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
preserveAspectRatio="none"
role="img"
aria-label={`Population over the last ${range}: peak ${geometry.peak}, average ${geometry.mean}`}
>
{/* Band first so the average line sits on top of it. */}
<path class="chart__band" d={geometry.band} />
{/* non-scaling-stroke keeps the line 1px despite the stretched viewBox. */}
<path class="chart__line" d={geometry.line} vector-effect="non-scaling-stroke" />
</svg>
<div class="chart__legend">
<span>
peak <b>{geometry.peak}</b>
</span>
<span>
avg <b>{geometry.mean}</b>
</span>
<span>
low <b>{geometry.low}</b>
</span>
{geometry.cap > 0 && <span>of {geometry.cap}</span>}
<span class="chart__bucket">{series?.bucket} buckets</span>
</div>
</>
) : (
<p class="state__hint" style={{ margin: 0 }}>
{loading ? "Loading history…" : "No recorded history for this range."}
</p>
)}
</div>
);
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Selected-server detail pane.
*
* Shows only values the API actually returns, or that were measured locally.
* There is deliberately no uptime figure, and mod readiness is reported without
* any action attached: the launcher cannot install Workshop content, so it says
* what the game will do instead. See `docs/mod-readiness.md`.
*/
import {
detailError,
detailLoading,
favoriteSet,
join,
joinError,
joining,
ping,
pingLoading,
selectServer,
selectedId,
selectedServer,
toggleFavorite,
} from "../lib/state";
import { HistoryChart } from "./HistoryChart";
import { PlatformBadges } from "./PlatformBadges";
import { StatusMarks } from "./StatusMarks";
import { ReadinessNote } from "./ReadinessNote";
import { openDiscordInvite, openServerPage } from "../lib/ipc";
import {
discordInviteFrom,
formatBytes,
formatPlayers,
humaniseRegion,
safeImageUrl,
tidyName,
} from "../lib/format";
import type { ServerDetail } from "../lib/types";
export function Inspector() {
const id = selectedId.value;
const server = selectedServer.value;
const error = detailError.value;
if (!id) {
return (
<aside class="inspector">
<div class="state">
<div class="state__title">No server selected</div>
<div class="state__hint">
Pick a server to see its scenario, population, mods and join it directly
through Steam.
</div>
</div>
</aside>
);
}
if (error) {
return (
<aside class="inspector">
<div class="inspector__head">
<div class="inspector__title">Could not load server</div>
</div>
<div class="inspector__body">
<p class="state__hint">{error.message}</p>
<div class="btn-row">
<button class="btn" onClick={() => void selectServer(id)}>
Try again
</button>
<button class="btn" onClick={() => void selectServer(null)}>
Close
</button>
</div>
</div>
</aside>
);
}
if (!server) {
return (
<aside class="inspector">
<div class="inspector__head">
<div class="inspector__title">Loading…</div>
</div>
</aside>
);
}
return <Detail server={server} isFavorite={favoriteSet.value.has(server.id)} />;
}
function Detail({ server, isFavorite }: { server: ServerDetail; isFavorite: boolean }) {
const refreshing = detailLoading.value;
const busy = joining.value;
const failure = joinError.value;
// About half of servers publish no imagery, and any URL the API sends is
// untrusted, so this is null more often than not.
const image = safeImageUrl(server.scenarioThumbnailUrl);
// Server operators advertise their Discord in the server name; the same
// detection the website uses surfaces it as a button.
const discord = discordInviteFrom(server.name);
return (
<aside class="inspector">
{image && (
<div class="scenario">
<img
class="scenario__img"
src={image}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/* A CDN hiccup should leave the panel tidy, not show a broken icon. */
onError={(e) => {
(e.currentTarget as HTMLImageElement).closest(".scenario")?.remove();
}}
/>
</div>
)}
<div class="inspector__head">
<div class="inspector__title">{tidyName(server.name)}</div>
<div class="inspector__sub">
{server.scenarioName || "Unnamed scenario"}
{server.gameVersion && ` — ${server.gameVersion}`}
</div>
<div class="tags" style={{ marginTop: "7px", flexWrap: "wrap" }}>
<span class={`tag ${server.online ? "tag--accent" : "tag--warn"}`}>
{server.online ? "ONLINE" : "NOT LISTED"}
</span>
{/* The same marks as the table row, so the two views agree. */}
<StatusMarks server={server} />
<PlatformBadges platforms={server.platforms} />
</div>
</div>
<div class="inspector__body">
<div class="facts">
<Fact k="Players" v={formatPlayers(server.players, server.maxPlayers)} />
<Fact
k="Queue"
v={server.queueMax > 0 ? `${server.queue} / ${server.queueMax}` : "—"}
/>
<Fact k="Region" v={humaniseRegion(server.pingSiteId ?? server.region)} />
<PingFact />
{/* The API reports the server's simulation FPS, not a client ping. */}
<Fact k="Server FPS" v={server.fps > 0 ? String(server.fps) : "—"} />
<Fact k="Mods" v={server.modCount > 0 ? String(server.modCount) : "None"} />
{/* Published by the API; shown as text and never acted on. */}
<Fact
k="Address"
v={server.host ? `${server.host}:${server.port}` : "Not published"}
/>
<Fact k="Host" v={server.platform ?? "Unknown"} />
</div>
<HistoryChart />
{server.mods.length > 0 && (
<div class="section">
<div class="section__h">
<span>Required mods</span>
<span>
{server.mods.length} total
{server.modSummary.knownSize > 0 &&
`, ${formatBytes(server.modSummary.knownSize)}`}
</span>
</div>
<ReadinessNote />
<div class="modlist">
{/* Three columns rather than one concatenated string, so versions
and sizes line up and the list can be scanned down. */}
{server.mods.map((mod) => (
<div class="modlist__row" key={mod.id}>
<span class="modlist__name" title={mod.name || mod.id}>
{mod.name || mod.id}
</span>
<span class="modlist__version">{mod.version || "—"}</span>
<span class="modlist__size">
{mod.sizeKnown && mod.size > 0 ? formatBytes(mod.size) : ""}
</span>
</div>
))}
</div>
</div>
)}
</div>
<div class="inspector__foot">
{failure && (
<p class="state__hint" style={{ color: "var(--bad)", marginTop: 0 }}>
{failure.message}
</p>
)}
<button
class="btn btn--primary"
disabled={busy || !server.online}
title={server.online ? "Close Arma Reforger first if it is running" : "Not listed"}
onClick={() => void join({ id: server.id, name: server.name })}
>
{busy ? "Handing off to Steam…" : "Join server"}
</button>
{discord && (
<button
class="btn btn--discord"
title={`discord.gg/${discord.code}`}
onClick={() => void openDiscordInvite(discord.code)}
>
Join Discord
</button>
)}
<div class="btn-row">
<button class="btn" onClick={() => void toggleFavorite(server.id)}>
{isFavorite ? "★ Favorited" : "☆ Favorite"}
</button>
<button
class="btn"
title="Open on reforgermods.net"
onClick={() => void openServerPage(server.id)}
>
Open page
</button>
<button
class="btn"
disabled={refreshing}
onClick={() => void selectServer(server.id)}
>
{refreshing ? "…" : "Refresh"}
</button>
</div>
</div>
</aside>
);
}
/**
* Measured round-trip latency to the server, by ICMP.
*
* Shows the median of three probes. When the host does not answer — about a
* third of servers filter ICMP — it says so rather than substituting a
* region-level estimate, which would look like a per-server measurement.
*/
function PingFact() {
const result = ping.value;
const loading = pingLoading.value;
if (loading && !result) return <Fact k="Ping" v="…" />;
const summary = result?.summary;
if (!summary) {
return (
<div class="fact" title={result?.message ?? "Not measured."}>
<div class="fact__k">Ping</div>
<div class="fact__v fact__v--muted">—</div>
</div>
);
}
const loss = summary.sent > 0 ? Math.round(((summary.sent - summary.received) / summary.sent) * 100) : 0;
return (
<div
class="fact"
title={`Best ${summary.bestMs} ms`}
>
<div class="fact__k">Ping{loss > 0 ? ` (${loss}% loss)` : ""}</div>
<div class="fact__v">{summary.medianMs} ms</div>
</div>
);
}
function Fact({ k, v }: { k: string; v: string }) {
return (
<div class="fact">
<div class="fact__k">{k}</div>
<div class="fact__v" title={v}>
{v}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Platform badges.
*
* The same three marks reforgermods.net uses, so a server reads the same in
* both places. Glyphs are Bootstrap Icons (MIT) — `pc-display`, `xbox` and
* `playstation` — inlined as paths rather than pulled in as a webfont, since
* three icons do not justify a font file and the CSP forbids remote fonts.
*/
import type { JSX } from "preact";
/** Server-reported platform tokens. */
export type PlatformToken = "pc" | "xbox" | "psn";
interface Glyph {
label: string;
/** Brand colour, used sparingly: the badge tint only. */
tint: string;
path: string;
}
const GLYPHS: Record<PlatformToken, Glyph> = {
pc: {
label: "PC",
tint: "#9aa7b7",
path: "M8 1a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1zm1 13.5a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0m2 0a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0M9.5 1a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM9 3.5a.5.5 0 0 0 .5.5h5a.5.5 0 0 0 0-1h-5a.5.5 0 0 0-.5.5M1.5 2A1.5 1.5 0 0 0 0 3.5v7A1.5 1.5 0 0 0 1.5 12H6v2h-.5a.5.5 0 0 0 0 1H7v-4H1.5a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5H7V2z",
},
xbox: {
label: "Xbox",
tint: "#6bad4a",
path: "M7.202 15.967a8 8 0 0 1-3.552-1.26c-.898-.585-1.101-.826-1.101-1.306 0-.965 1.062-2.656 2.879-4.583C6.459 7.723 7.897 6.44 8.052 6.475c.302.068 2.718 2.423 3.622 3.531 1.43 1.753 2.088 3.189 1.754 3.829-.254.486-1.83 1.437-2.987 1.802-.954.301-2.207.429-3.239.33m-5.866-3.57C.589 11.253.212 10.127.03 8.497c-.06-.539-.038-.846.137-1.95.218-1.377 1.002-2.97 1.945-3.95.401-.417.437-.427.926-.263.595.2 1.23.638 2.213 1.528l.574.519-.313.385C4.056 6.553 2.52 9.086 1.94 10.653c-.315.852-.442 1.707-.306 2.063.091.24.007.15-.3-.319Zm13.101.195c.074-.36-.019-1.02-.238-1.687-.473-1.443-2.055-4.128-3.508-5.953l-.457-.575.494-.454c.646-.593 1.095-.948 1.58-1.25.381-.237.927-.448 1.161-.448.145 0 .654.528 1.065 1.104a8.4 8.4 0 0 1 1.343 3.102c.153.728.166 2.286.024 3.012a9.5 9.5 0 0 1-.6 1.893c-.179.393-.624 1.156-.82 1.404-.1.128-.1.127-.043-.148ZM7.335 1.952c-.67-.34-1.704-.705-2.276-.803a4 4 0 0 0-.759-.043c-.471.024-.45 0 .306-.358A7.8 7.8 0 0 1 6.47.128c.8-.169 2.306-.17 3.094-.005.85.18 1.853.552 2.418.9l.168.103-.385-.02c-.766-.038-1.88.27-3.078.853-.361.176-.676.316-.699.312a12 12 0 0 1-.654-.319Z",
},
psn: {
label: "PlayStation",
tint: "#6d8fd6",
path: "M15.858 11.451c-.313.395-1.079.676-1.079.676l-5.696 2.046v-1.509l4.192-1.493c.476-.17.549-.412.162-.538-.386-.127-1.085-.09-1.56.08l-2.794.984v-1.566l.161-.054s.807-.286 1.942-.412c1.135-.125 2.525.017 3.616.43 1.23.39 1.368.962 1.056 1.356M9.625 8.883v-3.86c0-.453-.083-.87-.508-.988-.326-.105-.528.198-.528.65v9.664l-2.606-.827V2c1.108.206 2.722.692 3.59.985 2.207.757 2.955 1.7 2.955 3.825 0 2.071-1.278 2.856-2.903 2.072Zm-8.424 3.625C-.061 12.15-.271 11.41.304 10.984c.532-.394 1.436-.69 1.436-.69l3.737-1.33v1.515l-2.69.963c-.474.17-.547.411-.161.538.386.126 1.085.09 1.56-.08l1.29-.469v1.356l-.257.043a8.45 8.45 0 0 1-4.018-.323Z",
},
};
/** True for a token the launcher knows how to draw. */
export function isPlatformToken(value: string): value is PlatformToken {
return value === "pc" || value === "xbox" || value === "psn";
}
/**
* Renders the platforms a server accepts.
*
* Unknown tokens are skipped rather than drawn as a placeholder: a future
* platform should be invisible here until it has a real mark.
*/
export function PlatformBadges({
platforms,
size = 14,
}: {
platforms: string[];
size?: number;
}): JSX.Element | null {
const known = platforms.filter(isPlatformToken);
if (known.length === 0) return null;
return (
<span class="platforms">
{known.map((token) => {
const glyph = GLYPHS[token];
return (
<span class="platforms__badge" key={token} title={glyph.label}>
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill={glyph.tint}
role="img"
aria-label={glyph.label}
>
<path d={glyph.path} />
</svg>
</span>
);
})}
</span>
);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Mod readiness, as information only.
*
* Deliberately a single line of text and a count strip — not a checklist with a
* "prepare" button. There is no verified way to install Reforger Workshop
* content from outside the game, so the launcher states what it knows and what
* the game will do, and offers no action it cannot perform. See
* `docs/mod-readiness.md`.
*/
import { readiness } from "../lib/state";
import { readinessSummary, readinessTone } from "../lib/format";
export function ReadinessNote() {
const report = readiness.value;
// Null means the check has not answered yet. Rendering nothing is correct:
// an unknown state must never look like a reassuring one.
if (!report) return null;
const tone = readinessTone(report);
return (
<div class="readiness" data-tone={tone}>
<p class="readiness__line">
{/* A dot carries the state without a coloured edge down the side. */}
<span class="readiness__dot" />
{readinessSummary(report)}
</p>
{report.available && report.required > 0 && (
<dl class="readiness__counts">
<div>
<dt>Installed</dt>
<dd>{report.upToDate}</dd>
</div>
{report.outdated > 0 && (
<div>
<dt>Outdated</dt>
<dd>{report.outdated}</dd>
</div>
)}
{report.missing > 0 && (
<div>
<dt>Missing</dt>
<dd>{report.missing}</dd>
</div>
)}
<div>
<dt>On disk</dt>
<dd>{report.installedTotal}</dd>
</div>
</dl>
)}
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
/**
* The server table.
*
* Windowed: only the rows intersecting the viewport are in the DOM, so a
* 500-row page costs the same as a 20-row one. Row height is fixed in CSS
* (`--row-h`) and mirrored here, which is what makes the arithmetic cheap
* enough to run on every scroll frame.
*/
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import {
activeSort,
favoriteSet,
pingFor,
selectServer,
selectedId,
sortByColumn,
sortIsPageLocal,
toggleFavorite,
} from "../lib/state";
import type { SortColumn } from "../lib/sort";
import { formatPlayers, humaniseRegion, tidyName } from "../lib/format";
import { PlatformBadges } from "./PlatformBadges";
import { FavouriteStar, StatusMarks } from "./StatusMarks";
import type { ServerSummary } from "../lib/types";
const ROW_HEIGHT = 30;
/** Rows rendered beyond the viewport, so fast scrolling does not show gaps. */
const OVERSCAN = 8;
interface Props {
servers: ServerSummary[];
/**
* True when these rows are one page of a larger, API-ordered result.
*
* It decides both halves of the sorting behaviour: whether a header click can
* ask the API to reorder the whole dataset, and whether an ordering it cannot
* serve has to be marked as reaching only the loaded page. The favorites and
* recents tables pass false — those lists are resolved in full, so sorting
* them is always complete and never needs the caveat.
*/
paginated?: boolean;
}
export function ServerTable({ servers, paginated = true }: Props) {
const scrollerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(600);
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const measure = () => setViewportHeight(el.clientHeight);
measure();
// ResizeObserver catches layout changes the window never sees, such as the
// inspector collapsing at narrow widths. Fall back to window resize where
// it is unavailable, so the list still sizes correctly.
if (typeof ResizeObserver === "function") {
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
const onScroll = useCallback((event: Event) => {
setScrollTop((event.currentTarget as HTMLDivElement).scrollTop);
}, []);
const first = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const visibleCount = Math.ceil(viewportHeight / ROW_HEIGHT) + OVERSCAN * 2;
const last = Math.min(servers.length, first + visibleCount);
const visibleRows = servers.slice(first, last);
const selected = selectedId.value;
const favorites = favoriteSet.value;
/** Moves the selection by `delta` rows and keeps it in view. */
const move = useCallback(
(delta: number) => {
if (servers.length === 0) return;
const current = servers.findIndex((s) => s.id === selectedId.value);
const nextIndex = Math.min(
servers.length - 1,
Math.max(0, current === -1 ? 0 : current + delta),
);
const next = servers[nextIndex];
if (!next) return;
void selectServer(next.id);
const el = scrollerRef.current;
if (!el) return;
const top = nextIndex * ROW_HEIGHT;
if (top < el.scrollTop) el.scrollTop = top;
else if (top + ROW_HEIGHT > el.scrollTop + el.clientHeight) {
el.scrollTop = top + ROW_HEIGHT - el.clientHeight;
}
},
[servers],
);
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
move(1);
break;
case "ArrowUp":
event.preventDefault();
move(-1);
break;
case "PageDown":
event.preventDefault();
move(Math.floor(viewportHeight / ROW_HEIGHT));
break;
case "PageUp":
event.preventDefault();
move(-Math.floor(viewportHeight / ROW_HEIGHT));
break;
case "Home":
event.preventDefault();
move(-servers.length);
break;
case "End":
event.preventDefault();
move(servers.length);
break;
}
},
[move, viewportHeight, servers.length],
);
return (
<div
class="table"
ref={scrollerRef}
onScroll={onScroll}
onKeyDown={onKeyDown}
tabIndex={0}
role="listbox"
aria-label="Servers"
aria-activedescendant={selected ? `row-${selected}` : undefined}
>
{/* Inside the scroller, so the header and the rows share one width. With
the header outside, a visible scrollbar narrowed the rows and every
column drifted left of its heading. */}
<div class="thead" role="presentation">
<SortHeader column="name" label="Server" paginated={paginated} />
<div />
<SortHeader column="players" label="Players" align="num" paginated={paginated} />
<SortHeader column="queue" label="Queue" align="num" paginated={paginated} />
<SortHeader column="ping" label="Ping" align="mid" paginated={paginated} />
<SortHeader column="region" label="Region" paginated={paginated} />
<SortHeader column="mods" label="Mods" align="num" paginated={paginated} />
{/* Not sortable: a server carries a set of platforms, not a value to
order by. */}
<div>Platform</div>
</div>
<div class="rows" style={{ height: `${servers.length * ROW_HEIGHT}px` }}>
{visibleRows.map((server, i) => (
<Row
key={server.id}
server={server}
top={(first + i) * ROW_HEIGHT}
isSelected={server.id === selected}
isFavorite={favorites.has(server.id)}
/>
))}
</div>
</div>
);
}
interface SortHeaderProps {
column: SortColumn;
label: string;
/** Matches the column's cell alignment, so heading and values line up. */
align?: "num" | "mid";
paginated: boolean;
}
/**
* A clickable column heading.
*
* The arrow is filled when the ordering covers every matching server and hollow
* when it only reorders the rows already loaded — a distinction the API forces,
* since it sorts by `players` and `name` alone and takes no direction. Shape
* rather than colour alone carries it, and the tooltip says it in words.
*/
function SortHeader({ column, label, align, paginated }: SortHeaderProps) {
const active = activeSort.value;
const isActive = active?.column === column;
const pageLocal = isActive && paginated && sortIsPageLocal.value;
const ascending = active?.direction === "asc";
let title: string;
if (!isActive) {
title = `Sort by ${label.toLowerCase()}`;
} else if (pageLocal) {
title = `Sorted by ${label.toLowerCase()} — reorders this page only`;
} else if (paginated) {
title = `Sorted by ${label.toLowerCase()} across all matching servers`;
} else {
title = `Sorted by ${label.toLowerCase()}`;
}
return (
<button
type="button"
class={`th-sort${align ? ` ${align}` : ""}`}
data-active={isActive}
title={title}
aria-label={title}
onClick={() => sortByColumn(column, paginated)}
>
<span class="th-sort__label">{label}</span>
{isActive && (
<span class="th-sort__arrow" data-scope={pageLocal ? "page" : "all"} aria-hidden="true">
{pageLocal ? (ascending ? "\u25b3" : "\u25bd") : ascending ? "\u25b2" : "\u25bc"}
</span>
)}
</button>
);
}
interface RowProps {
server: ServerSummary;
top: number;
isSelected: boolean;
isFavorite: boolean;
}
/**
* Latency to the server's region.
*
* Region, not per-server: the list endpoint publishes no address, so eight
* probes stand in for a whole page. The title says so, and the inspector shows
* the exact per-server figure.
*/
function PingCell({ pingSiteId }: { pingSiteId: string | null }) {
const ms = pingFor(pingSiteId);
if (ms === null) return <div class="cell mid dim">—</div>;
const band = ms < 60 ? "good" : ms < 140 ? "ok" : "far";
return (
<div class="cell mid ping" data-band={band} title="Region latency">
{ms}
</div>
);
}
function Row({ server, top, isSelected, isFavorite }: RowProps) {
return (
<div
id={`row-${server.id}`}
class={`row${server.online ? "" : " row--offline"}`}
style={{ top: `${top}px` }}
role="option"
aria-selected={isSelected}
onClick={() => void selectServer(server.id)}
>
<div class="name">
<FavouriteStar
isFavorite={isFavorite}
onToggle={() => void toggleFavorite(server.id)}
/>
{/* Server names are untrusted text and are rendered as text content only. */}
<span class="name__text" title={tidyName(server.name)}>
{tidyName(server.name)}
</span>
</div>
{/* Own column, so the marks line up instead of trailing each name. */}
<StatusMarks server={server} />
<div class="cell num" data-full={server.players >= server.maxPlayers}>
{formatPlayers(server.players, server.maxPlayers)}
</div>
<div class="cell num dim">
{server.queue > 0 ? `+${server.queue}` : "—"}
</div>
<PingCell pingSiteId={server.pingSiteId} />
<div class="cell dim" title={server.pingSiteId ?? undefined}>
{humaniseRegion(server.pingSiteId ?? server.region)}
</div>
<div class="cell num dim">{server.modCount > 0 ? server.modCount : "—"}</div>
<PlatformBadges platforms={server.platforms} />
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
/** Persistent left navigation. */
import { favorites, history, recents, setView, view, type View } from "../lib/state";
interface Item {
id: View;
label: string;
count?: number;
}
export function Sidebar() {
const items: Item[] = [
{ id: "browse", label: "Server browser" },
{ id: "favorites", label: "Favorites", count: favorites.value.length },
{ id: "recent", label: "Recent", count: recents.value.length },
];
return (
<nav class="nav" aria-label="Sections">
<div class="nav__label">Play</div>
{items.map((item) => (
<button
key={item.id}
class="nav__item"
aria-current={view.value === item.id}
onClick={() => setView(item.id)}
>
<span>{item.label}</span>
{item.count !== undefined && item.count > 0 && (
<span class="nav__count">{item.count}</span>
)}
</button>
))}
<div class="nav__label">Launcher</div>
<button
class="nav__item"
aria-current={view.value === "settings"}
onClick={() => setView("settings")}
>
<span>Settings</span>
{history.value.length > 0 && <span class="nav__count">{history.value.length}</span>}
</button>
<div class="nav__spacer" />
<div class="nav__foot">
reforgermods.net is not affiliated with Bohemia Interactive.
</div>
</nav>
);
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Row status marks and the favourite star.
*
* These replace the text pills that used to trail the server name. The pills
* started wherever the name happened to end, so they formed a ragged column
* that read as clutter on every row; as icons in a fixed slot they line up and
* become scannable. Glyphs are Bootstrap Icons (MIT), inlined as paths.
*/
import type { JSX } from "preact";
const LOCK = "M8 0a4 4 0 0 1 4 4v2.05a2.5 2.5 0 0 1 2 2.45v5a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 2 13.5v-5a2.5 2.5 0 0 1 2-2.45V4a4 4 0 0 1 4-4m0 1a3 3 0 0 0-3 3v2h6V4a3 3 0 0 0-3-3";
const OFFICIAL = "M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01zm.287 5.984-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708.708";
const BATTLEYE = "M5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.8 11.8 0 0 1-2.517 2.453 7 7 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7 7 0 0 1-1.048-.625 11.8 11.8 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 63 63 0 0 1 5.072.56";
const OFFLINE = "M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1z";
const STAR_FILLED = "M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z";
const STAR_OUTLINE = "M2.866 14.85c-.078.444.36.791.746.593l4.39-2.256 4.389 2.256c.386.198.824-.149.746-.592l-.83-4.73 3.522-3.356c.33-.314.16-.888-.282-.95l-4.898-.696L8.465.792a.513.513 0 0 0-.927 0L5.354 5.12l-4.898.696c-.441.062-.612.636-.283.95l3.523 3.356-.83 4.73zm4.905-2.767-3.686 1.894.694-3.957a.56.56 0 0 0-.163-.505L1.71 6.745l4.052-.576a.53.53 0 0 0 .393-.288L8 2.223l1.847 3.658a.53.53 0 0 0 .393.288l4.052.575-2.906 2.77a.56.56 0 0 0-.163.506l.694 3.957-3.686-1.894a.5.5 0 0 0-.461 0z";
function Glyph({ path, size }: { path: string; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d={path} />
</svg>
);
}
/**
* The status marks for one server, in a fixed order.
*
* Order is constant so a given mark always occupies the same position in the
* slot, which is what lets the eye skip the column rather than read it.
*/
export function StatusMarks({ server }: {
server: {
official: boolean;
passwordProtected: boolean;
battlEye: boolean;
online: boolean;
};
}): JSX.Element {
return (
<span class="marks">
{server.official && (
<span class="marks__i marks__i--official" title="Official server">
<Glyph path={OFFICIAL} size={12} />
</span>
)}
{server.passwordProtected && (
<span class="marks__i marks__i--locked" title="Password required">
<Glyph path={LOCK} size={12} />
</span>
)}
{server.battlEye && (
<span class="marks__i" title="BattlEye enabled">
<Glyph path={BATTLEYE} size={12} />
</span>
)}
{!server.online && (
<span class="marks__i marks__i--offline" title="Not currently listed">
<Glyph path={OFFLINE} size={12} />
</span>
)}
</span>
);
}
/** Favourite toggle. */
export function FavouriteStar({
isFavorite,
onToggle,
}: {
isFavorite: boolean;
onToggle: () => void;
}): JSX.Element {
return (
<button
class="star"
data-on={isFavorite}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-pressed={isFavorite}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<Glyph path={isFavorite ? STAR_FILLED : STAR_OUTLINE} size={14} />
</button>
);
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Compact API status for the header.
*
* Reads `nowMs`, which ticks once a second, so the "updated Ns ago" figure stays
* live. Because signals subscribe per component, that tick re-renders this pill
* and nothing else — the server table is untouched.
*/
import { nowMs, status } from "../lib/state";
import { formatAge } from "../lib/format";
export function StatusPill() {
const s = status.value;
// Subscribe to the clock so the age figure advances.
const now = nowMs.value;
if (!s) {
return (
<div class="status" title="Checking the reforgermods.net API.">
<span class="status__dot" />
<span>Checking API…</span>
</div>
);
}
if (!s.apiReachable) {
return (
<div class="status" title={s.note ?? "The reforgermods.net API did not respond."}>
<span class="status__dot status__dot--bad" />
<span>API unreachable</span>
</div>
);
}
const dataset = s.dataset;
// `snapshotAgeSeconds` was measured when the response was produced; advance it
// by the time since, so the figure keeps counting between requests.
const measuredAtMs = datasetMeasuredAt(dataset?.snapshotAgeSeconds);
const age =
dataset && measuredAtMs !== null
? dataset.snapshotAgeSeconds + (now - measuredAtMs) / 1000
: null;
const stale = dataset?.stale === true;
const warming = dataset?.warming === true;
const dotClass = warming || stale ? "status__dot--warn" : "status__dot--ok";
return (
<div class="status" title={s.note ?? undefined}>
<span class={`status__dot ${dotClass}`} />
<span>{s.reportedStatus ? `API ${s.reportedStatus}` : "API operational"}</span>
{dataset && (
<>
<span class="status__divider" />
<span>
{warming
? "Data warming up"
: age === null
? "Data age unknown"
: `Updated ${formatAge(age)}`}
</span>
</>
)}
</div>
);
}
/**
* Wall-clock time the current `snapshotAgeSeconds` reading was taken.
*
* Tracked here rather than in state because it exists only to make the label
* tick; it is reset whenever the reading changes.
*/
let lastAgeSeconds: number | null = null;
let lastAgeAtMs: number | null = null;
function datasetMeasuredAt(ageSeconds: number | undefined): number | null {
if (ageSeconds === undefined) return null;
if (ageSeconds !== lastAgeSeconds) {
lastAgeSeconds = ageSeconds;
lastAgeAtMs = Date.now();
}
return lastAgeAtMs;
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Custom window title bar.
*
* The native Windows caption plus our own brand strip meant two stacked bars
* doing one job. This is a single row: brand, context, status, window controls.
* The window is frameless (`decorations: false`), so dragging and the caption
* buttons are wired here.
*/
import { getCurrentWindow } from "@tauri-apps/api/window";
import { StatusPill } from "./StatusPill";
import { fleet, totalServers, view } from "../lib/state";
import brandMark from "../assets/brand-mark.png";
/**
* Resolved lazily: `getCurrentWindow()` reads Tauri internals that only exist
* inside the app, so calling it at module scope would break any environment
* that merely imports this file.
*/
function windowAction(action: "minimize" | "toggleMaximize" | "close"): void {
try {
void getCurrentWindow()[action]();
} catch {
// No Tauri window (tests, or a browser preview): nothing to do.
}
}
export function TitleBar() {
return (
<header class="titlebar" data-tauri-drag-region>
{/* The drag region must be the element itself, not a child, or the
draggable area ends up shaped like the text inside it. */}
<img class="titlebar__mark" src={brandMark} alt="" data-tauri-drag-region />
{/* Wordmark split and coloured as on reforgermods.net. */}
<span class="wordmark" data-tauri-drag-region>
<span class="wordmark__reforger">reforger</span>
<span class="wordmark__mods">mods</span>
<span class="wordmark__net">.net</span>
<em class="wordmark__app">launcher</em>
</span>
<span class="titlebar__context" data-tauri-drag-region>
{view.value === "browse" && totalServers.value > 0
? `${totalServers.value.toLocaleString()} servers`
: ""}
{fleet.value && fleet.value.players > 0 && (
<>
{view.value === "browse" && totalServers.value > 0 && (
<span class="titlebar__dot" />
)}
{`${fleet.value.players.toLocaleString()} players online`}
</>
)}
</span>
<div class="titlebar__spacer" data-tauri-drag-region />
<StatusPill />
<div class="wincontrols">
<button
class="wincontrols__btn"
aria-label="Minimise"
onClick={() => windowAction("minimize")}
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path d="M0 5h10" stroke="currentColor" stroke-width="1" />
</svg>
</button>
<button
class="wincontrols__btn"
aria-label="Maximise"
onClick={() => windowAction("toggleMaximize")}
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<rect
x="0.5"
y="0.5"
width="9"
height="9"
fill="none"
stroke="currentColor"
stroke-width="1"
/>
</svg>
</button>
<button
class="wincontrols__btn wincontrols__btn--close"
aria-label="Close"
onClick={() => windowAction("close")}
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path d="M0 0l10 10M10 0L0 10" stroke="currentColor" stroke-width="1" />
</svg>
</button>
</div>
</header>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { buildChart } from "./chart";
import type { ServerHistoryPoint } from "./types";
function point(t: number, min: number, avg: number, max: number, cap = 128): ServerHistoryPoint {
return { t, min, avg, max, cap };
}
describe("buildChart", () => {
it("refuses to draw a series too short to be a line", () => {
expect(buildChart([], 300, 56)).toBeNull();
expect(buildChart([point(0, 1, 1, 1)], 300, 56)).toBeNull();
});
it("refuses a zero-sized box", () => {
const points = [point(0, 0, 0, 0), point(300, 1, 1, 1)];
expect(buildChart(points, 0, 56)).toBeNull();
expect(buildChart(points, 300, 0)).toBeNull();
});
it("spans the full width and inverts the Y axis", () => {
const points = [point(0, 0, 0, 0), point(300, 10, 10, 10)];
const chart = buildChart(points, 300, 56);
expect(chart).not.toBeNull();
// Average line: starts bottom-left at the zero bucket, ends top-right at the peak.
expect(chart?.line).toBe("M0,56L300,0");
});
it("closes the min-max band", () => {
const points = [point(0, 1, 2, 3), point(300, 1, 2, 3)];
const chart = buildChart(points, 300, 60);
// Upper edge left-to-right, lower edge back, closed.
expect(chart?.band).toBe("M0,0L300,0L300,40L0,40Z");
});
it("scales to the observed peak, not to capacity", () => {
// A server peaking at 8 of 128 must not draw as a flat line on the axis.
const points = [point(0, 0, 4, 8), point(300, 2, 3, 4)];
const chart = buildChart(points, 300, 100);
expect(chart?.peak).toBe(8);
expect(chart?.cap).toBe(128);
// The 8-player peak reaches the top of the box.
expect(chart?.line.startsWith("M0,50")).toBe(true);
expect(chart?.band.startsWith("M0,0")).toBe(true);
});
it("survives an all-zero series without dividing by zero", () => {
const points = [point(0, 0, 0, 0), point(300, 0, 0, 0), point(600, 0, 0, 0)];
const chart = buildChart(points, 300, 56);
expect(chart?.peak).toBe(0);
expect(chart?.line).not.toContain("NaN");
expect(chart?.band).not.toContain("NaN");
});
it("reports the summary figures the legend renders", () => {
const points = [point(100, 0, 10, 20), point(200, 5, 15, 25), point(300, 1, 2, 3)];
const chart = buildChart(points, 300, 56);
expect(chart?.peak).toBe(25);
expect(chart?.low).toBe(0);
expect(chart?.mean).toBe(9); // (10 + 15 + 2) / 3
expect(chart?.from).toBe(100);
expect(chart?.to).toBe(300);
});
it("falls back to zero capacity when no bucket recorded one", () => {
const points = [point(0, 1, 1, 1, 0), point(300, 1, 1, 1, 0)];
expect(buildChart(points, 300, 56)?.cap).toBe(0);
});
it("emits compact path data", () => {
const points = Array.from({ length: 288 }, (_, i) => point(i * 300, i % 7, i % 11, i % 13));
const chart = buildChart(points, 300, 56);
// Two decimals max, so 288 buckets stay a reasonable DOM string.
expect(chart?.line).not.toMatch(/\.\d{3}/);
expect((chart?.line.length ?? 0)).toBeLessThan(4000);
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* Geometry for the population history chart.
*
* Pure: it turns history buckets into SVG path strings and nothing else, so the
* scaling rules are unit-testable without rendering anything.
*
* The Y axis scales to the observed peak rather than to slot capacity. A server
* that peaks at 8 of 128 would otherwise draw as a flat line on the axis; the
* capacity is stated in the label instead, where it reads better anyway.
*/
import type { ServerHistoryPoint } from "./types";
export interface ChartGeometry {
/** Filled area between each bucket's min and max. */
band: string;
/** Line through each bucket's average. */
line: string;
/** Highest `max` in the series, which is the top of the Y scale. */
peak: number;
/** Lowest `min` in the series. */
low: number;
/** Average of the bucket averages, rounded. */
mean: number;
/** Slot capacity, from the last bucket that recorded one. 0 when unknown. */
cap: number;
/** First and last bucket timestamps, unix seconds. */
from: number;
to: number;
}
/**
* Builds the chart paths for `points` inside a `width` x `height` box.
*
* Returns `null` for a series too short to draw, which the caller renders as an
* empty state rather than an axis with nothing on it.
*/
export function buildChart(
points: ServerHistoryPoint[],
width: number,
height: number,
): ChartGeometry | null {
if (points.length < 2 || width <= 0 || height <= 0) return null;
const peak = points.reduce((acc, p) => Math.max(acc, p.max), 0);
const low = points.reduce((acc, p) => Math.min(acc, p.min), Number.POSITIVE_INFINITY);
const meanRaw = points.reduce((acc, p) => acc + p.avg, 0) / points.length;
const cap = points.reduce((acc, p) => (p.cap > 0 ? p.cap : acc), 0);
// An all-zero series still needs a finite scale, or every point divides by zero.
const scaleMax = peak > 0 ? peak : 1;
const lastIndex = points.length - 1;
const x = (i: number) => round((i / lastIndex) * width);
const y = (value: number) => round(height - (value / scaleMax) * height);
// Upper edge left-to-right, lower edge right-to-left, closed.
const upper = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(i)},${y(p.max)}`);
const lower = points
.slice()
.reverse()
.map((p, i) => `L${x(lastIndex - i)},${y(p.min)}`);
const band = `${upper.join("")}${lower.join("")}Z`;
const line = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(i)},${y(p.avg)}`).join("");
return {
band,
line,
peak,
low: Number.isFinite(low) ? low : 0,
mean: Math.round(meanRaw),
cap,
from: points[0]?.t ?? 0,
to: points[lastIndex]?.t ?? 0,
};
}
/** Two decimals is plenty for path data and keeps the DOM string small. */
function round(value: number): number {
return Math.round(value * 100) / 100;
}
+323
View File
@@ -0,0 +1,323 @@
import { describe, expect, it } from "vitest";
import {
discordInviteFrom,
formatAge,
formatBytes,
formatPlayers,
formatTimestamp,
humaniseRegion,
readinessSummary,
safeImageUrl,
readinessTone,
tidyName,
} from "./format";
describe("formatBytes", () => {
it("uses binary units", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1024)).toBe("1.0 KiB");
expect(formatBytes(1024 * 1024)).toBe("1.0 MiB");
// The real figure from a 122-mod server.
expect(formatBytes(18870301863)).toBe("17.6 GiB");
});
it("drops the decimal at three digits so columns stay narrow", () => {
expect(formatBytes(1024 * 200)).toBe("200 KiB");
expect(formatBytes(1024 * 99.5)).toBe("99.5 KiB");
});
it("handles nonsense input without throwing", () => {
expect(formatBytes(-1)).toBe("0 B");
expect(formatBytes(Number.NaN)).toBe("0 B");
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0 B");
});
});
describe("formatAge", () => {
it("renders the header's freshness label", () => {
expect(formatAge(18)).toBe("18s ago");
expect(formatAge(0.4)).toBe("just now");
expect(formatAge(59.9)).toBe("59s ago");
expect(formatAge(60)).toBe("1m ago");
expect(formatAge(3600)).toBe("1h ago");
expect(formatAge(86_400)).toBe("1d ago");
});
it("reports unknown rather than a negative age", () => {
expect(formatAge(-5)).toBe("unknown");
expect(formatAge(Number.NaN)).toBe("unknown");
});
});
describe("formatTimestamp", () => {
it("is relative to the supplied clock", () => {
const now = 1_700_000_000_000;
expect(formatTimestamp(now / 1000 - 120, now)).toBe("2m ago");
});
});
describe("humaniseRegion", () => {
it("turns ping-site ids into labels", () => {
expect(humaniseRegion("new_york")).toBe("New York");
expect(humaniseRegion("frankfurt")).toBe("Frankfurt");
expect(humaniseRegion("los_angeles")).toBe("Los Angeles");
});
it("falls back for an absent region", () => {
expect(humaniseRegion(null)).toBe("—");
expect(humaniseRegion("")).toBe("—");
expect(humaniseRegion(undefined)).toBe("—");
});
});
describe("formatPlayers", () => {
it("renders the population cell", () => {
expect(formatPlayers(12, 128)).toBe("12 / 128");
});
});
describe("tidyName", () => {
it("collapses whitespace in untrusted server names", () => {
expect(tidyName(" [EU1] Some Server \n\t ")).toBe("[EU1] Some Server");
});
it("leaves ordinary names alone", () => {
expect(tidyName("[OGR2] Old Guard Revival 2")).toBe("[OGR2] Old Guard Revival 2");
});
it("does not attempt to sanitise markup, which is rendered as text", () => {
// The value is set as text content, so angle brackets are inert; the
// function's job is layout, not escaping.
expect(tidyName("<b>x</b>")).toBe("<b>x</b>");
});
});
describe("readinessSummary", () => {
it("reads as sentences, not dot-separated fragments", () => {
// The joined-with-middots form read as machine output.
expect(readinessSummary(base)).not.toContain("·");
});
const base = {
available: true,
required: 136,
upToDate: 39,
outdated: 0,
missing: 96,
missingBytes: 8_772_055_531,
unresolvedMods: 1,
installedTotal: 235,
};
it("produces the intended sentence for a partly-installed server", () => {
// "at least" because one mod's size is unresolved; the closing clause
// states what the game does, because the launcher cannot do it.
expect(readinessSummary(base)).toBe(
"96 of 136 mods missing, at least 8.17 GiB to download. " +
"Arma Reforger downloads these when you join.",
);
});
it("drops 'at least' once every size is resolved", () => {
const summary = readinessSummary({ ...base, unresolvedMods: 0 });
expect(summary).toContain("8.17 GiB to download");
expect(summary).not.toContain("at least");
});
it("never presents an unavailable scan as a verdict", () => {
const summary = readinessSummary({
available: false,
unavailable: "no_addons_directory",
message: "No local Arma Reforger addon folder found, so installed mods could not be checked.",
required: 0,
upToDate: 0,
outdated: 0,
missing: 0,
missingBytes: 0,
unresolvedMods: 0,
installedTotal: 0,
});
expect(summary).toContain("could not be checked");
expect(summary).not.toContain("up to date");
expect(summary).not.toContain("missing");
});
it("falls back to generic copy if the backend sent no message", () => {
const summary = readinessSummary({ ...base, available: false, message: undefined });
expect(summary).toBe("Installed mods could not be checked.");
});
it("reports a fully-installed server plainly", () => {
expect(
readinessSummary({ ...base, upToDate: 136, outdated: 0, missing: 0, missingBytes: 0, unresolvedMods: 0 }),
).toBe("All 136 mods installed and up to date.");
});
it("says update, not download, when only versions differ", () => {
const summary = readinessSummary({
...base,
upToDate: 135,
outdated: 1,
missing: 0,
missingBytes: 0,
unresolvedMods: 0,
});
expect(summary).toBe("1 of 136 mods out of date. Arma Reforger updates these when you join.");
});
it("combines missing and outdated counts", () => {
const summary = readinessSummary({ ...base, outdated: 1, unresolvedMods: 0 });
expect(summary).toContain("96 missing and 1 out of date of 136 mods");
});
it("says the size is unknown rather than showing 0 B", () => {
const summary = readinessSummary({ ...base, missingBytes: 0, unresolvedMods: 96 });
expect(summary).toContain("download size unknown");
expect(summary).not.toContain("0 B");
});
it("handles a vanilla server", () => {
expect(readinessSummary({ ...base, required: 0, missing: 0, missingBytes: 0, unresolvedMods: 0 })).toBe(
"No mods required.",
);
});
});
describe("readinessTone", () => {
const base = {
available: true,
required: 10,
upToDate: 10,
outdated: 0,
missing: 0,
missingBytes: 0,
unresolvedMods: 0,
installedTotal: 20,
};
it("never reads as ready when the scan did not run", () => {
expect(readinessTone({ ...base, available: false })).toBe("unknown");
});
it("distinguishes ready from needing action", () => {
expect(readinessTone(base)).toBe("ready");
expect(readinessTone({ ...base, missing: 1 })).toBe("action");
expect(readinessTone({ ...base, outdated: 1 })).toBe("action");
});
});
describe("formatBytes precision override", () => {
it("keeps the compact default for table cells", () => {
expect(formatBytes(18870301863)).toBe("17.6 GiB");
});
it("honours an explicit precision for download figures", () => {
expect(formatBytes(8_772_055_531, 2)).toBe("8.17 GiB");
expect(formatBytes(8_772_055_531, 0)).toBe("8 GiB");
});
it("never puts decimals on whole bytes", () => {
expect(formatBytes(500, 2)).toBe("500 B");
});
});
describe("safeImageUrl", () => {
const real =
"https://ar-gcp-cdn.bistudio.com/image/b64d/02af30860106e7097a8238852468fb85d249062ae56b43b1d6c34e70c20f/45316.jpg";
it("accepts the CDN the API actually uses", () => {
// Verified across 300 servers: every image URL was https on this host.
expect(safeImageUrl(real)).toBe(real);
});
it("treats an absent image as absent, not an error", () => {
// About half of servers publish no imagery at all.
expect(safeImageUrl(null)).toBeNull();
expect(safeImageUrl(undefined)).toBeNull();
expect(safeImageUrl("")).toBeNull();
});
it("refuses non-https schemes", () => {
expect(safeImageUrl("http://ar-gcp-cdn.bistudio.com/a.jpg")).toBeNull();
expect(safeImageUrl("data:image/png;base64,iVBORw0KGgo=")).toBeNull();
expect(safeImageUrl("javascript:alert(1)")).toBeNull();
expect(safeImageUrl("file:///etc/passwd")).toBeNull();
});
it("refuses lookalike and subdomain-suffix hosts", () => {
for (const url of [
"https://evil.test/a.jpg",
"https://ar-gcp-cdn.bistudio.com.attacker.test/a.jpg",
"https://notar-gcp-cdn.bistudio.com/a.jpg",
"https://sub.ar-gcp-cdn.bistudio.com/a.jpg",
"https://ar-gcp-cdn.bistudio.com@evil.test/a.jpg",
]) {
expect(safeImageUrl(url), url).toBeNull();
}
});
it("refuses unparseable input", () => {
expect(safeImageUrl("not a url")).toBeNull();
expect(safeImageUrl("//ar-gcp-cdn.bistudio.com/a.jpg")).toBeNull();
});
});
describe("discordInviteFrom", () => {
it("finds invites in real server names", () => {
for (const [name, code] of [
["[OGR2] Old Guard Revival | discord.gg/oldguardarma", "oldguardarma"],
["[AU] FTA #2 | From The Ashes | discord.gg/from-the-ashes", "from-the-ashes"],
["EXD.gg 1PP | https://discord.gg/AbC_123", "AbC_123"],
["Server | https://www.discord.com/invite/xyz789", "xyz789"],
["Server | discordapp.com/invite/legacy1", "legacy1"],
] as const) {
expect(discordInviteFrom(name)?.code, name).toBe(code);
}
});
it("rebuilds the URL from the code rather than trusting the text", () => {
const found = discordInviteFrom("Join us at discord.gg/realcode today");
expect(found?.code).toBe("realcode");
expect(found?.url).toBe("https://discord.gg/realcode");
});
it("refuses a code followed by characters that could redirect it", () => {
// `discord.gg/code@evil.test` is not an invite; the trailing-boundary rule
// rejects it outright rather than extracting a code from it.
expect(discordInviteFrom("discord.gg/realcode@evil.test/path")).toBeNull();
expect(discordInviteFrom("discord.gg/realcode%2Fevil")).toBeNull();
});
it("returns nothing when there is no invite", () => {
for (const name of [
"",
"[EU1] Plain Server Name",
"discord.gg/",
"discord.gg/x",
"https://example.com/invite/abcdef",
]) {
expect(discordInviteFrom(name), name).toBeNull();
}
expect(discordInviteFrom(null)).toBeNull();
expect(discordInviteFrom(undefined)).toBeNull();
});
it("matches the site's regex, including its lack of a left boundary", () => {
// Ported verbatim from reforgermods.net so the button appears on exactly
// the same servers. A lookalike host is a known false positive upstream;
// it is harmless here because the URL is rebuilt from the code alone.
expect(discordInviteFrom("not-discord.gg/abcdef")?.url).toBe(
"https://discord.gg/abcdef",
);
});
it("stops the code at a trailing separator", () => {
expect(discordInviteFrom("come to discord.gg/abc123, we play daily")?.code).toBe("abc123");
expect(discordInviteFrom("discord.gg/abc123.")?.code).toBe("abc123");
expect(discordInviteFrom("(discord.gg/abc123)")?.code).toBe("abc123");
});
});
+199
View File
@@ -0,0 +1,199 @@
/**
* Pure display helpers.
*
* Kept free of DOM and Tauri imports so the formatting rules the UI depends on
* can be unit-tested directly.
*/
import type { ReadinessReport } from "./types";
/**
* Formats a byte count for a compact desktop layout.
*
* `digits` overrides the default precision. Table cells want one decimal so
* columns stay narrow; a download figure the user is about to commit to wants
* two, because 0.01 GiB is still 10 MB.
*/
export function formatBytes(bytes: number, digits?: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
// Whole bytes read oddly with a decimal, whatever the caller asked for.
const decimals = unit === 0 ? 0 : (digits ?? (value >= 100 ? 0 : 1));
return `${value.toFixed(decimals)} ${units[unit]}`;
}
/**
* Formats an age in seconds as a short relative label, e.g. `18s ago`.
*
* Returns `just now` under a second so the header never flashes `0s ago`.
*/
export function formatAge(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "unknown";
const s = Math.floor(seconds);
if (s < 1) return "just now";
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
/** Formats a unix-seconds timestamp as an age relative to `now`. */
export function formatTimestamp(unixSeconds: number, now = Date.now()): string {
return formatAge(now / 1000 - unixSeconds);
}
/**
* Turns a ping-site id into a readable label when the API has not supplied one,
* e.g. `new_york` -> `New York`.
*/
export function humaniseRegion(id: string | null | undefined): string {
if (!id) return "—";
return id
.split(/[_-]/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
/** `12 / 128`, with the queue appended when one exists. */
export function formatPlayers(players: number, max: number): string {
return `${players} / ${max}`;
}
/**
* Collapses whitespace in untrusted server names so a name padded with newlines
* cannot break the table layout. This is a layout measure, not a security one:
* the value is still rendered as text content, never as markup.
*/
export function tidyName(name: string): string {
return name.replace(/\s+/g, " ").trim();
}
/**
* The one-line mod readiness summary.
*
* Deliberately pure and directly tested, because this is the copy that has to
* stay honest:
*
* - "at least" appears whenever any required mod's size is unresolved, so a
* floor is never presented as a total.
* - An unavailable scan reports that it is unavailable. It never falls through
* to a reassuring message.
* - The closing clause states what actually happens — the game downloads the
* missing content during the join — because the launcher cannot do it. There
* is no "prepare mods" action to imply.
*/
export function readinessSummary(report: ReadinessReport): string {
if (!report.available) {
return report.message ?? "Installed mods could not be checked.";
}
if (report.required === 0) {
return "No mods required.";
}
if (report.missing === 0 && report.outdated === 0) {
return `All ${report.required} mods installed and up to date.`;
}
let counts: string;
if (report.missing > 0 && report.outdated > 0) {
counts = `${report.missing} missing and ${report.outdated} out of date of ${report.required} mods`;
} else if (report.missing > 0) {
counts = `${report.missing} of ${report.required} mods missing`;
} else {
counts = `${report.outdated} of ${report.required} mods out of date`;
}
if (report.missing > 0) {
if (report.missingBytes > 0) {
const size = formatBytes(report.missingBytes, 2);
// "at least" whenever a required mod's size is unresolved, so a floor is
// never presented as a total.
counts +=
report.unresolvedMods > 0
? `, at least ${size} to download`
: `, ${size} to download`;
} else if (report.unresolvedMods > 0) {
counts += ", download size unknown";
}
}
const actor =
report.missing > 0
? "Arma Reforger downloads these when you join."
: "Arma Reforger updates these when you join.";
return `${counts}. ${actor}`;
}
/** Severity for the readiness line, driving its colour only. */
export function readinessTone(report: ReadinessReport): "unknown" | "ready" | "action" {
if (!report.available) return "unknown";
return report.missing === 0 && report.outdated === 0 ? "ready" : "action";
}
/**
* The only host the API serves scenario imagery from.
*
* Verified across 300 servers: every image URL was `https` on this exact host,
* and about half of servers publish no image at all.
*/
const IMAGE_HOST = "ar-gcp-cdn.bistudio.com";
/**
* Returns `url` only if it is safe to put in an `img` tag.
*
* Image URLs arrive from the API and are therefore untrusted. This is the first
* of two layers: the page's CSP `img-src` is the enforcing one and would block
* anything else regardless, but refusing here means the launcher never even
* attempts a request to a host it did not expect.
*
* Anything that is not `https` on the known CDN host — including `data:`,
* `javascript:`, a redirect through another origin, or a lookalike domain —
* returns `null`, which renders as no image.
*/
export function safeImageUrl(url: string | null | undefined): string | null {
if (!url) return null;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
if (parsed.protocol !== "https:") return null;
// Exact host match: `evil-ar-gcp-cdn.bistudio.com.attacker.test` must not pass.
if (parsed.hostname !== IMAGE_HOST) return null;
return parsed.toString();
}
/**
* Finds a Discord invite in untrusted server text.
*
* The pattern is ported verbatim from reforgermods.net's own
* `discordInviteFromText`, so a server showing a Join Discord button on the site
* shows one here too.
*
* Only the invite **code** is taken from the text; the URL is rebuilt from it.
* Nothing a server operator writes in their name can steer where the button
* goes — a name containing `discord.gg/x@evil.test` yields a link to `x`.
*/
export function discordInviteFrom(text: string | null | undefined): {
code: string;
url: string;
} | null {
const match = String(text ?? "").match(
/(?:https?:\/\/)?(?:www\.)?(?:discord\.gg|discord(?:app)?\.com\/invite)\/([A-Za-z0-9_-]{2,64})(?=$|[\s)\]}<>"'|,;:?!./])/i,
);
const code = match?.[1];
if (!code) return null;
return { code, url: `https://discord.gg/${encodeURIComponent(code)}` };
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Typed wrappers around the Tauri command surface.
*
* Components call these, never `invoke` directly, so every command name and
* argument shape is declared once.
*/
import { invoke } from "@tauri-apps/api/core";
import type {
FleetNow,
HistoryRange,
InstallIdentity,
LauncherStatus,
LocalStateDto,
PingResult,
PingSite,
ReadinessReport,
ServerDetail,
ServerHistory,
ServerSummary,
ServerListResult,
ServerQuery,
Settings,
} from "./types";
export function listServers(query: ServerQuery): Promise<ServerListResult> {
return invoke("list_servers", { query });
}
export function getServer(id: string): Promise<ServerDetail> {
return invoke("get_server", { id });
}
/** Resolves server ids to current summaries, dropping any that no longer exist. */
export function getServers(ids: string[]): Promise<ServerSummary[]> {
return invoke("get_servers", { ids });
}
export function getServerHistory(id: string, range: HistoryRange): Promise<ServerHistory> {
return invoke("get_server_history", { id, range });
}
/**
* Compares the server's required mods against the local addon store.
*
* Reporting only — there is no command that acts on the result.
*/
export function getModReadiness(id: string): Promise<ReadinessReport> {
return invoke("get_mod_readiness", { id });
}
/**
* Measures latency to a server by ICMP echo.
*
* Resolves with a result that states why there is no figure when the host does
* not answer; it does not reject for an unreachable server.
*/
export function pingServer(id: string): Promise<PingResult> {
return invoke("ping_server", { id });
}
/**
* Latency to every ping site, keyed by site id.
*
* Eight probes cover an entire page, because `/v2/servers` publishes
* `pingSiteId` per row but no per-server address.
*/
export function pingRegions(): Promise<Record<string, PingResult>> {
return invoke("ping_regions");
}
/** Players online across the whole fleet. */
export function getFleetNow(): Promise<FleetNow> {
return invoke("get_fleet_now");
}
export function listPingSites(): Promise<PingSite[]> {
return invoke("list_ping_sites");
}
export function getStatus(): Promise<LauncherStatus> {
return invoke("get_status");
}
/** Hands a validated join URI to Steam. Resolves with the URI that was opened. */
export function joinServer(id: string, name: string): Promise<string> {
return invoke("join_server", { id, name });
}
export function launchGame(): Promise<void> {
return invoke("launch_game");
}
export function openServerPage(id: string): Promise<void> {
return invoke("open_server_page", { id });
}
/** Opens a Discord invite by code. The URL is rebuilt in Rust. */
export function openDiscordInvite(code: string): Promise<void> {
return invoke("open_discord_invite", { code });
}
export function getLocalState(): Promise<LocalStateDto> {
return invoke("get_local_state");
}
/** Returns the new favorite state for `id`. */
export function toggleFavorite(id: string): Promise<boolean> {
return invoke("toggle_favorite", { id });
}
/** Returns the settings as stored, after clamping. */
export function updateSettings(settings: Settings): Promise<Settings> {
return invoke("update_settings", { settings });
}
export function clearHistory(): Promise<void> {
return invoke("clear_history");
}
/** True when the launcher was started with RFM_DEBUG set. */
export function isDebugMode(): Promise<boolean> {
return invoke("is_debug_mode");
}
/** Install identity, for debug mode. Empty outside it. */
export function getInstallIdentity(): Promise<InstallIdentity> {
return invoke("get_install_identity");
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Table ordering.
*
* The cases that matter here are the ones where a plausible implementation is
* quietly wrong: unmeasured latency presented as fast, a direction inherited
* from the previous column, or equal values reshuffling on every refresh.
*/
import { describe, expect, it } from "vitest";
import {
API_SORTABLE,
isDatasetWide,
nextSort,
sortRows,
type PingLookup,
type TableSort,
} from "./sort";
import type { ServerSummary } from "./types";
function server(overrides: Partial<ServerSummary> & { id: string }): ServerSummary {
return {
name: `server ${overrides.id}`,
scenarioId: "{ABC}Missions/Conflict.conf",
scenarioName: "Conflict",
scenarioThumbnailUrl: null,
gameVersion: "1.8.0.13",
official: false,
passwordProtected: false,
battlEye: true,
crossPlay: true,
players: 0,
maxPlayers: 128,
queue: 0,
queueMax: 25,
pingSiteId: null,
region: "new_york",
platform: "LINUX",
platforms: ["pc"],
modCount: 0,
online: true,
firstSeen: null,
lastSeen: null,
analytics: null,
joinUrl: null,
...overrides,
};
}
/** No region was ever measured. */
const noPings: PingLookup = () => null;
const pings: PingLookup = (id) =>
({ frankfurt: 22, new_york: 95, sydney: 280 })[id ?? ""] ?? null;
const ids = (rows: ServerSummary[]) => rows.map((r) => r.id);
describe("nextSort", () => {
it("starts a column at the direction that answers the obvious question", () => {
// Busiest, most modded and lowest ping first; names from A.
expect(nextSort(null, "players")).toEqual({ column: "players", direction: "desc" });
expect(nextSort(null, "mods")).toEqual({ column: "mods", direction: "desc" });
expect(nextSort(null, "queue")).toEqual({ column: "queue", direction: "desc" });
expect(nextSort(null, "ping")).toEqual({ column: "ping", direction: "asc" });
expect(nextSort(null, "name")).toEqual({ column: "name", direction: "asc" });
expect(nextSort(null, "region")).toEqual({ column: "region", direction: "asc" });
});
it("reverses the column already sorted", () => {
const current: TableSort = { column: "players", direction: "desc" };
expect(nextSort(current, "players")).toEqual({ column: "players", direction: "asc" });
});
it("does not carry a direction across to a different column", () => {
// Otherwise clicking players (high–low) then name would sort names Z–A.
const current: TableSort = { column: "players", direction: "desc" };
expect(nextSort(current, "name")).toEqual({ column: "name", direction: "asc" });
});
});
describe("isDatasetWide", () => {
it("is true only for the orderings the API itself serves", () => {
expect(isDatasetWide({ column: "players", direction: "desc" })).toBe(true);
expect(isDatasetWide({ column: "name", direction: "asc" })).toBe(true);
});
it("is false in the reverse direction, which the API has no parameter for", () => {
expect(isDatasetWide({ column: "players", direction: "asc" })).toBe(false);
expect(isDatasetWide({ column: "name", direction: "desc" })).toBe(false);
});
it("is false for every column the API cannot sort by", () => {
for (const column of ["queue", "ping", "mods", "region"] as const) {
expect(isDatasetWide({ column, direction: "desc" })).toBe(false);
expect(isDatasetWide({ column, direction: "asc" })).toBe(false);
expect(API_SORTABLE[column]).toBeUndefined();
}
});
});
describe("sortRows", () => {
it("returns the rows untouched when nothing is sorted", () => {
const rows = [server({ id: "a" }), server({ id: "b" })];
// Identity, not just equality: the browse view re-renders on a new array.
expect(sortRows(rows, null, noPings)).toBe(rows);
});
it("does not mutate the array it was given", () => {
const rows = [server({ id: "a", players: 1 }), server({ id: "b", players: 9 })];
sortRows(rows, { column: "players", direction: "desc" }, noPings);
expect(ids(rows)).toEqual(["a", "b"]);
});
it("orders by players in both directions", () => {
const rows = [
server({ id: "mid", players: 40 }),
server({ id: "high", players: 120 }),
server({ id: "low", players: 3 }),
];
expect(ids(sortRows(rows, { column: "players", direction: "desc" }, noPings))).toEqual([
"high",
"mid",
"low",
]);
expect(ids(sortRows(rows, { column: "players", direction: "asc" }, noPings))).toEqual([
"low",
"mid",
"high",
]);
});
it("orders by name on the tidied text, as shown", () => {
const rows = [
server({ id: "b", name: " [EU] Bravo " }),
server({ id: "a", name: "[EU] Alpha" }),
];
expect(ids(sortRows(rows, { column: "name", direction: "asc" }, noPings))).toEqual([
"a",
"b",
]);
});
it("orders by queue and mod count", () => {
const rows = [
server({ id: "few", queue: 2, modCount: 5 }),
server({ id: "many", queue: 40, modCount: 190 }),
];
expect(ids(sortRows(rows, { column: "queue", direction: "desc" }, noPings))).toEqual([
"many",
"few",
]);
expect(ids(sortRows(rows, { column: "mods", direction: "desc" }, noPings))).toEqual([
"many",
"few",
]);
});
it("orders regions by the label on screen, not the raw id", () => {
// "new_york" renders as "New York", which belongs under N — before Sydney.
const rows = [
server({ id: "s", pingSiteId: "sydney" }),
server({ id: "n", pingSiteId: "new_york" }),
server({ id: "f", pingSiteId: "frankfurt" }),
];
expect(ids(sortRows(rows, { column: "region", direction: "asc" }, pings))).toEqual([
"f",
"n",
"s",
]);
});
it("orders by measured latency", () => {
const rows = [
server({ id: "far", pingSiteId: "sydney" }),
server({ id: "near", pingSiteId: "frankfurt" }),
server({ id: "mid", pingSiteId: "new_york" }),
];
expect(ids(sortRows(rows, { column: "ping", direction: "asc" }, pings))).toEqual([
"near",
"mid",
"far",
]);
});
it("keeps unmeasured regions last in both directions", () => {
// The case that must not regress: an unknown latency shown as the fastest
// thing on the page would be actively misleading.
const rows = [
server({ id: "unknown", pingSiteId: null }),
server({ id: "far", pingSiteId: "sydney" }),
server({ id: "near", pingSiteId: "frankfurt" }),
];
expect(ids(sortRows(rows, { column: "ping", direction: "asc" }, pings))).toEqual([
"near",
"far",
"unknown",
]);
expect(ids(sortRows(rows, { column: "ping", direction: "desc" }, pings))).toEqual([
"far",
"near",
"unknown",
]);
});
it("settles ties the same way every time", () => {
// Every vanilla server has zero mods; without a tie-break they would
// reshuffle on each refresh, because the source array is rebuilt per request.
const rows = [
server({ id: "quiet", name: "Zulu", players: 2 }),
server({ id: "busy", name: "Alpha", players: 60 }),
server({ id: "alsoBusy", name: "Bravo", players: 60 }),
];
const sort: TableSort = { column: "mods", direction: "desc" };
const once = ids(sortRows(rows, sort, noPings));
const twice = ids(sortRows([...rows].reverse(), sort, noPings));
expect(once).toEqual(["busy", "alsoBusy", "quiet"]);
expect(twice).toEqual(once);
});
});
+161
View File
@@ -0,0 +1,161 @@
/**
* Table ordering.
*
* Two kinds of sort exist here, and the difference is visible to the user:
*
* - **Dataset-wide.** `/v2/servers` orders every matching server before
* paginating, so the first page really is the top of the list. It accepts
* `players`, `name`, `newest` and `lastSeen`, and no direction parameter.
* - **Page-local.** Everything else — queue, ping, mod count, region, and the
* reverse of the two directions the API does offer — can only reorder the
* hundred rows already loaded. Sorting by queue does *not* find the most
* queued servers in the fleet; it reorders the page the API chose.
*
* The distinction is kept in the types rather than in a comment in the UI,
* because presenting the second as though it were the first is the kind of
* quiet lie this codebase avoids elsewhere (see the "Filters the loaded page"
* label on minimum players).
*
* These functions are pure and take latency as a lookup, so they can be tested
* without the signal graph or a Tauri host.
*/
import { humaniseRegion, tidyName } from "./format";
import type { ServerSort, ServerSummary } from "./types";
/** A column the table can be ordered by. */
export type SortColumn = "name" | "players" | "queue" | "ping" | "mods" | "region";
export type SortDirection = "asc" | "desc";
export interface TableSort {
column: SortColumn;
direction: SortDirection;
}
/** Resolves a region's median latency, or null when it was never measured. */
export type PingLookup = (pingSiteId: string | null) => number | null;
/**
* The direction a column takes on its first click.
*
* Chosen so one click gives the answer people actually want: the busiest
* servers, the lowest ping, the most mods — but names from A.
*/
export const NATURAL_DIRECTION: Record<SortColumn, SortDirection> = {
name: "asc",
players: "desc",
queue: "desc",
ping: "asc",
mods: "desc",
region: "asc",
};
/**
* Columns the API can order the whole dataset by, with the direction it returns.
*
* There is no direction parameter, so only these exact pairings are
* dataset-wide; anything else falls back to reordering the loaded page.
*/
export const API_SORTABLE: Partial<
Record<SortColumn, { sort: ServerSort; direction: SortDirection }>
> = {
name: { sort: "name", direction: "asc" },
players: { sort: "players", direction: "desc" },
};
/** Column headings, also used in the note naming the active page-local sort. */
export const COLUMN_LABEL: Record<SortColumn, string> = {
name: "server",
players: "players",
queue: "queue",
ping: "ping",
mods: "mods",
region: "region",
};
/**
* The sort a click on `column` produces.
*
* Clicking the active column reverses it; clicking any other starts at that
* column's natural direction rather than inheriting the previous one, which
* would otherwise sort names Z–A just because players had been sorted high–low.
*/
export function nextSort(current: TableSort | null, column: SortColumn): TableSort {
if (current?.column === column) {
return { column, direction: current.direction === "asc" ? "desc" : "asc" };
}
return { column, direction: NATURAL_DIRECTION[column] };
}
/** True when the API can serve this ordering across the whole dataset. */
export function isDatasetWide(sort: TableSort): boolean {
const api = API_SORTABLE[sort.column];
return api !== undefined && api.direction === sort.direction;
}
/**
* Orders `rows` by `sort`.
*
* Returns the input untouched when there is nothing to do, so the browse view
* keeps the API's ordering — and its array identity — rather than paying for a
* copy and a re-render on every refresh.
*/
export function sortRows(
rows: ServerSummary[],
sort: TableSort | null,
pingOf: PingLookup,
): ServerSummary[] {
if (!sort) return rows;
const direction = sort.direction === "asc" ? 1 : -1;
return [...rows].sort((a, b) => {
if (sort.column === "ping") {
const pa = pingOf(a.pingSiteId);
const pb = pingOf(b.pingSiteId);
// Unmeasured regions sort last in *both* directions. Flipping them with
// the rest would present "never measured" as the fastest thing on the
// page, which is the one reading that is actively misleading.
if (pa === null && pb === null) return tieBreak(a, b);
if (pa === null) return 1;
if (pb === null) return -1;
return (pa - pb) * direction || tieBreak(a, b);
}
return compareAscending(a, b, sort.column) * direction || tieBreak(a, b);
});
}
/** Compares two rows on `column`, always ascending. */
function compareAscending(
a: ServerSummary,
b: ServerSummary,
column: Exclude<SortColumn, "ping">,
): number {
switch (column) {
case "name":
return tidyName(a.name).localeCompare(tidyName(b.name));
case "players":
return a.players - b.players;
case "queue":
return a.queue - b.queue;
case "mods":
return a.modCount - b.modCount;
case "region":
// Sorted by the label shown, not the raw id, so the order matches what is
// on screen — "New York" sits under N, not under "new_york".
return humaniseRegion(a.pingSiteId ?? a.region).localeCompare(
humaniseRegion(b.pingSiteId ?? b.region),
);
}
}
/**
* Settles ties deterministically.
*
* Without this, equal values (every vanilla server has zero mods) would reorder
* themselves on each refresh, because `Array.prototype.sort` is only stable
* with respect to the array it was given and that array is rebuilt per request.
*/
function tieBreak(a: ServerSummary, b: ServerSummary): number {
return b.players - a.players || tidyName(a.name).localeCompare(tidyName(b.name));
}
+586
View File
@@ -0,0 +1,586 @@
/**
* Application state.
*
* Signals rather than a store framework: each component subscribes only to the
* signals it reads, so the one-second clock behind "updated 18s ago" re-renders
* the status pill and nothing else. The server table re-renders only when the
* server array itself changes.
*/
import { computed, signal } from "@preact/signals";
import * as ipc from "./ipc";
import {
API_SORTABLE,
isDatasetWide,
nextSort,
sortRows,
type SortColumn,
type TableSort,
} from "./sort";
import {
DEFAULT_QUERY,
isCommandError,
type CommandError,
type FleetNow,
type HistoryRange,
type InstallIdentity,
type PingResult,
type ReadinessReport,
type ServerHistory,
type LaunchRecord,
type LauncherStatus,
type PingSite,
type RecentServer,
type ServerDetail,
type ServerQuery,
type ServerSummary,
type Settings,
} from "./types";
export type View = "browse" | "favorites" | "recent" | "settings";
/** Debounce applied to the search field before a request is issued. */
export const SEARCH_DEBOUNCE_MS = 250;
/** How often the header re-reads API status. */
const STATUS_POLL_MS = 60_000;
// ---------------------------------------------------------------- navigation
export const view = signal<View>("browse");
// -------------------------------------------------------------- server list
export const query = signal<ServerQuery>({ ...DEFAULT_QUERY });
/** The raw search field, before debouncing into `query`. */
export const searchInput = signal("");
export const servers = signal<ServerSummary[]>([]);
export const listLoading = signal(false);
/** True only for the very first load, so refreshes do not blank the table. */
export const listInitialising = signal(true);
export const listError = signal<CommandError | null>(null);
export const totalServers = signal(0);
export const totalPages = signal(0);
export const filteredOut = signal(0);
/**
* Latency per ping-site id, measured once and reused for every row.
*
* Region latency, not per-server: see `ping_regions` in the Rust command layer
* for why a per-row measurement is not affordable.
*/
export const regionPings = signal<Record<string, PingResult>>({});
/**
* Client-side ordering applied on top of the API's sort.
*
* Null means the rows are shown in the order the API returned them. Anything
* else reorders only the rows already loaded — see `./sort` for why that
* distinction is kept rather than smoothed over.
*/
export const clientSort = signal<TableSort | null>(null);
// ------------------------------------------------- favorites & recents lists
/** Live rows for the favorites view, resolved from stored ids. */
export const favoriteServers = signal<ServerSummary[]>([]);
/** Live rows for the recents view, resolved from stored ids. */
export const recentServers = signal<ServerSummary[]>([]);
export const savedListLoading = signal(false);
// ------------------------------------------------------------------ detail
export const selectedId = signal<string | null>(null);
export const selectedServer = signal<ServerDetail | null>(null);
export const detailLoading = signal(false);
export const detailError = signal<CommandError | null>(null);
/** Set while a join is in flight, so the button can show progress. */
export const joining = signal(false);
export const joinError = signal<CommandError | null>(null);
// ------------------------------------------------------- history & readiness
export const historyRange = signal<HistoryRange>("oneDay");
export const historySeries = signal<ServerHistory | null>(null);
export const historyLoading = signal(false);
/** Null while unknown; the report itself says whether a scan was possible. */
export const readiness = signal<ReadinessReport | null>(null);
/** Null while unmeasured; the result itself says whether there is a figure. */
export const ping = signal<PingResult | null>(null);
export const pingLoading = signal(false);
// ------------------------------------------------------------ local storage
export const settings = signal<Settings | null>(null);
/** True when started with RFM_DEBUG. Gates developer-facing detail. */
export const debugMode = signal(false);
/** Install identity. Populated only in debug mode. */
export const installIdentity = signal<InstallIdentity | null>(null);
export const favorites = signal<string[]>([]);
export const recents = signal<RecentServer[]>([]);
export const history = signal<LaunchRecord[]>([]);
export const favoriteSet = computed(() => new Set(favorites.value));
/** Median latency for a server's region, or null when unmeasured. */
export function pingFor(pingSiteId: string | null): number | null {
if (!pingSiteId) return null;
return regionPings.value[pingSiteId]?.summary?.medianMs ?? null;
}
/** The browse rows, after any client-side ordering. */
export const visibleServers = computed(() =>
sortRows(servers.value, clientSort.value, pingFor),
);
/**
* Favorites and recents, under the same ordering.
*
* These lists are resolved in full rather than paginated, so a sort here covers
* everything the view can show and carries no page-local caveat.
*/
export const visibleFavorites = computed(() =>
sortRows(favoriteServers.value, clientSort.value, pingFor),
);
export const visibleRecents = computed(() =>
sortRows(recentServers.value, clientSort.value, pingFor),
);
/**
* The ordering currently in effect, for the header indicators.
*
* With no client sort, this reports the API's own ordering so the column the
* server sorted by is still marked — `newest` and `lastSeen` have no column and
* mark nothing.
*/
export const activeSort = computed<TableSort | null>(() => {
if (clientSort.value) return clientSort.value;
switch (query.value.sort) {
case "players":
return { column: "players", direction: "desc" };
case "name":
return { column: "name", direction: "asc" };
default:
return null;
}
});
/**
* True when the active ordering reaches only the loaded page.
*
* A client sort that happens to match what the API already did is not
* page-local: the rows are the dataset's top rows either way.
*/
export const sortIsPageLocal = computed(() => {
const sort = clientSort.value;
if (!sort) return false;
const api = API_SORTABLE[sort.column];
return !(api && api.direction === sort.direction && query.value.sort === api.sort);
});
/**
* Applies a header click.
*
* `datasetWide` is false for the favorites and recents tables, where there is
* no API query behind the rows and every sort is therefore local to the list
* being shown — and complete, because the list is not paginated.
*/
export function sortByColumn(column: SortColumn, datasetWide = true): void {
const next = nextSort(activeSort.value, column);
if (datasetWide && isDatasetWide(next)) {
// The API can order the whole dataset this way, so ask it to and drop the
// local reordering entirely.
clientSort.value = null;
setQuery({ sort: API_SORTABLE[next.column]!.sort });
return;
}
clientSort.value = next;
}
// ------------------------------------------------------------------ status
export const status = signal<LauncherStatus | null>(null);
export const pingSites = signal<PingSite[]>([]);
/** Fleet-wide population, shown in the title bar. */
export const fleet = signal<FleetNow | null>(null);
/** Ticks once a second purely so relative timestamps stay live. */
export const nowMs = signal(Date.now());
// ---------------------------------------------------------------- internals
/** Guards against a slow response overwriting a newer one. */
let listRequestSeq = 0;
let detailRequestSeq = 0;
let historyRequestSeq = 0;
let readinessRequestSeq = 0;
let pingRequestSeq = 0;
let savedListSeq = 0;
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function toCommandError(err: unknown): CommandError {
if (isCommandError(err)) return err;
return { kind: "unknown", message: String(err) };
}
// ----------------------------------------------------------------- actions
/** Replaces the query, resetting to page 1 unless the page itself changed. */
export function setQuery(patch: Partial<ServerQuery>): void {
const next = { ...query.value, ...patch };
if (patch.page === undefined) next.page = 1;
query.value = next;
void loadServers();
}
/** Updates the search box and schedules a debounced reload. */
export function setSearch(value: string): void {
searchInput.value = value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
if (searchInput.value !== query.value.search) {
setQuery({ search: searchInput.value });
}
}, SEARCH_DEBOUNCE_MS);
}
/** Clears every filter, keeping sort and page size. */
export function clearFilters(): void {
searchInput.value = "";
clearTimeout(searchTimer);
setQuery({
search: "",
region: null,
platform: null,
official: null,
hasMods: null,
battleye: null,
locked: null,
includeOffline: false,
minPlayers: 0,
});
}
export async function loadServers(): Promise<void> {
const seq = ++listRequestSeq;
listLoading.value = true;
try {
const result = await ipc.listServers(query.value);
if (seq !== listRequestSeq) return; // Superseded by a newer request.
servers.value = result.servers;
totalServers.value = result.totalServers;
totalPages.value = result.totalPages;
filteredOut.value = result.filteredOut;
listError.value = null;
if (status.value) {
// Every server response carries snapshot freshness, so the header stays
// current without a status request of its own.
status.value = { ...status.value, dataset: result.dataset };
}
} catch (err) {
if (seq !== listRequestSeq) return;
listError.value = toCommandError(err);
} finally {
if (seq === listRequestSeq) {
listLoading.value = false;
listInitialising.value = false;
}
}
}
export async function selectServer(id: string | null): Promise<void> {
selectedId.value = id;
joinError.value = null;
if (!id) {
selectedServer.value = null;
historySeries.value = null;
readiness.value = null;
ping.value = null;
return;
}
const seq = ++detailRequestSeq;
detailLoading.value = true;
detailError.value = null;
// Show what the list already knows while the detail request is in flight.
const known = servers.value.find((s) => s.id === id);
if (known && selectedServer.value?.id !== id) {
selectedServer.value = null;
}
try {
const detail = await ipc.getServer(id);
if (seq !== detailRequestSeq) return;
selectedServer.value = detail;
// Both are secondary to the panel and must not delay it, so they are not
// awaited. Readiness reuses the detail response the client just cached, so
// it costs a directory scan rather than a request.
void loadHistory(id);
void loadReadiness(id);
void measurePing(id);
// The backend recorded the visit; mirror it locally without a round trip.
recents.value = [
{ id: detail.id, name: detail.name, lastSeenAt: Math.floor(Date.now() / 1000) },
...recents.value.filter((r) => r.id !== detail.id),
].slice(0, 30);
} catch (err) {
if (seq !== detailRequestSeq) return;
detailError.value = toCommandError(err);
} finally {
if (seq === detailRequestSeq) detailLoading.value = false;
}
}
/** Loads population history for `id` at the current range. */
export async function loadHistory(id: string): Promise<void> {
const seq = ++historyRequestSeq;
historyLoading.value = true;
try {
const series = await ipc.getServerHistory(id, historyRange.value);
if (seq !== historyRequestSeq || selectedId.value !== id) return;
historySeries.value = series;
} catch {
if (seq !== historyRequestSeq) return;
// The chart is supplementary; a failure renders as "no history", not an error.
historySeries.value = null;
} finally {
if (seq === historyRequestSeq) historyLoading.value = false;
}
}
/** Switches the history range and reloads for the selected server. */
export function setHistoryRange(range: HistoryRange): void {
if (historyRange.value === range) return;
historyRange.value = range;
historySeries.value = null;
const id = selectedId.value;
if (id) void loadHistory(id);
}
/**
* Loads the mod readiness report for `id`.
*
* Reporting only: nothing in the launcher acts on the result.
*/
export async function loadReadiness(id: string): Promise<void> {
const seq = ++readinessRequestSeq;
try {
const report = await ipc.getModReadiness(id);
if (seq !== readinessRequestSeq || selectedId.value !== id) return;
readiness.value = report;
} catch {
if (seq !== readinessRequestSeq) return;
// Leaving this null renders nothing, which is correct: an unknown state
// must never be shown as a reassuring one.
readiness.value = null;
}
}
/**
* Measures latency to the selected server.
*
* Only ever for the selected server: measuring a whole page would emit a
* hundred ICMP probes on every refresh, which is both rude and indistinguishable
* from a scan.
*/
export async function measurePing(id: string): Promise<void> {
const seq = ++pingRequestSeq;
pingLoading.value = true;
ping.value = null;
try {
const result = await ipc.pingServer(id);
if (seq !== pingRequestSeq || selectedId.value !== id) return;
ping.value = result;
} catch {
if (seq !== pingRequestSeq) return;
ping.value = null;
} finally {
if (seq === pingRequestSeq) pingLoading.value = false;
}
}
/**
* Resolves the favorites or recents list into live server rows.
*
* Those views hold ids, so a table needs one detail request per row. Both lists
* are small and the client caches responses, so revisiting a tab is usually
* free; ids that no longer resolve are dropped by the backend.
*/
export async function loadSavedList(which: "favorites" | "recent"): Promise<void> {
const ids = which === "favorites" ? favorites.value : recents.value.map((r) => r.id);
const target = which === "favorites" ? favoriteServers : recentServers;
if (ids.length === 0) {
target.value = [];
return;
}
const seq = ++savedListSeq;
savedListLoading.value = true;
try {
const resolved = await ipc.getServers(ids);
if (seq !== savedListSeq) return;
target.value = resolved;
} catch {
if (seq !== savedListSeq) return;
// Leave whatever was shown; the stored list is still intact.
} finally {
if (seq === savedListSeq) savedListLoading.value = false;
}
}
/** Switches view, resolving the saved lists on entry. */
export function setView(next: View): void {
view.value = next;
if (next === "favorites" || next === "recent") {
void loadSavedList(next);
}
}
export async function toggleFavorite(id: string): Promise<void> {
try {
const isFavorite = await ipc.toggleFavorite(id);
favorites.value = isFavorite
? [...favorites.value, id]
: favorites.value.filter((f) => f !== id);
if (!isFavorite && view.value === "favorites") {
favoriteServers.value = favoriteServers.value.filter((s) => s.id !== id);
}
} catch {
// A failed toggle leaves the previous state visible, which is correct:
// nothing was persisted.
}
}
export async function join(server: { id: string; name: string }): Promise<void> {
joining.value = true;
joinError.value = null;
try {
await ipc.joinServer(server.id, server.name);
history.value = [
{
serverId: server.id,
serverName: server.name,
launchedAt: Math.floor(Date.now() / 1000),
succeeded: true,
},
...history.value,
].slice(0, 200);
} catch (err) {
joinError.value = toCommandError(err);
} finally {
joining.value = false;
}
}
export async function saveSettings(next: Settings): Promise<void> {
const stored = await ipc.updateSettings(next);
settings.value = stored;
if (stored.perPage !== query.value.perPage) {
setQuery({ perPage: stored.perPage });
}
}
/** Refreshes the fleet-wide population shown in the title bar. */
export async function refreshFleet(): Promise<void> {
try {
fleet.value = await ipc.getFleetNow();
} catch {
// Decorative; the count simply stays as it was.
}
}
export async function refreshStatus(): Promise<void> {
try {
const next = await ipc.getStatus();
// Prefer freshness already seen on a server response over a status probe
// that may not carry one.
status.value = { ...next, dataset: next.dataset ?? status.value?.dataset ?? null };
} catch {
status.value = {
apiReachable: false,
dataset: status.value?.dataset ?? null,
reportedStatus: null,
note: null,
};
}
}
export async function clearLocalHistory(): Promise<void> {
await ipc.clearHistory();
recents.value = [];
history.value = [];
}
let started = false;
/** Loads persisted state, then the first page of servers. */
export async function start(): Promise<void> {
if (started) return;
started = true;
try {
const local = await ipc.getLocalState();
settings.value = local.settings;
favorites.value = local.favorites;
recents.value = local.recents;
history.value = local.history;
query.value = { ...query.value, perPage: local.settings.perPage };
debugMode.value = await ipc.isDebugMode();
if (debugMode.value) {
installIdentity.value = await ipc.getInstallIdentity();
}
} catch {
// Defaults are already in place; a storage failure must not block startup.
}
// The first server page is what the user is waiting for, so it goes first and
// the decorative requests follow.
await loadServers();
void refreshStatus();
void refreshFleet();
// Eight ICMP probes, reused for every row in every page from here on.
void ipc
.pingRegions()
.then((results) => {
regionPings.value = results;
})
.catch(() => {
// The ping column simply stays empty; it is supplementary.
});
void ipc
.listPingSites()
.then((sites) => {
pingSites.value = sites;
})
.catch(() => {
// Region labels fall back to humanised ids.
});
setInterval(() => {
nowMs.value = Date.now();
}, 1000);
setInterval(() => {
void refreshStatus();
void refreshFleet();
}, STATUS_POLL_MS);
startAutoRefresh();
}
/**
* Optional periodic reload of the server list.
*
* Off by default and floored at 15s by the settings store, and it skips while
* the window is hidden so a backgrounded launcher makes no requests.
*/
function startAutoRefresh(): void {
let lastRefreshMs = Date.now();
// A single slow timer that checks the configured interval, rather than one
// timer per settings change.
setInterval(() => {
const s = settings.value;
if (!s?.autoRefresh) return;
if (document.hidden) return; // A backgrounded window makes no requests.
if (view.value !== "browse") return;
if (Date.now() - lastRefreshMs < s.refreshIntervalSecs * 1000) return;
lastRefreshMs = Date.now();
void loadServers();
}, 5_000);
}
+300
View File
@@ -0,0 +1,300 @@
/**
* Mirrors of the Rust DTOs crossing the Tauri boundary.
*
* These are hand-written rather than generated: the surface is small, and
* keeping it hand-written means the field names the UI reads are visible in
* one place. `rfm-core` serialises with `rename_all = "camelCase"`, with one
* deliberate exception — `battlEye`, which matches the API's own spelling.
*
* Every string here is untrusted remote text. Render it as text content only;
* never build a URL or markup from it.
*/
export interface DatasetMeta {
warming: boolean;
stale: boolean;
snapshotAgeSeconds: number;
lastCollectionAt: string | null;
serverCount: number;
onlineServerCount: number;
offlineServerCount: number;
}
export interface ServerAnalytics {
rank: number | null;
overallScore: number | null;
activityScore: number | null;
reliabilityScore: number | null;
confidence: number | null;
}
export interface ServerSummary {
id: string;
name: string;
scenarioId: string;
scenarioName: string;
scenarioThumbnailUrl: string | null;
gameVersion: string;
official: boolean;
passwordProtected: boolean;
/** Spelled as the API spells it. */
battlEye: boolean;
crossPlay: boolean;
players: number;
maxPlayers: number;
queue: number;
queueMax: number;
pingSiteId: string | null;
region: string | null;
/** Host OS (`LINUX`/`WINDOWS`), not a client platform. */
platform: string | null;
/** Client platforms that may join: `pc`, `xbox`, `psn`. */
platforms: string[];
modCount: number;
online: boolean;
firstSeen: string | null;
lastSeen: string | null;
analytics: ServerAnalytics | null;
/** The API's own join URI. Cross-checked in tests; never handed to the OS. */
joinUrl: string | null;
}
export interface ServerActivity {
peak24h: number;
avg24h: number;
median24h: number;
low24h: number;
peak7d: number;
samples24h: number;
trackingSince: string | null;
}
export interface ServerModSummary {
count: number;
knownSize: number;
knownSizeText: string | null;
unresolvedCount: number;
}
export interface ServerMod {
id: string;
name: string;
version: string;
size: number;
sizeText: string | null;
sizeKnown: boolean;
modUrl: string | null;
}
/** `ServerDetail` flattens `ServerSummary`, so it carries every summary field. */
export interface ServerDetail extends ServerSummary {
present: boolean;
hostType: string | null;
joinable: boolean;
/** Published address. Untrusted; only the backend ever acts on it. */
host: string | null;
port: number;
/** Server-reported simulation FPS; 0 when not reported. */
fps: number;
activity: ServerActivity | null;
modSummary: ServerModSummary;
mods: ServerMod[];
}
export interface PingSite {
id: string;
label: string;
regionGroup: string;
address: string | null;
}
/** Fleet-wide population right now. */
export interface FleetNow {
onlineServers: number;
players: number;
capacity: number;
queue: number;
}
export interface LauncherStatus {
apiReachable: boolean;
dataset: DatasetMeta | null;
reportedStatus: string | null;
note: string | null;
}
/** Ranges `/v2/servers/{id}/history` accepts. */
export type HistoryRange =
| "sixHours"
| "oneDay"
| "threeDays"
| "oneWeek"
| "oneMonth"
| "threeMonths"
| "oneYear"
| "all";
/** The ranges the inspector offers, with their labels. */
export const HISTORY_RANGES: ReadonlyArray<{ value: HistoryRange; label: string }> = [
{ value: "sixHours", label: "6h" },
{ value: "oneDay", label: "24h" },
{ value: "oneWeek", label: "7d" },
{ value: "oneMonth", label: "30d" },
];
export interface ServerHistoryPoint {
/** Bucket start, unix seconds. */
t: number;
avg: number;
min: number;
max: number;
/** Slot capacity during the bucket; 0 when not recorded. */
cap: number;
}
export interface ServerHistory {
serverId: string;
/** The range the API served, which may differ from what was asked. */
range: string;
/** Bucket width the API chose: `5m`, `hour` or `day`. */
bucket: string;
points: ServerHistoryPoint[];
}
/** Diagnostic view of the install identity. Populated only in debug mode. */
export interface InstallIdentity {
id: string;
/** True when the identifier survives a reinstall. */
machineDerived: boolean;
}
/** Why a server could not be measured. */
export type PingUnavailable = "no_host" | "unroutable_host" | "no_reply" | "not_permitted";
export interface PingSummary {
bestMs: number;
/** Preferred for display: one delayed packet should not move the figure. */
medianMs: number;
received: number;
sent: number;
}
/** A latency measurement, or a stated reason there is none. */
export interface PingResult {
summary?: PingSummary;
unavailable?: PingUnavailable;
message?: string;
}
/** Why a local mod scan could not produce an answer. */
export type ReadinessUnavailable = "no_addons_directory" | "scan_failed";
/**
* Comparison of a server's required mods against the local addon store.
*
* Reporting only. `available: false` means the scan did not run, and every
* count is then zero and must not be rendered.
*/
export interface ReadinessReport {
available: boolean;
unavailable?: ReadinessUnavailable;
message?: string;
required: number;
upToDate: number;
outdated: number;
missing: number;
/** Floor, not a total, while `unresolvedMods` is non-zero. */
missingBytes: number;
unresolvedMods: number;
installedTotal: number;
}
export type ServerSort = "players" | "name" | "newest" | "lastSeen";
export type ClientPlatform = "pc" | "xbox" | "psn";
export interface ServerQuery {
search: string;
sort: ServerSort;
region: string | null;
platform: ClientPlatform | null;
official: boolean | null;
hasMods: boolean | null;
battleye: boolean | null;
/** The API's name for password-protected. */
locked: boolean | null;
includeOffline: boolean;
/** Applied client-side; the API has no equivalent parameter. */
minPlayers: number;
page: number;
perPage: number;
}
export interface ServerListResult {
servers: ServerSummary[];
totalServers: number;
totalPages: number;
currentPage: number;
/** Rows the client-side filters removed from this page. */
filteredOut: number;
dataset: DatasetMeta;
}
export interface Settings {
perPage: number;
autoRefresh: boolean;
refreshIntervalSecs: number;
/** Publish activity to Discord Rich Presence. */
discordPresence: boolean;
}
export interface RecentServer {
id: string;
name: string;
lastSeenAt: number;
}
export interface LaunchRecord {
serverId: string;
serverName: string;
launchedAt: number;
succeeded: boolean;
}
export interface LocalStateDto {
settings: Settings;
favorites: string[];
recents: RecentServer[];
history: LaunchRecord[];
}
/** The shape every failing command rejects with. */
export interface CommandError {
kind: string;
message: string;
retryAfterSecs?: number;
}
/** Type guard for the error shape, since `catch` gives `unknown`. */
export function isCommandError(value: unknown): value is CommandError {
return (
typeof value === "object" &&
value !== null &&
typeof (value as CommandError).kind === "string" &&
typeof (value as CommandError).message === "string"
);
}
export const DEFAULT_QUERY: ServerQuery = {
search: "",
sort: "players",
region: null,
platform: null,
official: null,
hasMods: null,
battleye: null,
locked: null,
includeOffline: false,
minPlayers: 0,
page: 1,
perPage: 100,
};
+13
View File
@@ -0,0 +1,13 @@
import { render } from "preact";
import { App } from "./app";
import { start } from "./lib/state";
import "./styles.css";
const root = document.getElementById("app");
if (!root) throw new Error("missing #app root element");
render(<App />, root);
// Kick off data loading after the first paint, so the chrome is on screen
// immediately rather than waiting on the network.
void start();
+1319
View File
File diff suppressed because it is too large. Load diff
+133
View File
@@ -0,0 +1,133 @@
/** The server browser: filters, table, and the pager/status footer. */
import { FilterBar } from "../components/FilterBar";
import { ServerTable } from "../components/ServerTable";
import {
clearFilters,
filteredOut,
listError,
listInitialising,
listLoading,
loadServers,
query,
setQuery,
sortIsPageLocal,
activeSort,
totalPages,
totalServers,
visibleServers,
} from "../lib/state";
import { COLUMN_LABEL } from "../lib/sort";
export function BrowseView() {
const rows = visibleServers.value;
const error = listError.value;
const loading = listLoading.value;
const initialising = listInitialising.value;
const q = query.value;
return (
<div class="browser">
<FilterBar />
{loading ? <div class="loadbar" /> : <div style={{ height: "1px" }} />}
{error && (
<div class="banner banner--error">
<span>{errorHeadline(error.kind)}</span>
<span style={{ color: "var(--text-dim)" }}>{error.message}</span>
<div class="toolbar__spacer" />
<button class="linkish" onClick={() => void loadServers()}>
Retry
</button>
</div>
)}
{filteredOut.value > 0 && (
<div class="banner">
{filteredOut.value} server{filteredOut.value === 1 ? "" : "s"} on this page hidden
by the minimum-player filter.
</div>
)}
{initialising ? (
<div class="state">
<div class="state__title">Loading servers…</div>
<div class="state__hint">Fetching the current snapshot from reforgermods.net.</div>
</div>
) : rows.length === 0 && !error ? (
<div class="state">
<div class="state__title">No servers match these filters</div>
<div class="state__hint">
Try a broader search, or clear the filters to see the full list.
</div>
<button class="btn" onClick={clearFilters}>
Clear filters
</button>
</div>
) : rows.length === 0 && error ? (
<div class="state">
<div class="state__title">Nothing to show</div>
<div class="state__hint">
The launcher could not reach reforgermods.net, and has no cached page to fall
back to.
</div>
</div>
) : (
<ServerTable servers={rows} />
)}
<div class="footbar">
<span>
{rows.length} shown
{totalServers.value > 0 && ` of ${totalServers.value.toLocaleString()} matched`}
{/* A sort the API cannot serve reorders these rows and no others.
Saying so here is the same contract as the minimum-player filter
being labelled page-local rather than presented as a search. */}
{sortIsPageLocal.value && activeSort.value && (
<span style={{ color: "var(--text-faint)" }}>
{" "}
· sorted by {COLUMN_LABEL[activeSort.value.column]} within this page
</span>
)}
</span>
<div class="footbar__spacer" />
{totalPages.value > 1 && (
<div class="pager">
<button
disabled={q.page <= 1 || loading}
onClick={() => setQuery({ page: q.page - 1 })}
aria-label="Previous page"
>
‹
</button>
<span>
{q.page} / {totalPages.value}
</span>
<button
disabled={q.page >= totalPages.value || loading}
onClick={() => setQuery({ page: q.page + 1 })}
aria-label="Next page"
>
›
</button>
</div>
)}
</div>
</div>
);
}
function errorHeadline(kind: string): string {
switch (kind) {
case "offline":
return "Offline";
case "timeout":
return "Timed out";
case "rate_limited":
return "Rate limited";
case "decode":
return "Unexpected response";
default:
return "Request failed";
}
}
+131
View File
@@ -0,0 +1,131 @@
/**
* Favorites and recent servers.
*
* Both hold ids locally and render the same table as the browser, resolved to
* live rows, so a favorite shows its current population, ping and flags rather
* than a remembered name. The stored name is only a fallback for the empty and
* error states.
*/
import { ServerTable } from "../components/ServerTable";
import {
favorites,
loadSavedList,
nowMs,
recents,
savedListLoading,
setView,
visibleFavorites,
visibleRecents,
} from "../lib/state";
import { formatTimestamp } from "../lib/format";
interface Props {
title: string;
lede: string;
/** Rows resolved from the stored ids. */
rows: typeof visibleFavorites.value;
/** How many ids are stored, which may exceed the rows that still resolve. */
storedCount: number;
emptyTitle: string;
emptyHint: string;
onReload: () => void;
}
function SavedList(props: Props) {
const loading = savedListLoading.value;
const { rows, storedCount } = props;
if (storedCount === 0) {
return (
<div class="browser">
<div class="state">
<div class="state__title">{props.emptyTitle}</div>
<div class="state__hint">{props.emptyHint}</div>
<button class="btn" onClick={() => setView("browse")}>
Open the server browser
</button>
</div>
</div>
);
}
// Servers drop out of the index; say so rather than quietly showing fewer.
const missing = storedCount - rows.length;
return (
<div class="browser">
<div class="toolbar">
<span class="control-label">{props.title}</span>
<span style={{ fontSize: "11px", color: "var(--text-dim)" }}>{props.lede}</span>
<div class="toolbar__spacer" />
<button class="linkish" disabled={loading} onClick={props.onReload}>
{loading ? "Refreshing…" : "Refresh"}
</button>
</div>
{loading ? <div class="loadbar" /> : <div style={{ height: "1px" }} />}
{missing > 0 && rows.length > 0 && (
<div class="banner">
{missing} of {storedCount} are not in the current server index.
</div>
)}
{rows.length === 0 && !loading ? (
<div class="state">
<div class="state__title">None of these are currently listed</div>
<div class="state__hint">
The servers are still saved. They will reappear here when they return to
the index.
</div>
</div>
) : (
<ServerTable servers={rows} paginated={false} />
)}
<div class="footbar">
<span>
{rows.length} listed
{storedCount !== rows.length && ` of ${storedCount} saved`}
</span>
</div>
</div>
);
}
export function FavoritesView() {
return (
<SavedList
title="Favorites"
lede="Stored locally, shown with live population."
rows={visibleFavorites.value}
storedCount={favorites.value.length}
emptyTitle="No favorites yet"
emptyHint="Star a server in the browser and it will be kept here across restarts."
onReload={() => void loadSavedList("favorites")}
/>
);
}
export function RecentView() {
const items = recents.value;
const newest = items[0];
// Subscribe to the clock so the "last opened" line stays live.
const now = nowMs.value;
return (
<SavedList
title="Recent"
lede={
newest
? `Most recent ${formatTimestamp(newest.lastSeenAt, now)}.`
: "Servers you open appear here."
}
rows={visibleRecents.value}
storedCount={items.length}
emptyTitle="No recent servers"
emptyHint="Servers you open appear here, most recent first."
onReload={() => void loadSavedList("recent")}
/>
);
}
+200
View File
@@ -0,0 +1,200 @@
/** Application preferences and launch history. */
import { useState } from "preact/hooks";
import {
clearLocalHistory,
history,
debugMode,
installIdentity,
nowMs,
saveSettings,
settings,
} from "../lib/state";
import { launchGame } from "../lib/ipc";
import { formatTimestamp, tidyName } from "../lib/format";
import type { Settings } from "../lib/types";
export function SettingsView() {
const stored = settings.value;
const [draft, setDraft] = useState<Settings | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
if (!stored) {
return <div class="state">Loading settings…</div>;
}
const current = draft ?? stored;
const dirty = draft !== null && JSON.stringify(draft) !== JSON.stringify(stored);
function patch(next: Partial<Settings>) {
setDraft({ ...current, ...next });
}
async function save() {
setSaveError(null);
try {
await saveSettings(current);
setDraft(null);
} catch (err) {
setSaveError(
typeof err === "object" && err !== null && "message" in err
? String((err as { message: unknown }).message)
: "Could not save settings.",
);
}
}
return (
<div class="page">
<h2>Settings</h2>
<p class="page__lede">
Stored locally in the launcher's configuration directory. No reforgermods.net
account is required, and the launcher sends no credentials.
</p>
<div class="field">
<span class="field__k">Servers per page</span>
<input
type="number"
min={10}
max={500}
value={current.perPage}
onInput={(e) => patch({ perPage: Number((e.target as HTMLInputElement).value) })}
/>
<span class="field__hint">Between 10 and 500. Larger pages mean fewer requests.</span>
</div>
<div class="field">
<span class="field__k">Refresh automatically</span>
<input
type="checkbox"
checked={current.autoRefresh}
onChange={(e) => patch({ autoRefresh: (e.target as HTMLInputElement).checked })}
/>
<span class="field__hint">
On by default. The list refreshes only while the window is visible and the
browser is open, so a backgrounded launcher makes no requests.
</span>
</div>
<div class="field">
<span class="field__k">Refresh interval</span>
<input
type="number"
min={15}
max={3600}
value={current.refreshIntervalSecs}
disabled={!current.autoRefresh}
onInput={(e) =>
patch({ refreshIntervalSecs: Number((e.target as HTMLInputElement).value) })
}
/>
<span class="field__hint">Seconds. Floored at 15.</span>
</div>
<div class="field">
<span class="field__k">Discord status</span>
<input
type="checkbox"
checked={current.discordPresence}
onChange={(e) =>
patch({ discordPresence: (e.target as HTMLInputElement).checked })
}
/>
<span class="field__hint">
Shows "reforgermods.net launcher" as the application you are running, along
with the server you are viewing. Turn this off to publish nothing to Discord.
</span>
</div>
<div style={{ display: "flex", gap: "6px", marginTop: "14px" }}>
<button class="btn" disabled={!dirty} onClick={() => void save()}>
Save changes
</button>
<button class="btn" disabled={!dirty} onClick={() => setDraft(null)}>
Discard
</button>
<button class="btn" onClick={() => void launchGame()} title="steam://run/1874880">
Launch Arma Reforger
</button>
</div>
{saveError && (
<p class="state__hint" style={{ color: "var(--bad)" }}>
{saveError}
</p>
)}
<InstallIdentityPanel />
<LaunchHistory />
</div>
);
}
/**
* Install identity, shown only in debug mode.
*
* The identifier is derived from the machine so it survives a reinstall and the
* API does not count one person's reinstalls as separate installs. It is not
* user-facing: there is nothing to configure, and surfacing a stable identifier
* in the window invites it being copied around.
*/
function InstallIdentityPanel() {
if (!debugMode.value) return null;
const identity = installIdentity.value;
return (
<div class="section" style={{ marginTop: "26px" }}>
<div class="section__h">
<span>Install identity (debug)</span>
</div>
<p class="page__lede" style={{ marginBottom: "8px" }}>
Sent with every API request so reforgermods.net can count distinct
installs rather than only requests. Derived from this machine, salted and
hashed — the machine value itself never leaves the process.
</p>
<div class="identity">
<code>{identity?.id || "unavailable"}</code>
<span class="simple-list__meta">
{identity?.machineDerived
? "machine-derived (survives reinstall)"
: "random fallback (no machine source readable)"}
</span>
</div>
</div>
);
}
function LaunchHistory() {
const records = history.value;
const now = nowMs.value;
return (
<div class="section" style={{ marginTop: "26px" }}>
<div class="section__h">
<span>Launch history</span>
{records.length > 0 && (
<button class="linkish" onClick={() => void clearLocalHistory()}>
Clear history and recents
</button>
)}
</div>
{records.length === 0 ? (
<p class="state__hint" style={{ margin: 0 }}>
Servers you join are recorded here.
</p>
) : (
<div class="simple-list">
{records.slice(0, 40).map((record, i) => (
<div class="simple-list__row" key={`${record.serverId}-${record.launchedAt}-${i}`}>
<span class="simple-list__name">{tidyName(record.serverName) || record.serverId}</span>
<span class="simple-list__meta">
{record.succeeded ? "handed to Steam" : "failed"}
</span>
<span class="simple-list__meta">{formatTimestamp(record.launchedAt, now)}</span>
</div>
))}
</div>
)}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts", "vitest.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";
// Tauri serves the dev build from a fixed port and copies `dist/` into the
// bundle for release builds.
export default defineConfig({
plugins: [preact()],
// Fail loudly rather than silently picking another port: Tauri's devUrl is fixed.
server: { port: 1420, strictPort: true },
clearScreen: false,
build: {
// Matches the WebView2 / WKWebView baseline Tauri 2 targets, so no
// unnecessary transpilation bloat ships.
target: "es2022",
sourcemap: false,
// The whole UI is small; one chunk avoids extra requests at startup.
chunkSizeWarningLimit: 300,
},
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
import preact from "@preact/preset-vite";
export default defineConfig({
plugins: [preact()],
test: {
// Most tests are pure; the shell smoke test opts into jsdom per file.
environment: "node",
css: false,
},
});