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