initial commit: reforgermods.net launcher 0.1.0

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

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

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

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

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
[package]
name = "reforgermods-launcher"
description = "reforgermods.net launcher: a fast desktop server browser and direct launcher for Arma Reforger."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[lib]
# Tauri builds the app as a library so the mobile targets can link it; the
# desktop binary in main.rs is a thin wrapper around run().
name = "reforgermods_launcher_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[features]
# Tauri decides dev-vs-production from this feature, NOT from the cargo profile.
# `tauri`'s build script computes `dev = !custom_protocol`, and in dev mode
# `generate_context!` makes the app load its UI from `build.devUrl`
# (http://localhost:1420) instead of the embedded frontend. Without this a
# release build still ships as a dev build and shows ERR_CONNECTION_REFUSED on
# any machine that is not running `npm run dev`.
#
# `cargo tauri build` passes this automatically; a direct `cargo build` must be
# given `--features custom-protocol`. See scripts/build-windows.sh.
custom-protocol = ["tauri/custom-protocol"]
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
rfm-core = { path = "../crates/rfm-core" }
tauri = { version = "2.11.6", features = [] }
tauri-plugin-opener = "2.5.5"
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
discord-rich-presence = "1.1.0"
# Windows only, on purpose. The updater replaces the installed application in
# place, which is correct for an NSIS per-user install and wrong for the Arch
# package: a distro package is updated by the distro's tooling, not by the app
# rewriting its own files under /usr/bin (docs/distribution.md section 7).
# Scoping it here rather than with cfg! keeps the dependency out of the Linux
# build entirely.
[target.'cfg(target_os = "windows")'.dependencies]
# default-features = false is not tidiness: the plugin's defaults include
# `rustls-tls`, and because Cargo unions features across a shared dependency that
# switches the *whole binary's* reqwest onto rustls. That pulls in ring, undoing
# the deliberate schannel choice in rfm-core/Cargo.toml — no bundled C crypto to
# build, and the machine's own trust store, which is what works behind corporate
# TLS inspection. It also does not cross-compile: ring's build script fails under
# cargo-xwin, so the mistake shows up as a broken Windows build rather than a
# silently different TLS stack.
#
# `zip` is kept although the current NSIS bundle is signed in place and ships as
# a plain -setup.exe: Tauri emits a zipped artifact for some bundle types, and a
# plugin that cannot unpack one fails on the user's machine at install time,
# which is the worst place to discover a missing feature flag.
tauri-plugin-updater = { version = "2.12.0", default-features = false, features = [
"native-tls",
"system-proxy",
"zip",
] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "Minimum permissions for the launcher window. The window is frameless, so the UI may drag it and operate its own caption buttons — and nothing else. The UI may only invoke this app's own commands; it is granted no filesystem, shell, HTTP or opener access. URIs are opened from Rust, after the server id has been validated.",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

+518
View File
@@ -0,0 +1,518 @@
//! The Tauri command surface.
//!
//! This is the only boundary the UI can call across, so it is kept deliberately
//! small. Commands orchestrate; they contain no HTTP, no URI building and no
//! persistence rules of their own — those live in `rfm-core` where they are
//! unit-tested.
//!
//! Untrusted API text never reaches an OS call here. The two commands that hand
//! a URL to the operating system ([`join_server`] and [`open_server_page`]) both
//! build it from a validated [`rfm_core::ServerId`], never from an API-supplied
//! URL field.
use serde::Serialize;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
use rfm_core::api::models::{
DatasetMeta, FleetNow, LauncherStatus, PingSite, ServerDetail, ServerHistory, ServerSummary,
};
use rfm_core::api::{ApiError, HistoryRange, ServerQuery};
use rfm_core::launch::{steam_join_uri, steam_launch_uri, LaunchError};
use rfm_core::modscan::FilesystemInventory;
use rfm_core::ping::{self, PingResult};
use rfm_core::presence::Activity;
use rfm_core::readiness::{self, ReadinessReport, Unavailable};
use rfm_core::store::{now_unix, LaunchRecord, LocalState, RecentServer, Settings, StoreError};
use rfm_core::ServerId;
use std::collections::HashMap;
use crate::state::AppState;
/// An error the UI can branch on.
///
/// `kind` is a stable slug; `message` is display text. Nothing here leaks a file
/// path or an internal type name.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandError {
pub kind: String,
pub message: String,
/// Set for rate limiting, so the UI can say how long to wait.
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_after_secs: Option<u64>,
}
impl From<ApiError> for CommandError {
fn from(err: ApiError) -> Self {
let retry_after_secs = match &err {
ApiError::RateLimited { retry_after } => *retry_after,
_ => None,
};
Self {
kind: err.kind().to_string(),
message: err.to_string(),
retry_after_secs,
}
}
}
impl From<LaunchError> for CommandError {
fn from(err: LaunchError) -> Self {
Self {
kind: "invalid_server_id".into(),
message: err.to_string(),
retry_after_secs: None,
}
}
}
impl From<StoreError> for CommandError {
fn from(err: StoreError) -> Self {
Self {
kind: "storage".into(),
// Deliberately not including the path: it names the user's profile.
message: "Could not save launcher settings.".into(),
retry_after_secs: None,
}
.tap_log(&err)
}
}
impl CommandError {
/// Logs the underlying cause to stderr while returning the sanitised error.
fn tap_log(self, cause: &dyn std::error::Error) -> Self {
eprintln!("[reforgermods-launcher] {}: {cause}", self.kind);
self
}
fn other(kind: &str, message: impl Into<String>) -> Self {
Self {
kind: kind.into(),
message: message.into(),
retry_after_secs: None,
}
}
}
type CommandResult<T> = Result<T, CommandError>;
/// One page of the server browser.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerListResult {
pub servers: Vec<ServerSummary>,
/// Total servers the API matched, before client-side filtering.
pub total_servers: u32,
pub total_pages: u32,
pub current_page: u32,
/// How many of the fetched servers the client-side filters removed, so the
/// UI can say so instead of silently showing fewer rows.
pub filtered_out: u32,
pub dataset: DatasetMeta,
}
/// Diagnostic view of the install identity, for debug mode.
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallIdentity {
pub id: String,
/// True when the identifier survives a reinstall.
pub machine_derived: bool,
}
/// Everything the UI needs from local storage in one call at startup.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalStateDto {
pub settings: Settings,
pub favorites: Vec<String>,
pub recents: Vec<RecentServer>,
pub history: Vec<LaunchRecord>,
}
impl From<&LocalState> for LocalStateDto {
fn from(s: &LocalState) -> Self {
Self {
settings: s.settings.clone(),
favorites: s.favorites.clone(),
recents: s.recents.clone(),
history: s.history.clone(),
}
}
}
/// `GET /v2/servers` with the browser's current filters.
#[tauri::command]
pub async fn list_servers(
state: State<'_, AppState>,
query: ServerQuery,
) -> CommandResult<ServerListResult> {
let page = state.client().servers(&query).await?;
state.remember_dataset(page.dataset.clone());
state.publish_presence(Activity::Browsing {
online_servers: page.dataset.online_server_count,
});
let fetched = page.data.len() as u32;
let servers: Vec<ServerSummary> = page
.data
.into_iter()
.filter(|s| query.retains(s.players))
.collect();
Ok(ServerListResult {
filtered_out: fetched - servers.len() as u32,
servers,
total_servers: page.meta.total_servers,
total_pages: page.meta.total_pages,
current_page: page.meta.current_page,
dataset: page.dataset,
})
}
/// `GET /v2/servers/{id}`, and record the server in recents.
#[tauri::command]
pub async fn get_server(state: State<'_, AppState>, id: String) -> CommandResult<ServerDetail> {
let id = ServerId::parse(&id)?;
let response = state.client().server(&id).await?;
state.remember_dataset(response.dataset.clone());
let name = response.server.summary.name.clone();
state.publish_presence(Activity::Viewing {
server_name: &name,
players: response.server.summary.players,
max_players: response.server.summary.max_players,
});
// A failure to persist a recents entry must not fail the lookup the user asked for.
let _ = state
.store
.write(|s| s.touch_recent(id.as_str(), &name, now_unix()));
Ok(response.server)
}
/// `GET /v2/servers/{id}/history` for one range.
#[tauri::command]
pub async fn get_server_history(
state: State<'_, AppState>,
id: String,
range: HistoryRange,
) -> CommandResult<ServerHistory> {
let id = ServerId::parse(&id)?;
Ok(state.client().server_history(&id, range).await?)
}
/// Compares a server's required mods against the local Arma Reforger addon store.
///
/// Reporting only. There is no verified way to install Reforger Workshop content
/// from outside the game, so this command has no counterpart that acts on the
/// result — see `docs/mod-readiness.md`.
///
/// The server detail comes from the client's cache in the common case, because
/// the inspector has just fetched it, so this costs a directory scan and no HTTP.
#[tauri::command]
pub async fn get_mod_readiness(
state: State<'_, AppState>,
id: String,
) -> CommandResult<ReadinessReport> {
let id = ServerId::parse(&id)?;
let detail = state.client().server(&id).await?;
let mods = detail.server.mods;
// Reading a few hundred small files is blocking IO; keep it off the async
// runtime's worker threads.
tokio::task::spawn_blocking(move || match FilesystemInventory::discover() {
Ok(inventory) => readiness::report(&mods, &inventory),
Err(_) => ReadinessReport::unavailable(Unavailable::NoAddonsDirectory),
})
.await
.map_err(|err| {
CommandError::other("scan_failed", "Could not check locally installed mods.").tap_log(&err)
})
}
/// Measures round-trip latency to a server by ICMP echo.
///
/// The address comes from the API and is validated by
/// [`rfm_core::ping::parse_target`] before a packet is sent: literal public IPs
/// only, so remote data cannot aim the launcher at a hostname of its choosing
/// or at the user's own network.
///
/// Never errors on an unreachable host — it returns a result that states why
/// there is no figure, because "no reply" is information, not a failure.
#[tauri::command]
pub async fn ping_server(state: State<'_, AppState>, id: String) -> CommandResult<PingResult> {
let id = ServerId::parse(&id)?;
// Served from cache in the common case: the inspector just fetched it.
let detail = state.client().server(&id).await?;
Ok(ping::measure(
detail.server.host.as_deref(),
ping::DEFAULT_ATTEMPTS,
ping::DEFAULT_TIMEOUT,
)
.await)
}
/// Measures latency to every published ping site, keyed by site id.
///
/// This is what makes a ping column in the browser possible. `/v2/servers` does
/// not publish a per-server address — only `/v2/servers/{id}` does — so pinging
/// a 100-row page would cost 100 detail requests against a 60/minute limit.
/// Every row does carry `pingSiteId`, and there are only eight sites, so eight
/// probes cover the whole list.
///
/// The figure is therefore latency to the server's *region*, not to the server.
/// Measured against real servers the two agree within roughly 20 ms, which is
/// enough to sort by and is what the game's own browser shows. The inspector
/// still measures the selected server exactly.
///
/// Sites are probed concurrently; one unreachable site cannot hold up the rest.
#[tauri::command]
pub async fn ping_regions(
state: State<'_, AppState>,
) -> CommandResult<HashMap<String, PingResult>> {
let sites = state.client().ping_sites().await?.sites;
let probes: Vec<_> = sites
.into_iter()
.filter_map(|site| {
let address = site.address?;
Some(tokio::spawn(async move {
let result =
ping::measure_address(&address, ping::DEFAULT_ATTEMPTS, ping::DEFAULT_TIMEOUT)
.await;
(site.id, result)
}))
})
.collect();
let mut out = HashMap::with_capacity(probes.len());
for probe in probes {
if let Ok((id, result)) = probe.await {
out.insert(id, result);
}
}
Ok(out)
}
/// Most servers resolvable in one call. Each costs a request, and the public
/// limit is 60/minute; favorites and recents are both bounded below this.
const MAX_RESOLVE: usize = 40;
/// Requests in flight while resolving. Enough to feel instant, low enough not
/// to burst the rate limiter.
const RESOLVE_CONCURRENCY: usize = 4;
/// Resolves a list of server ids to current summaries.
///
/// Backs the favorites and recents views, which hold ids rather than servers and
/// so cannot render a live table from local state alone. Results keep the input
/// order; ids that no longer resolve are dropped rather than failing the call,
/// because a favorite disappearing from the index is normal and the rest of the
/// list is still useful.
///
/// `/v2/servers` cannot filter by id, so this is one detail request per server.
/// That is affordable only because both lists are small — hence [`MAX_RESOLVE`].
#[tauri::command]
pub async fn get_servers(
state: State<'_, AppState>,
ids: Vec<String>,
) -> CommandResult<Vec<ServerSummary>> {
let wanted: Vec<ServerId> = ids
.iter()
.filter_map(|id| ServerId::parse(id).ok())
.take(MAX_RESOLVE)
.collect();
let limit = std::sync::Arc::new(tokio::sync::Semaphore::new(RESOLVE_CONCURRENCY));
let mut tasks = Vec::with_capacity(wanted.len());
for id in wanted {
let client = state.client();
let limit = limit.clone();
tasks.push(tokio::spawn(async move {
let _permit = limit.acquire().await.ok()?;
client.server(&id).await.ok().map(|r| r.server.summary)
}));
}
let mut out = Vec::with_capacity(tasks.len());
for task in tasks {
if let Ok(Some(summary)) = task.await {
out.push(summary);
}
}
Ok(out)
}
/// Players online across the whole fleet, for the header.
#[tauri::command]
pub async fn get_fleet_now(state: State<'_, AppState>) -> CommandResult<FleetNow> {
Ok(state.client().fleet_now().await?.now)
}
/// Ping sites, used to label regions in the filter bar.
#[tauri::command]
pub async fn list_ping_sites(state: State<'_, AppState>) -> CommandResult<Vec<PingSite>> {
Ok(state.client().ping_sites().await?.sites)
}
/// The compact API status for the header. Never fails; an unreachable API
/// produces an offline reading.
#[tauri::command]
pub async fn get_status(state: State<'_, AppState>) -> CommandResult<LauncherStatus> {
let hint = state.last_dataset();
// `launcher_status` absorbs every failure into an offline/unknown reading,
// so this is infallible; the Result is only Tauri's async command contract.
Ok(state.client().launcher_status(hint).await)
}
/// Joins a server directly through Steam.
///
/// The id is validated first, so the URI handed to the OS contains nothing but
/// hex digits and dashes. `name` is only ever written to the local history file.
#[tauri::command]
pub async fn join_server(
app: tauri::AppHandle,
state: State<'_, AppState>,
id: String,
name: String,
) -> CommandResult<String> {
let id = ServerId::parse(&id)?;
// steam://run/<appid>//<payload>/ — the empty segment marks the payload as a
// launch argument. See `rfm_core::launch` for why the single-slash form
// starts the game without joining.
let uri = steam_join_uri(&id);
state.publish_presence(Activity::Joining { server_name: &name });
let result = app.opener().open_url(&uri, None::<&str>);
let succeeded = result.is_ok();
let _ = state
.store
.write(|s| s.record_launch(id.as_str(), &name, succeeded, now_unix()));
match result {
Ok(()) => Ok(uri),
Err(err) => Err(CommandError::other(
"launch_failed",
"Could not hand the join request to Steam. Is Steam installed?",
)
.tap_log(&err)),
}
}
/// Starts Arma Reforger without joining a server.
#[tauri::command]
pub async fn launch_game(app: tauri::AppHandle) -> CommandResult<()> {
app.opener()
.open_url(steam_launch_uri(), None::<&str>)
.map_err(|err| {
CommandError::other(
"launch_failed",
"Could not start Arma Reforger through Steam.",
)
.tap_log(&err)
})
}
/// Opens a server's page on reforgermods.net in the default browser.
///
/// The URL is built from the validated id rather than from the API's `modURL`
/// field, so a hostile API response cannot choose the destination.
#[tauri::command]
pub async fn open_server_page(app: tauri::AppHandle, id: String) -> CommandResult<()> {
let id = ServerId::parse(&id)?;
let url = format!("https://reforgermods.net/servers/{id}");
app.opener().open_url(&url, None::<&str>).map_err(|err| {
CommandError::other("open_failed", "Could not open the reforgermods.net page.")
.tap_log(&err)
})
}
/// Opens a Discord invite in the default browser.
///
/// Takes the invite **code**, never a URL: the code is re-validated here and the
/// URL is rebuilt, so text in a server name cannot choose the destination even
/// if the UI's own extraction were wrong.
#[tauri::command]
pub async fn open_discord_invite(app: tauri::AppHandle, code: String) -> CommandResult<()> {
let valid = (2..=64).contains(&code.len())
&& code
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
if !valid {
return Err(CommandError::other(
"invalid_invite",
"Not a valid Discord invite.",
));
}
let url = format!("https://discord.gg/{code}");
app.opener()
.open_url(&url, None::<&str>)
.map_err(|err| CommandError::other("open_failed", "Could not open Discord.").tap_log(&err))
}
/// Settings, favorites, recents and history, read once at startup.
#[tauri::command]
pub fn get_local_state(state: State<'_, AppState>) -> LocalStateDto {
state.store.read(|state| LocalStateDto::from(state))
}
/// Adds or removes a favorite. Returns the new state.
#[tauri::command]
pub fn toggle_favorite(state: State<'_, AppState>, id: String) -> CommandResult<bool> {
state
.store
.write(|s| s.toggle_favorite(&id))?
.ok_or_else(|| CommandError::other("invalid_server_id", "Not a valid server id."))
}
/// Replaces application settings, re-pointing the API client when the base URL
/// changed. Returns the settings as stored, after clamping.
#[tauri::command]
pub fn update_settings(state: State<'_, AppState>, settings: Settings) -> CommandResult<Settings> {
let stored = state.store.write(|s| {
s.settings = settings;
s.settings.sanitise();
s.settings.clone()
})?;
// Applies a presence opt-out immediately rather than at the next refresh.
if !stored.discord_presence {
state.publish_presence(Activity::Browsing { online_servers: 0 });
}
Ok(stored)
}
/// Diagnostic view of the installation identity. Debug mode only.
///
/// Returns an empty id outside debug mode so the value is not exposed to the
/// UI — and therefore not to anything reading the window — in normal use.
#[tauri::command]
pub fn get_install_identity(state: State<'_, AppState>) -> InstallIdentity {
if !crate::debug_mode() {
return InstallIdentity::default();
}
InstallIdentity {
id: state.store.install_id(),
machine_derived: rfm_core::identity::is_machine_derived(),
}
}
/// Whether this session is running in debug mode.
///
/// Gates developer-facing detail in the UI, such as the install identifier.
/// Enabled with `RFM_DEBUG=1`.
#[tauri::command]
pub fn is_debug_mode() -> bool {
crate::debug_mode()
}
/// Clears recents and launch history. Favorites are left alone.
#[tauri::command]
pub fn clear_history(state: State<'_, AppState>) -> CommandResult<()> {
state.store.write(|s| {
s.recents.clear();
s.history.clear();
})?;
Ok(())
}
+108
View File
@@ -0,0 +1,108 @@
//! reforgermods.net launcher: the Tauri application shell.
//!
//! This crate is intentionally thin. It owns the window, the command surface and
//! the wiring between them; all logic lives in `rfm-core`, which has no Tauri
//! dependency and is tested directly.
mod commands;
mod presence;
mod state;
#[cfg(target_os = "windows")]
mod update;
use rfm_core::store::Store;
use state::AppState;
use tauri::Manager as _;
/// True when `RFM_DEBUG` is set to something truthy.
///
/// Gates developer-facing detail; nothing about product behaviour depends on it.
pub fn debug_mode() -> bool {
std::env::var("RFM_DEBUG")
.map(|v| matches!(v.trim(), "1" | "true" | "yes"))
.unwrap_or(false)
}
/// File the launcher persists settings, favorites, recents and history to.
const STATE_FILE: &str = "launcher-state.json";
/// Builds and runs the application.
pub fn run() {
#[allow(unused_mut)]
let mut builder = tauri::Builder::default()
// Used from Rust only, to hand steam:// and https:// URIs to the OS.
// The UI is not granted permission to call it.
.plugin(tauri_plugin_opener::init());
// Windows only; on Linux the distro package owns updates. See update.rs.
#[cfg(target_os = "windows")]
{
builder = builder
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(update::PendingUpdate::default());
}
let app = builder
.setup(|app| {
let store = Store::load(state_file_path(app.handle()));
let state = AppState::new(store).map_err(|e| {
// A client that cannot be built even against the production
// default means no TLS backend; there is nothing to recover to.
format!("could not initialise the API client: {e}")
})?;
app.manage(state);
// Background: never blocks the window appearing, and the download is
// applied when the session ends rather than during it.
#[cfg(target_os = "windows")]
update::spawn_check(app.handle().clone());
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::list_servers,
commands::get_server,
commands::get_servers,
commands::get_server_history,
commands::get_mod_readiness,
commands::ping_server,
commands::ping_regions,
commands::get_fleet_now,
commands::list_ping_sites,
commands::get_status,
commands::join_server,
commands::launch_game,
commands::open_server_page,
commands::open_discord_invite,
commands::get_local_state,
commands::toggle_favorite,
commands::update_settings,
commands::get_install_identity,
commands::is_debug_mode,
commands::clear_history,
])
.build(tauri::generate_context!())
.expect("error while building the reforgermods.net launcher");
// `run` with a callback rather than `Builder::run`, so the exit event can
// hand off to a staged installer. The callback runs for every event; only
// Exit is of interest, and it is the last one.
app.run(|_handle, event| {
if let tauri::RunEvent::Exit = event {
#[cfg(target_os = "windows")]
update::install_staged(_handle);
}
});
}
/// Where the state file lives.
///
/// Uses Tauri's resolved per-app config directory when available, falling back to
/// the working directory so a sandboxed environment without a config dir still
/// starts rather than panicking.
fn state_file_path(app: &tauri::AppHandle) -> std::path::PathBuf {
match app.path().app_config_dir() {
Ok(dir) => dir.join(STATE_FILE),
Err(_) => std::path::PathBuf::from(STATE_FILE),
}
}
+108
View File
@@ -0,0 +1,108 @@
// Release builds must not open a console window on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
/// Works around a WebKitGTK startup crash on Nvidia under Wayland.
///
/// WebKitGTK's DMABUF renderer and the proprietary Nvidia driver disagree about
/// buffer import, and GTK aborts before the window is ever mapped:
///
/// ```text
/// Gdk-Message: Error 71 (Protocol error) dispatching to Wayland display.
/// ```
///
/// Setting `WEBKIT_DISABLE_DMABUF_RENDERER=1` falls back to a software
/// composite path, which costs some GPU acceleration and produces a window that
/// actually appears. Reported against Arch with an Nvidia card; it is a WebKit
/// and driver interaction rather than anything this app controls.
///
/// Deliberately narrow, because the fallback is not free for anyone who does not
/// need it:
///
/// - an existing value is never overridden, so a user debugging this keeps control;
/// - only when the Nvidia driver is actually loaded;
/// - only in a Wayland session, since the X11 path does not use this renderer;
/// - not when GDK is already pinned to X11, where it is equally unused.
#[cfg(target_os = "linux")]
fn disable_dmabuf_renderer_on_nvidia_wayland() {
let decision = should_disable_dmabuf(
std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_some(),
std::env::var_os("WAYLAND_DISPLAY").is_some(),
std::env::var_os("GDK_BACKEND")
.as_deref()
.and_then(|b| b.to_str()),
std::path::Path::new("/proc/driver/nvidia/version").exists()
|| std::path::Path::new("/sys/module/nvidia").exists(),
);
if !decision {
return;
}
// SAFETY: this runs at the top of main, before Tauri, GTK or any thread
// exists, so there is no concurrent reader of the environment. It must also
// happen here rather than later: WebKit reads this once, during init.
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
/// The decision behind [`disable_dmabuf_renderer_on_nvidia_wayland`], separated
/// from the environment so each condition can be tested.
#[cfg(target_os = "linux")]
fn should_disable_dmabuf(
already_set: bool,
wayland_session: bool,
gdk_backend: Option<&str>,
nvidia_loaded: bool,
) -> bool {
if already_set || !wayland_session || !nvidia_loaded {
return false;
}
// A GDK_BACKEND naming x11 alone means the Wayland renderer is not in play.
// A list such as "wayland,x11" still prefers Wayland, so only the exact pin
// counts as opting out.
gdk_backend != Some("x11")
}
fn main() {
#[cfg(target_os = "linux")]
disable_dmabuf_renderer_on_nvidia_wayland();
reforgermods_launcher_lib::run()
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::should_disable_dmabuf;
#[test]
fn nvidia_under_wayland_is_the_case_that_needs_the_workaround() {
assert!(should_disable_dmabuf(false, true, None, true));
assert!(should_disable_dmabuf(false, true, Some("wayland"), true));
// Still Wayland-first, so the renderer is still the one that breaks.
assert!(should_disable_dmabuf(
false,
true,
Some("wayland,x11"),
true
));
}
#[test]
fn an_explicit_setting_is_never_overridden() {
// Someone debugging this has set it to 0 on purpose; honour that.
assert!(!should_disable_dmabuf(true, true, None, true));
}
#[test]
fn other_sessions_and_drivers_keep_accelerated_rendering() {
assert!(
!should_disable_dmabuf(false, false, None, true),
"X11 session"
);
assert!(
!should_disable_dmabuf(false, true, None, false),
"not Nvidia"
);
assert!(
!should_disable_dmabuf(false, true, Some("x11"), true),
"pinned to XWayland"
);
}
}
+163
View File
@@ -0,0 +1,163 @@
//! Discord Rich Presence transport.
//!
//! Discord's IPC is a blocking local socket, and Discord may not be running at
//! all, so this owns one background thread and talks to it over a channel.
//! Nothing on the UI path ever blocks on Discord, and a missing or restarted
//! Discord is a no-op rather than an error the user sees.
//!
//! Payload text is built in `rfm_core::presence`, where the length and
//! sanitisation rules are tested.
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
use discord_rich_presence::error::Error as DiscordError;
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
use rfm_core::presence::Presence;
/// Discord application id. Determines the name shown as the game being played.
///
/// Empty by default: without a real application this build must not attempt to
/// connect. Set `RFM_DISCORD_APP_ID` at runtime, or replace this constant once
/// the application exists. See `docs/discord-presence.md`.
const DEFAULT_APP_ID: &str = "1552757014474784898";
/// Environment override for the application id.
const APP_ID_ENV: &str = "RFM_DISCORD_APP_ID";
/// Artwork key uploaded to the Discord application's Rich Presence assets.
const LARGE_IMAGE_KEY: &str = "logo";
/// How long to wait before retrying a failed connection. Discord being closed
/// is the normal case, so this stays slow and quiet.
const RECONNECT_DELAY: Duration = Duration::from_secs(30);
/// Messages the worker accepts.
enum Message {
/// Publish this presence.
Update(Presence),
/// Stop publishing and disconnect.
Clear,
}
/// Handle to the presence worker.
pub struct PresenceHandle {
tx: Option<Sender<Message>>,
}
impl PresenceHandle {
/// Starts the worker, or returns an inert handle when presence is disabled.
///
/// Disabled means: no application id configured. The launcher still runs
/// normally; nothing is published.
pub fn start() -> Self {
let app_id = std::env::var(APP_ID_ENV)
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| DEFAULT_APP_ID.to_string());
if app_id.trim().is_empty() {
return Self { tx: None };
}
let (tx, rx) = mpsc::channel();
std::thread::Builder::new()
.name("discord-presence".into())
.spawn(move || worker(app_id, rx))
.ok();
Self { tx: Some(tx) }
}
/// True when presence is configured and a worker is running.
pub fn is_enabled(&self) -> bool {
self.tx.is_some()
}
/// Publishes `presence`. Never blocks and never fails the caller.
pub fn update(&self, presence: Presence) {
self.send(Message::Update(presence));
}
/// Clears the presence, e.g. when the user turns it off.
pub fn clear(&self) {
self.send(Message::Clear);
}
fn send(&self, message: Message) {
if let Some(tx) = &self.tx {
// A dead worker means Discord support is gone for this session;
// that is not worth surfacing.
let _ = tx.send(message);
}
}
}
/// Owns the IPC client for the life of the process.
fn worker(app_id: String, rx: Receiver<Message>) {
let mut client = DiscordIpcClient::new(&app_id);
let mut connected = false;
let mut last_attempt: Option<Instant> = None;
// Re-sent after a reconnect, so a Discord restart does not blank the status.
let mut current: Option<Presence> = None;
while let Ok(message) = rx.recv() {
match message {
Message::Update(presence) => current = Some(presence),
Message::Clear => {
// `take` both clears and tells us whether anything was live, so
// a clear with nothing published costs no IPC round trip.
if current.take().is_some() && connected {
let _ = client.clear_activity();
}
}
}
// Nothing to publish means no reason to hold or open a connection.
let Some(presence) = current.clone() else {
continue;
};
if !connected {
// Back off: Discord not running is the normal case, not an error.
let due = last_attempt.is_none_or(|at| at.elapsed() >= RECONNECT_DELAY);
if !due {
continue;
}
last_attempt = Some(Instant::now());
connected = client.connect().is_ok();
if !connected {
continue;
}
}
if publish(&mut client, &presence).is_err() {
// Discord closed or restarted; drop the connection and let the next
// update reconnect after the backoff.
connected = false;
let _ = client.close();
}
}
if connected {
let _ = client.clear_activity();
let _ = client.close();
}
}
/// Sends one activity.
fn publish(client: &mut DiscordIpcClient, presence: &Presence) -> Result<(), DiscordError> {
client.set_activity(
activity::Activity::new()
.details(presence.details.as_str())
.state(presence.state.as_str())
.assets(
activity::Assets::new()
.large_image(LARGE_IMAGE_KEY)
.large_text(presence.large_text.as_str()),
)
.buttons(vec![activity::Button::new(
"Get the launcher",
"https://reforgermods.net",
)]),
)
}
+77
View File
@@ -0,0 +1,77 @@
//! Shared application state.
use std::sync::{Mutex, RwLock};
use rfm_core::api::models::DatasetMeta;
use rfm_core::presence::Activity;
use rfm_core::store::Store;
use crate::presence::PresenceHandle;
use rfm_core::{ApiClient, ApiError};
/// State held for the lifetime of the application.
pub struct AppState {
/// Behind an `RwLock` because changing the API base URL in settings swaps the
/// client, while every request only needs read access.
client: RwLock<ApiClient>,
/// Persisted settings, favorites, recents and launch history.
pub store: Store,
/// Discord Rich Presence publisher. Inert when no application id is set.
presence: PresenceHandle,
/// Snapshot freshness from the most recent server response.
///
/// Every `/v2/servers` response carries this, so the header can show
/// "server data updated Ns ago" without its own polling loop.
last_dataset: Mutex<Option<DatasetMeta>>,
}
impl AppState {
/// Builds state with a client for the configured API origin, identifying
/// with the store's anonymous install id.
pub fn new(store: Store) -> Result<Self, ApiError> {
let client = ApiClient::new(&store.install_id())?;
Ok(Self {
client: RwLock::new(client),
store,
presence: PresenceHandle::start(),
last_dataset: Mutex::new(None),
})
}
/// A clone of the current client, so requests do not hold the lock while
/// awaiting the network.
pub fn client(&self) -> ApiClient {
self.client
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// Publishes `activity` to Discord, if the user has it enabled.
///
/// Silently does nothing when presence is off or unconfigured; this is a
/// decorative feature and must never interfere with a user action.
pub fn publish_presence(&self, activity: Activity<'_>) {
if !self.presence.is_enabled() {
return;
}
if self.store.read(|s| s.settings.discord_presence) {
self.presence.update(activity.to_presence());
} else {
self.presence.clear();
}
}
/// Records snapshot freshness seen on a server response.
pub fn remember_dataset(&self, dataset: DatasetMeta) {
*self.last_dataset.lock().unwrap_or_else(|e| e.into_inner()) = Some(dataset);
}
/// The most recently seen snapshot freshness, if any.
pub fn last_dataset(&self) -> Option<DatasetMeta> {
self.last_dataset
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Background updates, Windows only.
//!
//! The launcher is an API client, which is what makes this worth shipping in the
//! first release rather than later: when `/v2/servers` changes shape, anyone on
//! an old build gets a broken server browser and blames the site. An updater
//! turns that from a support problem into a non-event.
//!
//! The shape is deliberate, and it is the reason this is not simply
//! `download_and_install`:
//!
//! 1. check and download in the background, so nothing blocks startup;
//! 2. hold the downloaded package until the session ends;
//! 3. run the installer as the app exits, without relaunching it.
//!
//! Nobody is interrupted mid-session, and nobody is asked to make a decision
//! about a patch release. The update is simply there the next time they open the
//! launcher. `Update::install` calls `std::process::exit(0)` on Windows after
//! launching the installer, which is harmless at exit and would be a hard kill at
//! any other moment — another reason the install is pinned to exactly this point.
//!
//! There is no updater on Linux. The Arch package is owned by pacman, and an app
//! that rewrites its own files under `/usr/bin` fights the package manager (see
//! `docs/distribution.md` section 7). The dependency is scoped to Windows in
//! `Cargo.toml`, so this module does not exist in a Linux build.
use std::sync::Mutex;
use tauri::Manager as _;
use tauri_plugin_updater::{Update, UpdaterExt as _};
/// An update that has been downloaded and is waiting for the session to end.
///
/// `Update` carries the verified release metadata and `bytes` is the installer
/// payload, already signature-checked by the plugin against the public key in
/// `tauri.conf.json`. Holding both means the install at exit is a local
/// operation: no network, nothing that can hang a closing app.
#[derive(Default)]
pub struct PendingUpdate(Mutex<Option<Staged>>);
struct Staged {
update: Update,
bytes: Vec<u8>,
}
/// Reports a failure only to a developer. An updater that cannot reach the
/// network is not the user's problem and must not produce a dialog; `RFM_DEBUG=1`
/// is how it becomes visible, matching how the rest of the app gates detail.
fn note(context: &str, error: &dyn std::fmt::Display) {
if crate::debug_mode() {
eprintln!("updater: {context}: {error}");
}
}
/// Checks for an update and downloads it, in the background.
///
/// Silent on every failure by design: no endpoint, no network, a malformed
/// manifest or a bad signature all leave the app exactly as it was.
pub fn spawn_check(app: tauri::AppHandle) {
// A dev build's version always matches the manifest, so the check can only
// cost a request. Skipped rather than relied upon to no-op.
if cfg!(debug_assertions) {
return;
}
tauri::async_runtime::spawn(async move {
let updater = match app.updater() {
Ok(updater) => updater,
Err(error) => return note("could not build the updater", &error),
};
let update = match updater.check().await {
Ok(Some(update)) => update,
// Already current: the overwhelmingly common case.
Ok(None) => return,
Err(error) => return note("check failed", &error),
};
let bytes = match update.download(|_, _| {}, || {}).await {
Ok(bytes) => bytes,
Err(error) => return note("download failed", &error),
};
if let Some(state) = app.try_state::<PendingUpdate>() {
*state.0.lock().unwrap() = Some(Staged { update, bytes });
}
});
}
/// Runs a staged installer, if there is one. Called from the exit event.
///
/// `restart_after_install(false)` is the whole point: the default relaunches the
/// app, and relaunching something the user has just closed is worse than the
/// stale build it fixes.
pub fn install_staged(app: &tauri::AppHandle) {
let Some(state) = app.try_state::<PendingUpdate>() else {
return;
};
// Taken, not borrowed: if the installer somehow returns, a second attempt
// during the same shutdown is not wanted.
let Some(staged) = state.0.lock().unwrap().take() else {
return;
};
if let Err(error) = staged
.update
.restart_after_install(false)
.install(staged.bytes)
{
note("install failed", &error);
}
}
+74
View File
@@ -0,0 +1,74 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "reforgermods.net launcher",
"version": "0.1.0",
"identifier": "net.reforgermods.launcher",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "reforgermods.net launcher",
"width": 1400,
"height": 880,
"minWidth": 1000,
"minHeight": 620,
"resizable": true,
"center": true,
"decorations": false,
"theme": "Dark",
"dragDropEnabled": false,
"backgroundColor": "#050d10"
}
],
"security": {
"csp": {
"default-src": "'self'",
"script-src": "'self'",
"style-src": "'self' 'unsafe-inline'",
"font-src": "'self'",
"connect-src": "'self' ipc: http://ipc.localhost",
"img-src": "'self' data: https://ar-gcp-cdn.bistudio.com",
"object-src": "'none'",
"base-uri": "'self'",
"frame-src": "'none'"
},
"capabilities": ["main"]
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": true,
"publisher": "reforgermods.net",
"shortDescription": "Server browser and direct launcher for Arma Reforger.",
"longDescription": "A fast, lightweight desktop companion for Arma Reforger built on the reforgermods.net API: live server browser, filtering, favorites and direct joining through Steam.",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
],
"windows": {
"nsis": {
"installMode": "currentUser"
},
"webviewInstallMode": {
"type": "downloadBootstrapper"
}
}
},
"plugins": {
"updater": {
"endpoints": ["https://dl.reforgermods.net/updates/latest.json"],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEIxMDJFNDhENEZDNTE4M0IKUldRN0dNVlBqZVFDc1dBTHlSY2tsakRyNjYvZHdpdWNHQzRud3dzeGhlTTFidmwrekNaUlJYdEYK",
"windows": {
"installMode": "passive"
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"windows": {
"signCommand": "cmd /C ..\\packaging\\windows\\sign.cmd \"%1\""
}
}
}