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