initial commit: reforgermods.net launcher 0.1.0

A desktop server browser for Arma Reforger, built on the reforgermods.net v2
API. Live server list with search, sort and filters; a detail inspector with
population history and mod readiness; measured ICMP latency; favorites, recents
and settings; and direct joining through Steam.

Tauri 2 and Rust, with a Preact frontend that builds to 80 KB of JavaScript in
one chunk. Logic lives in rfm-core, which has no Tauri dependency and is tested
directly; src-tauri is the window, the command surface and the wiring.

Packaged for the public beta: a per-user NSIS installer, a portable .exe and an
Arch package, with signature-verified background updates on Windows and a
release pipeline that builds from a tag. The artifacts are not yet
Authenticode-signed — that is wired and waiting on an Azure Trusted Signing
identity, and docs/distribution.md says so plainly rather than letting anyone
discover it at the SmartScreen dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
SowinskiBraedenandClaude Opus 5 committed 2026-09-24 20:29:00 -07:00
commit 377f3a31b3
91 files changed
+24994

No files matched your search

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