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