Skip to main content

rpfm_server/
main.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! # `rpfm_server`
12//!
13//! Backend process for [Rusted PackFile Manager][rpfm]. Hosts the heavy work
14//! that the Qt6 UI ([`rpfm_ui`][ui]) and AI / MCP clients drive remotely:
15//! Pack I/O, schema decoding, diagnostics, search, dependencies, optimisation
16//! and so on.
17//!
18//! [rpfm]: https://github.com/Frodo45127/rpfm
19//! [ui]: https://crates.io/crates/rpfm_ui
20//!
21//! ## Architecture
22//!
23//! The server is built on [`axum`] (HTTP + WebSocket) and [`tokio`]. It binds
24//! to `127.0.0.1:45127` by default and exposes three endpoints:
25//!
26//! | Endpoint    | Method | Purpose                                                                          |
27//! |-------------|--------|----------------------------------------------------------------------------------|
28//! | `/ws`       | GET    | WebSocket upgrade. Carries the [`rpfm_ipc`] command/response protocol.           |
29//! | `/version`  | GET    | REST: report the server build version + pid.    |
30//! | `/sessions` | GET    | REST: list every active session (used by the UI session picker).                 |
31//! | `/mcp`      | *      | MCP `StreamableHttpService` exposing the same surface to AI / MCP clients.       |
32//!
33//! Every client connection is wrapped in a [`session::Session`] managed by a
34//! [`session::SessionManager`]. Each session owns a dedicated background
35//! thread (see [`background_thread`]) that processes commands serially against
36//! its own in-memory state (open packs, dependency cache, settings cache),
37//! so multiple concurrent clients can't step on each other.
38//!
39//! ## Modules
40//!
41//! - [`background_thread`] — central command dispatcher; one async loop per session.
42//! - [`comms`] — generic mpsc-based request/response abstraction used to talk
43//!   to the background thread.
44//! - [`server_websocket`] — `/ws` upgrade handler and message multiplexer.
45//! - [`server_mcp`] — `/mcp` endpoint: tools, prompts, resources for MCP clients.
46//! - [`session`] — `SessionManager`, `Session`, lifecycle and timeout handling.
47//! - [`settings`] — JSON-backed settings store with batch-write optimisation.
48//! - [`updater`] — self-update checks against GitHub releases.
49//!
50//! ## Telemetry
51//!
52//! Logging, panic capture and action telemetry are wired through
53//! [`rpfm_telemetry`]. The Sentry guard returned by [`Logger::init`] is held
54//! for the process lifetime in [`main`].
55
56// Under windows, hide the server window by default.
57#![windows_subsystem = "windows"]
58
59use axum::{extract::State, routing::get, Json, Router};
60use rmcp::transport::streamable_http_server::{session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService};
61use tokio::net::TcpListener;
62use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer};
63
64use std::net::SocketAddr;
65use std::path::PathBuf;
66use std::sync::Arc;
67
68use rpfm_ipc::helpers::SessionInfo;
69use rpfm_ipc::settings_keys::{ANONYMOUS_TELEMETRY_ID, ENABLE_CRASH_REPORTS, ENABLE_USAGE_TELEMETRY};
70
71use rpfm_telemetry::{Logger, SentryLayer, SENTRY_DSN, info, release_name, warn};
72
73use crate::server_mcp::McpServer;
74use crate::session::SessionManager;
75use rpfm_ipc::settings::{error_path, init_config_path, Settings};
76use crate::server_websocket::ws_handler;
77
78pub mod api;
79pub mod background_thread;
80pub mod ceo_builder;
81pub mod jobs;
82pub mod server_mcp;
83pub mod server_websocket;
84pub mod session;
85pub mod state;
86pub mod translation_hub;
87pub mod updater;
88#[cfg(test)] mod updater_test;
89
90use mimalloc::MiMalloc;
91
92#[global_allocator]
93static GLOBAL: MiMalloc = MiMalloc;
94
95//-------------------------------------------------------------------------------//
96//                                  Constants
97//-------------------------------------------------------------------------------//
98
99/// Sentry DSN used for crash reports.
100const SENTRY_DSN_KEY: &str = match option_env!("RPFM_SERVER_SENTRY_DSN") {
101    Some(dsn) => dsn,
102    None => "",
103};
104
105/// PostHog project API key used for telemetry and feedback.
106const POSTHOG_API_KEY_VALUE: &str = match option_env!("RPFM_SERVER_POSTHOG_API_KEY") {
107    Some(key) => key,
108    None => "",
109};
110
111/// Client ID of RPFM's GitHub OAuth App, used to sign in to submit translations.
112pub const GITHUB_OAUTH_CLIENT_ID: &str = match option_env!("RPFM_SERVER_GITHUB_OAUTH_CLIENT_ID") {
113    Some(id) => id,
114    None => "",
115};
116
117/// Default IP address the HTTP server binds to (`127.0.0.1` / loopback).
118const DEFAULT_ADDRESS: [u8; 4] = [127, 0, 0, 1];
119
120/// Default TCP port the HTTP server listens on.
121const DEFAULT_PORT: u16 = 45127;
122
123//-------------------------------------------------------------------------------//
124//                                  Functions
125//-------------------------------------------------------------------------------//
126
127/// Process entry point.
128///
129/// Initialises the Sentry/telemetry guard, primes the telemetry toggles from
130/// persisted settings, builds the [`session::SessionManager`], wires the
131/// `axum` router (`/ws`, `/sessions`, `/mcp`) and starts the listener on
132/// [`DEFAULT_ADDRESS`]:[`DEFAULT_PORT`].
133///
134/// Returns when the listener stops accepting (typically after every session
135/// has been cleaned up — the cleanup task in [`session::SessionManager`]
136/// terminates the process when the session set drains).
137#[tokio::main]
138async fn main() {
139
140    // Sentry client guard, so we can reuse it later on and keep it in scope for the entire duration of the program.
141    // Must be initialized before the tracing subscriber so the SentryLayer can capture spans.
142    *SENTRY_DSN.write().unwrap() = SENTRY_DSN_KEY.to_owned();
143    rpfm_telemetry::set_posthog_api_key(POSTHOG_API_KEY_VALUE);
144    let guard = Logger::init(&{
145        init_config_path().expect("Error while trying to initialize config path. We're fucked.");
146        error_path().unwrap_or_else(|_| PathBuf::from("."))
147    }, true, false, release_name!()).expect("Failed to initialize logging system.");
148
149    // Setup tracing subscriber for logging, redirecting to stderr to avoid interfering with MCP.
150    // The SentryLayer captures tracing spans/events as Sentry breadcrumbs and performance spans.
151    tracing_subscriber::registry()
152        .with(tracing_subscriber::fmt::layer()
153            .with_writer(std::io::stderr)
154            .with_filter(tracing_subscriber::filter::LevelFilter::INFO))
155        .with(SentryLayer::default())
156        .init();
157
158    if guard.is_enabled() {
159        info!("Sentry logging support for RPFM SERVER enabled. Starting...");
160    } else {
161        info!("Sentry logging support for RPFM SERVER disabled. Starting...");
162    }
163
164    // Read telemetry settings from disk before any sessions spin up so early commands
165    // are counted and crash reports respect the user's choice. Configured sessions refresh them.
166    apply_telemetry_settings(&Settings::init(false));
167
168    // Attach breakdown dimensions to every PostHog event in the next flush.
169    rpfm_telemetry::set_event_property("release", serde_json::Value::from(env!("CARGO_PKG_VERSION")));
170    rpfm_telemetry::set_event_property("os", serde_json::Value::from(std::env::consts::OS));
171    rpfm_telemetry::set_event_property("is_beta", serde_json::Value::from(rpfm_telemetry::is_beta()));
172
173    // Create the session manager to handle per-client sessions,
174    // and start the background cleanup task for expired sessions.
175    let session_manager: Arc<SessionManager> = Arc::new(SessionManager::default());
176    SessionManager::start_cleanup_task(session_manager.clone());
177
178    // Create the MCP service. All MCP clients share one session, got from the manager on each call.
179    let sm = session_manager.clone();
180    let http_service = StreamableHttpService::new(
181        move || Ok(McpServer::new(sm.clone())),
182        LocalSessionManager::default().into(),
183        StreamableHttpServerConfig::default().with_allowed_origins(allowed_origins()),
184    );
185
186    // Setup the endpoints for the server.
187    let app = Router::new()
188        .route("/ws", get(ws_handler))
189        .route("/version", get(version_handler))
190        .route("/sessions", get(sessions_handler))
191        .nest_service("/mcp", http_service)
192        .with_state(session_manager);
193
194    let addr = SocketAddr::from((DEFAULT_ADDRESS, DEFAULT_PORT));
195    match TcpListener::bind(addr).await {
196        Ok(listener) => {
197            info!("Listening on {}", addr);
198            axum::serve(listener, app).await.unwrap();
199        }
200        Err(err) => {
201            warn!("Failed to bind to address {}: {}\n\nThis usually means you got another copy of the server running. Either use that one, or stop it and try again.", addr, err);
202        }
203    }
204}
205
206/// Returns the browser origins allowed to send requests to the server: only its own, as it serves no pages that call it.
207///
208/// Requests without an origin don't come from browsers, so they're always allowed.
209pub fn allowed_origins() -> Vec<String> {
210    ["localhost", "127.0.0.1", "[::1]"].iter()
211        .map(|host| format!("http://{host}:{DEFAULT_PORT}"))
212        .collect()
213}
214
215/// Applies the telemetry toggles and the anonymous telemetry ID of the provided settings to the whole process.
216pub fn apply_telemetry_settings(settings: &Settings) {
217    rpfm_telemetry::set_usage_telemetry_enabled(settings.bool(ENABLE_USAGE_TELEMETRY));
218    rpfm_telemetry::set_crash_reports_enabled(settings.bool(ENABLE_CRASH_REPORTS));
219
220    let id = settings.string(ANONYMOUS_TELEMETRY_ID);
221    if !id.is_empty() {
222        rpfm_telemetry::set_distinct_id(&id);
223    }
224}
225
226/// REST endpoint reporting the server's build version and process id.
227///
228/// Returns a JSON object: `{ "version": "5.0.0", "pid": 1234 }`.
229async fn version_handler() -> Json<serde_json::Value> {
230    Json(serde_json::json!({
231        "version": env!("CARGO_PKG_VERSION"),
232        "pid": std::process::id(),
233    }))
234}
235
236/// REST endpoint to get information about all active sessions.
237///
238/// Returns a JSON array of [`SessionInfo`] objects containing:
239/// - `session_id`: Unique session identifier
240/// - `connection_count`: Number of active WebSocket connections
241/// - `timeout_remaining_secs`: Seconds until session cleanup (if disconnected)
242/// - `is_shutting_down`: Whether session is marked for shutdown
243///
244/// This endpoint is used by the UI's session management dialog to display
245/// available sessions and allow users to connect to specific ones.
246async fn sessions_handler(State(session_manager): State<Arc<SessionManager>>) -> Json<Vec<SessionInfo>> {
247    let sessions = session_manager.get_sessions_info();
248    info!("Sessions endpoint queried: {} active session(s)", sessions.len());
249    Json(sessions)
250}