feat(launcher): one-click client arming + modular preflight/services
Add a GUI "Arm client" button that reproduces client_arm.sh in a single pkexec batch: kernel.yama.ptrace_scope=0, DNAT of EA's hardcoded redirector IP to the OpenFUT server (+ MASQUERADE reply path), and /etc/hosts rewrites for every dead EA hostname (removing foreign shadow lines first, so glibc's first-match resolution can't land on a stale loopback entry). All steps are idempotent (delete-then-add) and injection-safe: config values are charset- validated and rejected on a surprising character, never shell-escaped. arm() returns the concrete change list, which the button logs line-by-line and echoes as an inline pass/fail status on the pre-launch tab (no tab jump, no reuse of the local-services toast). This necessarily lands the surrounding launcher modularization the arm feature is built on, extracted from the former monolithic app.rs/process.rs: - preflight: advisory pre-launch checks (ptrace, redirector DNAT, hostnames, backend reachability) that colour rows but never block Launch - local_services: launcher-owned LSX/autopatch child processes - game_launch, account_sync, health, netcheck helpers - openfut-common: dependency-free shared server-destination/port mapping, used by both the launcher and (separately) openfut_hook.dll openfut-hook RE changes are intentionally left uncommitted (separate concern). fmt + clippy -D warnings clean; 46 tests pass.
This commit is contained in:
Generated
+5
@@ -2279,6 +2279,10 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-common"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-launcher"
|
||||
version = "0.1.0"
|
||||
@@ -2288,6 +2292,7 @@ dependencies = [
|
||||
"dirs",
|
||||
"eframe",
|
||||
"egui",
|
||||
"openfut-common",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
||||
@@ -12,3 +12,4 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
openfut-common = { path = "openfut-common" }
|
||||
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "openfut-common"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "openfut-common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Shared OpenFUT server-destination configuration, port mapping, and address conversion used by both the launcher and openfut_hook.dll. Pure std, no dependencies, so it stays small inside the injected DLL and is unit-testable on any host."
|
||||
|
||||
[lib]
|
||||
# Rlib only. Deliberately dependency-free so linking it into openfut_hook.dll
|
||||
# (a cdylib) adds no runtime weight and no cross-platform risk.
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,479 @@
|
||||
//! Shared OpenFUT destination configuration.
|
||||
//!
|
||||
//! This crate is the **single source of truth** for where FIFA's intercepted
|
||||
//! EA traffic is redirected. It is consumed by both the launcher (which writes
|
||||
//! `openfut.cfg`) and `openfut_hook.dll` (which reads it and rewrites sockets).
|
||||
//!
|
||||
//! Design rules enforced here:
|
||||
//! * There is exactly ONE configured OpenFUT destination (host + ports).
|
||||
//! * Missing/invalid configuration is a hard error — it NEVER silently
|
||||
//! degrades to `127.0.0.1`/localhost. Loopback is only ever used if the
|
||||
//! user explicitly configures it.
|
||||
//! * The address/port -> WinSock representation helpers live here and are
|
||||
//! unit-tested on the host, so the byte-order logic the socket hooks depend
|
||||
//! on is verified without needing WinSock or FIFA.
|
||||
//!
|
||||
//! ## EA source ports vs OpenFUT destination ports
|
||||
//!
|
||||
//! FIFA connects to a handful of EA endpoints on fixed ports. Those source
|
||||
//! ports are *signatures* used to recognise traffic that must be intercepted —
|
||||
//! they are hardcoded on purpose (see [`ea_ports`]). Each recognised EA source
|
||||
//! port maps to an OpenFUT *destination* port, which comes from configuration
|
||||
//! ([`OpenFutPorts`]). Source port = fixed EA signature; destination port =
|
||||
//! configurable OpenFUT service.
|
||||
|
||||
use std::fmt;
|
||||
use std::net::{Ipv4Addr, ToSocketAddrs};
|
||||
use std::str::FromStr;
|
||||
|
||||
/// EA source ports FIFA dials. These are fixed EA endpoint signatures used to
|
||||
/// recognise traffic for interception — NOT OpenFUT configuration. Do not make
|
||||
/// these configurable; changing them would stop us recognising EA traffic.
|
||||
pub mod ea_ports {
|
||||
/// EA HTTPS (UTAS / EAWebKit / footapi) — the game dials EA hosts on 443.
|
||||
pub const HTTPS: u16 = 443;
|
||||
/// EA Blaze redirector (gosredirector) source port.
|
||||
pub const BLAZE_REDIRECTOR: u16 = 10041;
|
||||
/// FIFA 17's `winter15.gosredirector.ea.com` endpoint source port. This is
|
||||
/// an observed, fixed vendor-port signature; it maps to the same configured
|
||||
/// OpenFUT redirector destination as the newer 10041 endpoint.
|
||||
pub const FIFA17_BLAZE_REDIRECTOR: u16 = 42230;
|
||||
/// EA Blaze main server source port.
|
||||
pub const BLAZE_MAIN: u16 = 42127;
|
||||
}
|
||||
|
||||
/// Default OpenFUT *destination* ports, derived from the current OpenFUT server
|
||||
/// implementation (bridge listens on 8443 for HTTPS; Blaze services keep their
|
||||
/// native ports). The launcher may pre-populate these; the user may change them
|
||||
/// without recompiling anything.
|
||||
pub mod default_ports {
|
||||
/// OpenFUT bridge HTTPS listener (EA :443 is redirected here).
|
||||
pub const HTTPS: u16 = 8443;
|
||||
/// OpenFUT FIFA 17 Blaze redirector listener.
|
||||
pub const BLAZE_REDIRECTOR: u16 = 42127;
|
||||
/// OpenFUT FIFA 17 Blaze main listener.
|
||||
pub const BLAZE_MAIN: u16 = 42130;
|
||||
}
|
||||
|
||||
/// OpenFUT destination ports. Each field is where an intercepted EA source port
|
||||
/// is redirected. Not collapsed into one port: OpenFUT runs distinct services
|
||||
/// (bridge HTTPS + two Blaze listeners) that FIFA reaches on distinct ports.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OpenFutPorts {
|
||||
/// Destination for EA :443 traffic (bridge HTTPS).
|
||||
pub https: u16,
|
||||
/// Destination for EA :10041 traffic (Blaze redirector).
|
||||
pub blaze_redirector: u16,
|
||||
/// Destination for EA :42127 traffic (Blaze main).
|
||||
pub blaze_main: u16,
|
||||
}
|
||||
|
||||
impl Default for OpenFutPorts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
https: default_ports::HTTPS,
|
||||
blaze_redirector: default_ports::BLAZE_REDIRECTOR,
|
||||
blaze_main: default_ports::BLAZE_MAIN,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenFutPorts {
|
||||
/// Map a recognised EA *source* port to its OpenFUT *destination* port.
|
||||
/// Returns `None` for ports we don't intercept (the socket hook then leaves
|
||||
/// the connection untouched).
|
||||
pub fn map_source_port(&self, ea_source_port: u16) -> Option<u16> {
|
||||
match ea_source_port {
|
||||
ea_ports::HTTPS => Some(self.https),
|
||||
ea_ports::BLAZE_REDIRECTOR | ea_ports::FIFA17_BLAZE_REDIRECTOR => {
|
||||
Some(self.blaze_redirector)
|
||||
}
|
||||
ea_ports::BLAZE_MAIN => Some(self.blaze_main),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed OpenFUT server configuration: what host to redirect EA traffic to
|
||||
/// and which destination ports to use. `host` may be an IPv4 literal or a
|
||||
/// hostname; resolution to a concrete [`Ipv4Addr`] happens in [`Self::resolve`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ServerConfig {
|
||||
/// User-configured OpenFUT server host (IPv4 literal or hostname). Never
|
||||
/// defaulted to loopback — an empty host is a [`ConfigError::ServerMissing`].
|
||||
pub host: String,
|
||||
pub ports: OpenFutPorts,
|
||||
}
|
||||
|
||||
/// A [`ServerConfig`] whose host has been resolved to a concrete IPv4 address.
|
||||
/// This is what the socket hooks consume — all three interception layers share
|
||||
/// exactly this resolved destination.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ResolvedServer {
|
||||
/// The concrete IPv4 the EA connection's destination is rewritten to.
|
||||
pub redirect_ip: Ipv4Addr,
|
||||
pub ports: OpenFutPorts,
|
||||
}
|
||||
|
||||
/// Errors loading/validating OpenFUT server configuration. Every one of these
|
||||
/// must BLOCK operation — none of them may fall back to loopback.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConfigError {
|
||||
/// No config file present (e.g. `openfut.cfg` missing).
|
||||
ConfigMissing,
|
||||
/// Config present but no server host set.
|
||||
ServerMissing,
|
||||
/// Host could not be parsed/resolved to an IPv4 address.
|
||||
InvalidAddress(String),
|
||||
/// A port value was not a valid `u16` / was zero.
|
||||
InvalidPort(String),
|
||||
/// Config bytes were structurally unparseable.
|
||||
MalformedConfig(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ConfigError::ConfigMissing => {
|
||||
write!(f, "No OpenFUT server configured (config file missing)")
|
||||
}
|
||||
ConfigError::ServerMissing => write!(
|
||||
f,
|
||||
"No OpenFUT server configured. Enter the hostname or IP address of your OpenFUT server."
|
||||
),
|
||||
ConfigError::InvalidAddress(a) => write!(f, "Invalid OpenFUT server address: {a}"),
|
||||
ConfigError::InvalidPort(p) => write!(f, "Invalid OpenFUT port: {p}"),
|
||||
ConfigError::MalformedConfig(m) => write!(f, "Malformed OpenFUT config: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
impl ServerConfig {
|
||||
/// Parse `openfut.cfg` contents.
|
||||
///
|
||||
/// Two accepted formats:
|
||||
/// * **Structured** (preferred), one `key=value` per line:
|
||||
/// ```text
|
||||
/// host=192.168.1.50
|
||||
/// https_port=8443
|
||||
/// blaze_redirector_port=10041
|
||||
/// blaze_main_port=42127
|
||||
/// ```
|
||||
/// `host` is required; any omitted port uses its default.
|
||||
/// * **Legacy**, a single bare line containing just the host
|
||||
/// (IPv4 or hostname). Ports default. This keeps old deployments working.
|
||||
///
|
||||
/// An empty/whitespace-only body is [`ConfigError::ServerMissing`], NOT a
|
||||
/// silent loopback fallback.
|
||||
pub fn parse(contents: &str) -> Result<Self, ConfigError> {
|
||||
let trimmed = contents.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ConfigError::ServerMissing);
|
||||
}
|
||||
|
||||
// Legacy single-token form: no '=' anywhere and a single line.
|
||||
let has_kv = trimmed.lines().any(|l| l.contains('='));
|
||||
if !has_kv {
|
||||
let host = trimmed.trim();
|
||||
if host.is_empty() {
|
||||
return Err(ConfigError::ServerMissing);
|
||||
}
|
||||
return Ok(Self {
|
||||
host: host.to_string(),
|
||||
ports: OpenFutPorts::default(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut host: Option<String> = None;
|
||||
let mut ports = OpenFutPorts::default();
|
||||
|
||||
for (lineno, raw) in trimmed.lines().enumerate() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let (key, value) = line.split_once('=').ok_or_else(|| {
|
||||
ConfigError::MalformedConfig(format!("line {}: expected key=value", lineno + 1))
|
||||
})?;
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
match key {
|
||||
"host" | "server" | "redirect_ip" => {
|
||||
if value.is_empty() {
|
||||
return Err(ConfigError::ServerMissing);
|
||||
}
|
||||
host = Some(value.to_string());
|
||||
}
|
||||
"https_port" => ports.https = parse_port(value)?,
|
||||
"blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?,
|
||||
"blaze_main_port" => ports.blaze_main = parse_port(value)?,
|
||||
other => {
|
||||
return Err(ConfigError::MalformedConfig(format!(
|
||||
"line {}: unknown key '{other}'",
|
||||
lineno + 1
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let host = host.ok_or(ConfigError::ServerMissing)?;
|
||||
Ok(Self { host, ports })
|
||||
}
|
||||
|
||||
/// Serialize to the structured `openfut.cfg` format.
|
||||
pub fn to_cfg_string(&self) -> String {
|
||||
format!(
|
||||
"host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\n",
|
||||
self.host, self.ports.https, self.ports.blaze_redirector, self.ports.blaze_main
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate the host is a syntactically acceptable IPv4 literal or hostname.
|
||||
/// Does NOT perform DNS (no network) — use [`Self::resolve`] for that.
|
||||
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||
let host = self.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(ConfigError::ServerMissing);
|
||||
}
|
||||
if Ipv4Addr::from_str(host).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
if is_plausible_hostname(host) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConfigError::InvalidAddress(host.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the configured host to a concrete IPv4 destination.
|
||||
///
|
||||
/// This is the single shared resolution path all hooks rely on: an IPv4
|
||||
/// literal is used directly; a hostname is resolved via the platform
|
||||
/// resolver ([`ToSocketAddrs`]). Only IPv4 results are used (WinSock
|
||||
/// interception here is IPv4). Never falls back to loopback.
|
||||
pub fn resolve(&self) -> Result<ResolvedServer, ConfigError> {
|
||||
let host = self.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(ConfigError::ServerMissing);
|
||||
}
|
||||
|
||||
// IPv4 literal: no DNS needed.
|
||||
if let Ok(ip) = Ipv4Addr::from_str(host) {
|
||||
return Ok(ResolvedServer {
|
||||
redirect_ip: ip,
|
||||
ports: self.ports,
|
||||
});
|
||||
}
|
||||
|
||||
// Hostname: resolve via the platform resolver. Port is irrelevant to
|
||||
// the lookup; we only need an address. Take the first IPv4 answer.
|
||||
let ip = (host, 0u16)
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| ConfigError::InvalidAddress(format!("{host}: {e}")))?
|
||||
.find_map(|sa| match sa.ip() {
|
||||
std::net::IpAddr::V4(v4) => Some(v4),
|
||||
std::net::IpAddr::V6(_) => None,
|
||||
})
|
||||
.ok_or_else(|| ConfigError::InvalidAddress(format!("{host}: no IPv4 address found")))?;
|
||||
|
||||
Ok(ResolvedServer {
|
||||
redirect_ip: ip,
|
||||
ports: self.ports,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_port(value: &str) -> Result<u16, ConfigError> {
|
||||
let n: u16 = value
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidPort(value.to_string()))?;
|
||||
if n == 0 {
|
||||
return Err(ConfigError::InvalidPort(value.to_string()));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Lenient hostname sanity check (RFC-1123-ish). Not a full validator — just
|
||||
/// enough to reject obvious garbage while accepting `.local`/`.home` names.
|
||||
fn is_plausible_hostname(host: &str) -> bool {
|
||||
if host.is_empty() || host.len() > 253 {
|
||||
return false;
|
||||
}
|
||||
host.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& label.len() <= 63
|
||||
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
&& !label.starts_with('-')
|
||||
&& !label.ends_with('-')
|
||||
})
|
||||
}
|
||||
|
||||
// ── WinSock representation helpers ───────────────────────────────────────────
|
||||
// These convert a resolved destination into the exact in-memory representation
|
||||
// WinSock's `sockaddr_in` expects. Kept here (not in the DLL) so the byte-order
|
||||
// contract the socket hooks depend on is unit-tested on the host.
|
||||
|
||||
/// Value to store in `sockaddr_in.sin_addr.S_un.S_addr`.
|
||||
///
|
||||
/// WinSock stores the four IPv4 octets in network byte order in memory. Reading
|
||||
/// those bytes back as a native-endian `u32` (how the hook's `SockaddrIn.sin_addr`
|
||||
/// field is typed) is exactly `from_ne_bytes(octets)`. On little-endian x86_64
|
||||
/// this yields e.g. `127.0.0.1 -> 0x0100_007F`, matching the value the original
|
||||
/// hooks hardcoded — confirming the conversion is correct.
|
||||
pub fn sin_addr_from_ipv4(ip: Ipv4Addr) -> u32 {
|
||||
u32::from_ne_bytes(ip.octets())
|
||||
}
|
||||
|
||||
/// Value to store in `sockaddr_in.sin_port` — the port in network byte order.
|
||||
pub fn sin_port_nbo(port: u16) -> u16 {
|
||||
port.to_be()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn configured_ipv4_survives_parsing() {
|
||||
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
|
||||
assert_eq!(c.host, "192.168.1.50");
|
||||
assert_eq!(c.ports, OpenFutPorts::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_bare_host_line_parses() {
|
||||
let c = ServerConfig::parse("10.0.0.7\n").unwrap();
|
||||
assert_eq!(c.host, "10.0.0.7");
|
||||
assert_eq!(c.ports, OpenFutPorts::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_server_does_not_become_loopback() {
|
||||
// Empty config is an explicit error, never 127.0.0.1.
|
||||
assert_eq!(
|
||||
ServerConfig::parse("").unwrap_err(),
|
||||
ConfigError::ServerMissing
|
||||
);
|
||||
assert_eq!(
|
||||
ServerConfig::parse(" \n\n").unwrap_err(),
|
||||
ConfigError::ServerMissing
|
||||
);
|
||||
assert_eq!(
|
||||
ServerConfig::parse("https_port=8443\n").unwrap_err(),
|
||||
ConfigError::ServerMissing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_server_is_rejected() {
|
||||
let c = ServerConfig {
|
||||
host: "not a host!!".into(),
|
||||
ports: OpenFutPorts::default(),
|
||||
};
|
||||
assert!(matches!(c.validate(), Err(ConfigError::InvalidAddress(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_port_survives_parsing() {
|
||||
let c = ServerConfig::parse(
|
||||
"host=srv.home\nhttps_port=9000\nblaze_redirector_port=11000\nblaze_main_port=42000\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(c.ports.https, 9000);
|
||||
assert_eq!(c.ports.blaze_redirector, 11000);
|
||||
assert_eq!(c.ports.blaze_main, 42000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_port_is_rejected() {
|
||||
assert!(matches!(
|
||||
ServerConfig::parse("host=x\nhttps_port=0\n"),
|
||||
Err(ConfigError::InvalidPort(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
ServerConfig::parse("host=x\nhttps_port=99999\n"),
|
||||
Err(ConfigError::InvalidPort(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_is_malformed() {
|
||||
assert!(matches!(
|
||||
ServerConfig::parse("host=x\nbogus=1\n"),
|
||||
Err(ConfigError::MalformedConfig(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_cfg_string() {
|
||||
let c = ServerConfig {
|
||||
host: "192.168.1.50".into(),
|
||||
ports: OpenFutPorts {
|
||||
https: 8443,
|
||||
blaze_redirector: 10041,
|
||||
blaze_main: 42127,
|
||||
},
|
||||
};
|
||||
let s = c.to_cfg_string();
|
||||
assert_eq!(ServerConfig::parse(&s).unwrap(), c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_ipv4_becomes_correct_sockaddr() {
|
||||
// Resolve an IPv4 literal and confirm the sin_addr value.
|
||||
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
|
||||
let r = c.resolve().unwrap();
|
||||
assert_eq!(r.redirect_ip, Ipv4Addr::new(192, 168, 1, 50));
|
||||
// sin_addr is the octets as a native-endian u32.
|
||||
assert_eq!(
|
||||
sin_addr_from_ipv4(r.redirect_ip),
|
||||
u32::from_ne_bytes([192, 168, 1, 50])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_sockaddr_matches_legacy_constant() {
|
||||
// Guards the byte-order contract: the old hooks hardcoded 0x0100_007F
|
||||
// for 127.0.0.1. Our helper must reproduce it on this (LE) host.
|
||||
assert_eq!(sin_addr_from_ipv4(Ipv4Addr::new(127, 0, 0, 1)), 0x0100_007F);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sin_port_is_network_byte_order() {
|
||||
// 443 -> 0xBB01, 42127 -> 0x8FA4 (matches the legacy hook constants).
|
||||
assert_eq!(sin_port_nbo(443), 0xBB01);
|
||||
assert_eq!(sin_port_nbo(42127), 0x8FA4);
|
||||
assert_eq!(sin_port_nbo(10041), 0x3927);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_mapping_source_to_destination() {
|
||||
let p = OpenFutPorts::default();
|
||||
assert_eq!(p.map_source_port(ea_ports::HTTPS), Some(8443));
|
||||
assert_eq!(p.map_source_port(ea_ports::BLAZE_REDIRECTOR), Some(42127));
|
||||
assert_eq!(
|
||||
p.map_source_port(ea_ports::FIFA17_BLAZE_REDIRECTOR),
|
||||
Some(42127)
|
||||
);
|
||||
assert_eq!(p.map_source_port(ea_ports::BLAZE_MAIN), Some(42130));
|
||||
assert_eq!(p.map_source_port(12345), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_literal_ipv4_no_dns() {
|
||||
let c = ServerConfig::parse("host=127.0.0.1\n").unwrap();
|
||||
let r = c.resolve().unwrap();
|
||||
assert_eq!(r.redirect_ip, Ipv4Addr::LOCALHOST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_empty_host_errors() {
|
||||
let c = ServerConfig {
|
||||
host: " ".into(),
|
||||
ports: OpenFutPorts::default(),
|
||||
};
|
||||
assert_eq!(c.resolve().unwrap_err(), ConfigError::ServerMissing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use crate::config::LauncherConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync";
|
||||
const TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountSyncRequest<'a> {
|
||||
persona_id: u64,
|
||||
persona_name: &'a str,
|
||||
level: u32,
|
||||
experience: u32,
|
||||
experience_max: u32,
|
||||
account_funds: u32,
|
||||
account_funds_cap: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AccountSyncResult {
|
||||
pub account: AccountSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountSummary {
|
||||
pub persona_id: u64,
|
||||
pub persona_name: String,
|
||||
pub level: u32,
|
||||
pub experience: u32,
|
||||
pub account_funds: u32,
|
||||
pub coins: i64,
|
||||
pub unopened_packs: usize,
|
||||
}
|
||||
|
||||
/// Select the persistent EA/FUT account before LSX and FIFA start.
|
||||
///
|
||||
/// This deliberately uses a tiny stdlib HTTP client so the launcher does not
|
||||
/// acquire an async runtime solely for one bounded control-plane request.
|
||||
pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
||||
config.validate_server()?;
|
||||
config.validate_account()?;
|
||||
|
||||
let host = config.openfut_server_host.trim();
|
||||
let port = config.openfut_account_sync_port;
|
||||
let address = (host, port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|error| format!("cannot resolve account server {host}:{port}: {error}"))?
|
||||
.next()
|
||||
.ok_or_else(|| format!("account server {host}:{port} resolved to no addresses"))?;
|
||||
let mut stream = TcpStream::connect_timeout(&address, TIMEOUT)
|
||||
.map_err(|error| format!("cannot connect to account server {host}:{port}: {error}"))?;
|
||||
stream
|
||||
.set_read_timeout(Some(TIMEOUT))
|
||||
.map_err(|error| format!("cannot set account sync timeout: {error}"))?;
|
||||
stream
|
||||
.set_write_timeout(Some(TIMEOUT))
|
||||
.map_err(|error| format!("cannot set account sync timeout: {error}"))?;
|
||||
|
||||
let payload = serde_json::to_vec(&AccountSyncRequest {
|
||||
persona_id: config.fut_persona_id,
|
||||
persona_name: config.fut_persona_name.trim(),
|
||||
level: config.fut_account_level,
|
||||
experience: config.fut_account_experience,
|
||||
experience_max: config.fut_account_experience_max,
|
||||
account_funds: config.fut_account_funds,
|
||||
account_funds_cap: config.fut_account_funds_cap,
|
||||
})
|
||||
.map_err(|error| format!("cannot encode account sync request: {error}"))?;
|
||||
|
||||
let request = format!(
|
||||
"POST {ACCOUNT_SYNC_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
payload.len()
|
||||
);
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.and_then(|()| stream.write_all(&payload))
|
||||
.map_err(|error| format!("cannot send account sync request: {error}"))?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut response)
|
||||
.map_err(|error| format!("cannot read account sync response: {error}"))?;
|
||||
let separator = response
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.ok_or_else(|| "account server returned a malformed HTTP response".to_string())?;
|
||||
let headers = std::str::from_utf8(&response[..separator])
|
||||
.map_err(|_| "account server returned non-UTF-8 headers".to_string())?;
|
||||
let status = headers
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.ok_or_else(|| "account server returned a malformed status line".to_string())?;
|
||||
let body = &response[separator + 4..];
|
||||
if !(200..300).contains(&status) {
|
||||
let detail = String::from_utf8_lossy(body);
|
||||
return Err(format!(
|
||||
"account server rejected sync (HTTP {status}): {detail}"
|
||||
));
|
||||
}
|
||||
let envelope: AccountSyncResult = serde_json::from_slice(body)
|
||||
.map_err(|error| format!("account server returned invalid JSON: {error}"))?;
|
||||
if envelope.account.persona_id != config.fut_persona_id {
|
||||
return Err(format!(
|
||||
"account server selected persona {} instead of {}",
|
||||
envelope.account.persona_id, config.fut_persona_id
|
||||
));
|
||||
}
|
||||
Ok(envelope.account)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn sync_posts_account_and_reads_selected_profile() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
let mut request = Vec::new();
|
||||
loop {
|
||||
let mut chunk = [0; 1024];
|
||||
let count = socket.read(&mut chunk).unwrap();
|
||||
assert!(count > 0);
|
||||
request.extend_from_slice(&chunk[..count]);
|
||||
if let Some(separator) = request.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let headers = String::from_utf8_lossy(&request[..separator]);
|
||||
let length = headers
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Content-Length: "))
|
||||
.unwrap()
|
||||
.parse::<usize>()
|
||||
.unwrap();
|
||||
if request.len() >= separator + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&request);
|
||||
assert!(request.starts_with("POST /openfut/account/sync HTTP/1.1"));
|
||||
assert!(request.contains("\"personaId\":12345678"));
|
||||
assert!(request.contains("\"personaName\":\"TEST_USER\""));
|
||||
let body = r#"{"status":"OK","account":{"personaId":12345678,"personaName":"TEST_USER","level":7,"experience":200,"accountFunds":50,"coins":15000,"unopenedPacks":1}}"#;
|
||||
write!(
|
||||
socket,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let config = LauncherConfig {
|
||||
openfut_server_host: "127.0.0.1".into(),
|
||||
openfut_account_sync_port: port,
|
||||
fut_persona_id: 12345678,
|
||||
fut_persona_name: "TEST_USER".into(),
|
||||
fut_account_level: 7,
|
||||
fut_account_experience: 200,
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
let selected = sync(&config).unwrap();
|
||||
assert_eq!(selected.persona_name, "TEST_USER");
|
||||
assert_eq!(selected.coins, 15000);
|
||||
assert_eq!(selected.unopened_packs, 1);
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
+779
-321
File diff suppressed because it is too large
Load Diff
+239
@@ -0,0 +1,239 @@
|
||||
//! One-click client arming — the GUI equivalent of `client_arm.sh`, driven by
|
||||
//! [`LauncherConfig`] so it repairs exactly what [`crate::preflight`] checks.
|
||||
//!
|
||||
//! Everything the game reaches by a routable address is redirected to the
|
||||
//! OpenFUT server; two things are inherently local and are NOT touched here (they
|
||||
//! are managed as child processes, see [`crate::local_services`]): the LSX/Origin
|
||||
//! emulator on loopback `:4216` and `autopatch`.
|
||||
//!
|
||||
//! The three privileged steps run in ONE elevated batch (a single `pkexec`
|
||||
//! prompt), mirroring the volatile state `client_arm.sh` set by hand:
|
||||
//!
|
||||
//! 1. `kernel.yama.ptrace_scope=0` — so `autopatch` can write FIFA's `/proc/PID/mem`.
|
||||
//! 2. DNAT EA's hardcoded redirector IP → `server:redirector_port` (+ MASQUERADE
|
||||
//! on the reply path, required for a DNAT to a remote host).
|
||||
//! 3. Point each dead EA hostname at the server in `/etc/hosts`.
|
||||
//!
|
||||
//! All of it is idempotent: the DNAT deletes any prior copy before adding, and
|
||||
//! every `/etc/hosts` line for a managed hostname is removed first — including a
|
||||
//! foreign single-machine-era `127.0.0.1 easw.easports.com` shadow that
|
||||
//! `client_arm.sh` could not remove, because it only deleted its own `# openfut`
|
||||
//! lines and glibc returns the FIRST match.
|
||||
|
||||
use crate::config::LauncherConfig;
|
||||
|
||||
/// Accept only hostname/IP characters. These values come from config fields that
|
||||
/// are ever only IPs or hostnames, so a surprising character is a bug — reject it
|
||||
/// rather than try to escape it into an elevated shell command.
|
||||
fn safe_host(s: &str) -> anyhow::Result<&str> {
|
||||
let t = s.trim();
|
||||
if t.is_empty() {
|
||||
anyhow::bail!("empty host/address");
|
||||
}
|
||||
if t.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b':' | b'-' | b'_'))
|
||||
{
|
||||
Ok(t)
|
||||
} else {
|
||||
anyhow::bail!("refusing to arm with an unexpected character in {t:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the privileged arming script. Pure and unit-tested; the effectful part
|
||||
/// ([`arm`]) only validates config and hands this to the elevated runner.
|
||||
pub(crate) fn arming_script(
|
||||
server: &str,
|
||||
redirector_port: u16,
|
||||
ea_ip: &str,
|
||||
hostnames: &[String],
|
||||
) -> anyhow::Result<String> {
|
||||
let server = safe_host(server)?;
|
||||
let ea_ip = safe_host(ea_ip)?;
|
||||
|
||||
let mut s = String::from("set -eu\n");
|
||||
|
||||
// 1) ptrace_scope for autopatch's /proc/PID/mem write.
|
||||
s.push_str("sysctl -q kernel.yama.ptrace_scope=0\n");
|
||||
|
||||
// 2) DNAT EA's hardcoded redirector IP to the server; SNAT the redirected
|
||||
// flow (a DNAT from OUTPUT to a remote host needs a matching MASQUERADE or
|
||||
// the server's replies won't match the game's conntrack entry). Both are
|
||||
// delete-then-add so re-running and IP changes stay clean.
|
||||
s.push_str(&format!(
|
||||
"while iptables -t nat -D OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port} 2>/dev/null; do :; done\n\
|
||||
iptables -t nat -A OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port}\n\
|
||||
while iptables -t nat -D POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE 2>/dev/null; do :; done\n\
|
||||
iptables -t nat -A POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE\n"
|
||||
));
|
||||
|
||||
// 3) Every dead EA hostname resolves to the server. Delete ALL existing lines
|
||||
// listing the name (foreign shadow included) BEFORE writing ours, so the
|
||||
// first-match-wins resolution can never land on a stale loopback line.
|
||||
for host in hostnames {
|
||||
let host = safe_host(host)?;
|
||||
let re = host.replace('.', "\\.");
|
||||
s.push_str(&format!(
|
||||
"sed -ri '/[[:space:]]{re}([[:space:]]|$)/d' /etc/hosts\n\
|
||||
printf '%s\\t%s\\t# openfut\\n' '{server}' '{host}' >> /etc/hosts\n"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Human-readable list of what [`arm`] changed, in the order the script applies
|
||||
/// it. Logged by the UI so the user sees exactly what was set — not just that
|
||||
/// "something" ran under `pkexec`.
|
||||
pub(crate) fn arming_summary(
|
||||
server: &str,
|
||||
redirector_port: u16,
|
||||
ea_ip: &str,
|
||||
hostnames: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut out = vec![
|
||||
"kernel.yama.ptrace_scope = 0 (autopatch can attach)".to_string(),
|
||||
format!("DNAT {ea_ip} -> {server}:{redirector_port} (+ MASQUERADE reply path)"),
|
||||
];
|
||||
for host in hostnames {
|
||||
out.push(format!("hosts: {host} -> {server}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Arm the client from config, under one elevated prompt. Requires the same
|
||||
/// fields preflight reads; a missing one is a clear error, never a silent
|
||||
/// loopback fallback. Returns the applied changes for the UI to surface.
|
||||
pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
|
||||
let server = cfg.openfut_server_host.trim();
|
||||
if server.is_empty() {
|
||||
anyhow::bail!("Set the OpenFUT server host in the Config tab before arming.");
|
||||
}
|
||||
let ea_ip = cfg.ea_redirect_probe_ip.trim();
|
||||
if ea_ip.is_empty() {
|
||||
anyhow::bail!("Set the EA redirector IP (Config tab) before arming.");
|
||||
}
|
||||
if cfg.ea_hostnames.is_empty() {
|
||||
anyhow::bail!("Add at least one EA hostname (e.g. easw.easports.com) in the Config tab before arming.");
|
||||
}
|
||||
let redirector_port = cfg.openfut_blaze_redirector_port;
|
||||
let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?;
|
||||
crate::setup::run_elevated(&script)?;
|
||||
Ok(arming_summary(
|
||||
server,
|
||||
redirector_port,
|
||||
ea_ip,
|
||||
&cfg.ea_hostnames,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn script() -> String {
|
||||
arming_script(
|
||||
"10.10.0.120",
|
||||
42127,
|
||||
"159.153.51.20",
|
||||
&["easw.easports.com".to_string()],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sets_ptrace_scope_zero() {
|
||||
assert!(script().contains("sysctl -q kernel.yama.ptrace_scope=0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dnats_ea_ip_to_server_and_masquerades() {
|
||||
let s = script();
|
||||
assert!(s.contains(
|
||||
"iptables -t nat -A OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 10.10.0.120:42127"
|
||||
));
|
||||
assert!(s.contains(
|
||||
"iptables -t nat -A POSTROUTING -p tcp -d 10.10.0.120 --dport 42127 -j MASQUERADE"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dnat_is_delete_then_add_for_idempotence() {
|
||||
let s = script();
|
||||
// The delete loop precedes the add, so re-arming never stacks duplicates.
|
||||
let del = s.find("-D OUTPUT").unwrap();
|
||||
let add = s.find("-A OUTPUT").unwrap();
|
||||
assert!(del < add, "delete must run before add");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_shadowing_hosts_line_before_writing_ours() {
|
||||
let s = script();
|
||||
// Deletes any existing easw.easports.com line (foreign shadow included)…
|
||||
assert!(
|
||||
s.contains("sed -ri '/[[:space:]]easw\\.easports\\.com([[:space:]]|$)/d' /etc/hosts")
|
||||
);
|
||||
// …then appends the OpenFUT-tagged mapping to the server.
|
||||
assert!(s.contains(
|
||||
"printf '%s\\t%s\\t# openfut\\n' '10.10.0.120' 'easw.easports.com' >> /etc/hosts"
|
||||
));
|
||||
let del = s.find("sed -ri").unwrap();
|
||||
let add = s.find("printf").unwrap();
|
||||
assert!(del < add, "shadow removal must precede our line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_hostnames_each_get_a_mapping() {
|
||||
let s = arming_script(
|
||||
"10.10.0.120",
|
||||
42127,
|
||||
"159.153.51.20",
|
||||
&["easw.easports.com".into(), "utas.fut.ea.com".into()],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.contains("'easw.easports.com' >> /etc/hosts"));
|
||||
assert!(s.contains("'utas.fut.ea.com' >> /etc/hosts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_shell_metacharacters_in_config() {
|
||||
assert!(arming_script("10.0.0.1; rm -rf /", 42127, "159.153.51.20", &[]).is_err());
|
||||
assert!(arming_script("10.0.0.1", 42127, "$(evil)", &[]).is_err());
|
||||
assert!(
|
||||
arming_script("10.0.0.1", 42127, "159.153.51.20", &["a b`c`".into()]).is_err(),
|
||||
"a hostname with a backtick is rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_requires_server_ea_ip_and_hostname() {
|
||||
let mut c = LauncherConfig::default();
|
||||
assert!(arm(&c).unwrap_err().to_string().contains("server host"));
|
||||
c.openfut_server_host = "10.10.0.120".into();
|
||||
assert!(arm(&c)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("EA redirector IP"));
|
||||
c.ea_redirect_probe_ip = "159.153.51.20".into();
|
||||
assert!(arm(&c).unwrap_err().to_string().contains("EA hostname"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_lists_ptrace_dnat_and_each_host() {
|
||||
let s = arming_summary(
|
||||
"10.10.0.120",
|
||||
42127,
|
||||
"159.153.51.20",
|
||||
&["easw.easports.com".into(), "utas.fut.ea.com".into()],
|
||||
);
|
||||
assert!(s.iter().any(|l| l.contains("ptrace_scope = 0")));
|
||||
assert!(s
|
||||
.iter()
|
||||
.any(|l| l.contains("DNAT 159.153.51.20 -> 10.10.0.120:42127")));
|
||||
assert!(s
|
||||
.iter()
|
||||
.any(|l| l == "hosts: easw.easports.com -> 10.10.0.120"));
|
||||
assert!(s
|
||||
.iter()
|
||||
.any(|l| l == "hosts: utas.fut.ea.com -> 10.10.0.120"));
|
||||
}
|
||||
}
|
||||
+605
-18
@@ -1,4 +1,101 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One `dosdevices` entry to create inside the Wine prefix before launching.
|
||||
/// `link` is relative to the prefix (e.g. `dosdevices/w:`).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PrefixLink {
|
||||
pub link: String,
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
/// A DRM licence file the game refuses to start without, and the executable
|
||||
/// that recreates it. A crashed launch deletes the licence, so this is a
|
||||
/// per-launch precondition rather than a one-time setup step.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LicenseCheck {
|
||||
/// Absolute, or relative to the Wine prefix.
|
||||
pub path: String,
|
||||
/// Executable run through the profile's runner to regenerate it.
|
||||
pub generator: String,
|
||||
#[serde(default = "default_license_timeout")]
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
/// Everything needed to start one game, as data.
|
||||
///
|
||||
/// This is what keeps the launcher game-independent: FIFA 17's runner, prefix,
|
||||
/// executable, `w:` drive and licence id live here in the user's config, never
|
||||
/// in launcher code. An unconfigured profile means "fall back to
|
||||
/// `game_launch_command`", so upgrading cannot break a working setup.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GameProfile {
|
||||
/// Program that starts the game (e.g. `umu-run`). Empty = profile unused.
|
||||
#[serde(default)]
|
||||
pub runner: String,
|
||||
/// Argument passed to the runner (e.g. `FIFA17.exe`).
|
||||
#[serde(default)]
|
||||
pub executable: String,
|
||||
/// Working directory the runner is started from.
|
||||
#[serde(default)]
|
||||
pub game_dir: String,
|
||||
/// `WINEPREFIX` for the game. Exported automatically when set.
|
||||
#[serde(default)]
|
||||
pub wine_prefix: String,
|
||||
/// Extra environment for the runner (`GAMEID`, `PROTONPATH`, …).
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub prefix_links: Vec<PrefixLink>,
|
||||
#[serde(default)]
|
||||
pub license: Option<LicenseCheck>,
|
||||
}
|
||||
|
||||
impl GameProfile {
|
||||
/// Whether this profile is filled in enough to launch from.
|
||||
pub fn configured(&self) -> bool {
|
||||
!self.runner.trim().is_empty()
|
||||
&& !self.executable.trim().is_empty()
|
||||
&& !self.game_dir.trim().is_empty()
|
||||
}
|
||||
|
||||
/// Reject a half-filled profile rather than launching something surprising.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.runner.trim().is_empty() {
|
||||
return Err("Game profile has no runner (e.g. umu-run).".into());
|
||||
}
|
||||
if self.executable.trim().is_empty() {
|
||||
return Err("Game profile has no executable.".into());
|
||||
}
|
||||
if self.game_dir.trim().is_empty() {
|
||||
return Err("Game profile has no game directory.".into());
|
||||
}
|
||||
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
|
||||
return Err("Game profile defines prefix links but no wine_prefix.".into());
|
||||
}
|
||||
for l in &self.prefix_links {
|
||||
if l.link.trim().is_empty() || l.target.trim().is_empty() {
|
||||
return Err("Game profile has a prefix link with an empty link or target.".into());
|
||||
}
|
||||
if std::path::Path::new(&l.link).is_absolute() {
|
||||
return Err(format!(
|
||||
"Prefix link {:?} must be relative to the Wine prefix.",
|
||||
l.link
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(lic) = &self.license {
|
||||
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
|
||||
return Err("Game profile licence needs both a path and a generator.".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_license_timeout() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LauncherConfig {
|
||||
@@ -15,8 +112,104 @@ pub struct LauncherConfig {
|
||||
pub hook_dll_path: String,
|
||||
/// FIFA 23 game folder inside the Proton prefix (where the DLL is deployed).
|
||||
pub fifa_game_dir: String,
|
||||
/// IP the hook DLL redirects EA hostnames to (written to openfut.cfg).
|
||||
pub hook_redirect_ip: String,
|
||||
/// The OpenFUT server FIFA's EA traffic is redirected to. IPv4 literal or
|
||||
/// hostname. Empty means "not configured" — launching is blocked until set.
|
||||
/// There is intentionally NO loopback default.
|
||||
#[serde(default, alias = "hook_redirect_ip")]
|
||||
pub openfut_server_host: String,
|
||||
/// OpenFUT destination port for intercepted EA :443 (bridge HTTPS).
|
||||
#[serde(default = "default_https_port")]
|
||||
pub openfut_https_port: u16,
|
||||
/// OpenFUT destination port for intercepted EA :10041 (Blaze redirector).
|
||||
#[serde(default = "default_blaze_redirector_port")]
|
||||
pub openfut_blaze_redirector_port: u16,
|
||||
/// OpenFUT destination port for intercepted EA :42127 (Blaze main).
|
||||
#[serde(default = "default_blaze_main_port")]
|
||||
pub openfut_blaze_main_port: u16,
|
||||
/// Plain HTTP UTAS/control-plane port used to select the active account.
|
||||
#[serde(default = "default_account_sync_port")]
|
||||
pub openfut_account_sync_port: u16,
|
||||
/// EA/Origin persona selected for this local single-player profile.
|
||||
#[serde(default)]
|
||||
pub fut_persona_id: u64,
|
||||
#[serde(default)]
|
||||
pub fut_persona_name: String,
|
||||
/// EASFC/POW account-bar state (separate from FUT club coins).
|
||||
#[serde(default = "default_account_level")]
|
||||
pub fut_account_level: u32,
|
||||
#[serde(default)]
|
||||
pub fut_account_experience: u32,
|
||||
#[serde(default = "default_account_experience_max")]
|
||||
pub fut_account_experience_max: u32,
|
||||
#[serde(default)]
|
||||
pub fut_account_funds: u32,
|
||||
#[serde(default = "default_account_funds_cap")]
|
||||
pub fut_account_funds_cap: u32,
|
||||
/// Shell command the launcher runs to start the game. Run via `sh -c`, from
|
||||
/// `game_launch_workdir` if set. Empty means "not configured" — the Launch
|
||||
/// Game button is disabled until the user provides one. This keeps the
|
||||
/// launcher agnostic to Steam vs umu-run vs a custom script.
|
||||
#[serde(default)]
|
||||
pub game_launch_command: String,
|
||||
/// Optional working directory for `game_launch_command`. Empty = inherit.
|
||||
#[serde(default)]
|
||||
pub game_launch_workdir: String,
|
||||
/// Native launch definition. When [`GameProfile::configured`], the launcher
|
||||
/// starts the game itself and `game_launch_command` is not used; the command
|
||||
/// remains as a fallback so an existing setup keeps working after upgrade.
|
||||
#[serde(default)]
|
||||
pub game_profile: GameProfile,
|
||||
|
||||
// ── Pre-launch checks (see `preflight`) ─────────────────────────────────
|
||||
/// EA's hardcoded redirector IP, probed to confirm the client-side DNAT is
|
||||
/// armed. Empty = the check is skipped. A game fact, so it is configuration.
|
||||
#[serde(default)]
|
||||
pub ea_redirect_probe_ip: String,
|
||||
/// Dead EA hostnames that must resolve to `openfut_server_host`.
|
||||
#[serde(default)]
|
||||
pub ea_hostnames: Vec<String>,
|
||||
|
||||
// ── FIFA 17 local companion services (client-side, run on THIS machine) ──
|
||||
// FIFA 17's FUT flow needs two pieces that are inherently local to the game
|
||||
// box and cannot move to the server: the LSX Origin emulator (the game dials
|
||||
// it on the hardcoded loopback 127.0.0.1:4216) and autopatch (patches
|
||||
// FIFA17.exe process memory for ProtoSSL cert-verify). The launcher manages
|
||||
// both as child processes. The heavy responders (Blaze/UTAS/roster/POW) run
|
||||
// in the server container; these two stay here.
|
||||
/// Directory holding the FIFA 17 Python responders (fifa17-recon `tools/`).
|
||||
/// Empty means the local-services feature is unconfigured and its controls
|
||||
/// stay disabled.
|
||||
#[serde(default)]
|
||||
pub fifa17_tools_dir: String,
|
||||
/// Python interpreter used to run the local companion services.
|
||||
#[serde(default = "default_python")]
|
||||
pub fifa17_python: String,
|
||||
}
|
||||
|
||||
fn default_python() -> String {
|
||||
"python3".to_string()
|
||||
}
|
||||
|
||||
fn default_https_port() -> u16 {
|
||||
openfut_common::default_ports::HTTPS
|
||||
}
|
||||
fn default_blaze_redirector_port() -> u16 {
|
||||
openfut_common::default_ports::BLAZE_REDIRECTOR
|
||||
}
|
||||
fn default_blaze_main_port() -> u16 {
|
||||
openfut_common::default_ports::BLAZE_MAIN
|
||||
}
|
||||
fn default_account_sync_port() -> u16 {
|
||||
8099
|
||||
}
|
||||
fn default_account_level() -> u32 {
|
||||
1
|
||||
}
|
||||
fn default_account_experience_max() -> u32 {
|
||||
1000
|
||||
}
|
||||
fn default_account_funds_cap() -> u32 {
|
||||
100_000
|
||||
}
|
||||
|
||||
impl Default for LauncherConfig {
|
||||
@@ -57,7 +250,32 @@ impl Default for LauncherConfig {
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
hook_redirect_ip: "127.0.0.1".into(),
|
||||
// No server configured by default — the user MUST enter one. There
|
||||
// is deliberately no loopback/localhost default.
|
||||
openfut_server_host: String::new(),
|
||||
openfut_https_port: default_https_port(),
|
||||
openfut_blaze_redirector_port: default_blaze_redirector_port(),
|
||||
openfut_blaze_main_port: default_blaze_main_port(),
|
||||
openfut_account_sync_port: default_account_sync_port(),
|
||||
fut_persona_id: 0,
|
||||
fut_persona_name: String::new(),
|
||||
fut_account_level: default_account_level(),
|
||||
fut_account_experience: 0,
|
||||
fut_account_experience_max: default_account_experience_max(),
|
||||
fut_account_funds: 0,
|
||||
fut_account_funds_cap: default_account_funds_cap(),
|
||||
game_launch_command: String::new(),
|
||||
game_launch_workdir: String::new(),
|
||||
// Empty by default, exactly like the server host: the launcher must
|
||||
// never invent a path to somebody's game install.
|
||||
game_profile: GameProfile::default(),
|
||||
ea_redirect_probe_ip: String::new(),
|
||||
ea_hostnames: Vec::new(),
|
||||
fifa17_tools_dir: base
|
||||
.join("fifa17-recon/tools")
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
fifa17_python: default_python(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,22 +306,391 @@ impl LauncherConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn core_env(&self) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("DATABASE_URL".into(), self.core_database_url.clone()),
|
||||
("DATA_DIR".into(), self.core_data_dir.clone()),
|
||||
("LISTEN_ADDR".into(), self.core_listen_addr.clone()),
|
||||
("RUST_LOG".into(), "openfut_core=info,tower_http=info".into()),
|
||||
]
|
||||
/// The (host, port) the health monitor should poll, or None when no server
|
||||
/// is configured. Uses the bridge HTTPS port — the port the FIFA client
|
||||
/// actually connects to — so "reachable" means what the game will see.
|
||||
pub fn health_target(&self) -> Option<(String, u16)> {
|
||||
let host = self.openfut_server_host.trim();
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((host.to_string(), self.openfut_https_port))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bridge_env(&self) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("CORE_URL".into(), self.bridge_core_url.clone()),
|
||||
("LISTEN_ADDR".into(), self.bridge_listen_addr.clone()),
|
||||
("CAPTURES_DIR".into(), self.bridge_captures_dir.clone()),
|
||||
("TLS_ENABLED".into(), self.bridge_tls_enabled.to_string()),
|
||||
("RUST_LOG".into(), "openfut_bridge=info".into()),
|
||||
]
|
||||
/// Build the shared [`ServerConfig`] from the launcher's configured server
|
||||
/// host + destination ports. This is the single place the launcher turns UI
|
||||
/// fields into the canonical config consumed by the hook.
|
||||
pub fn server_config(&self) -> openfut_common::ServerConfig {
|
||||
openfut_common::ServerConfig {
|
||||
host: self.openfut_server_host.trim().to_string(),
|
||||
ports: openfut_common::OpenFutPorts {
|
||||
https: self.openfut_https_port,
|
||||
blaze_redirector: self.openfut_blaze_redirector_port,
|
||||
blaze_main: self.openfut_blaze_main_port,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the configured server (syntax only, no DNS). Returns the same
|
||||
/// user-facing message the task specifies when nothing is configured.
|
||||
pub fn validate_server(&self) -> Result<(), String> {
|
||||
if self.openfut_server_host.trim().is_empty() {
|
||||
return Err("No OpenFUT server configured. Please enter the hostname \
|
||||
or IP address of your OpenFUT server."
|
||||
.to_string());
|
||||
}
|
||||
self.server_config().validate().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Validate the client-local FIFA 17 service configuration. Filesystem
|
||||
/// existence is checked by the process launcher immediately before spawn;
|
||||
/// this ensures required user configuration is never silently invented.
|
||||
pub fn validate_local_services(&self) -> Result<(), String> {
|
||||
if self.fifa17_tools_dir.trim().is_empty() {
|
||||
return Err("No FIFA 17 tools dir configured. Set it in the Config tab.".into());
|
||||
}
|
||||
if self.fifa17_python.trim().is_empty() {
|
||||
return Err("No Python interpreter configured. Set it in the Config tab.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate every configuration value required by the one-button FIFA 17
|
||||
/// launch path. Runtime state such as hook deployment is checked by the UI.
|
||||
pub fn validate_launch_config(&self) -> Result<(), String> {
|
||||
self.validate_server()?;
|
||||
self.validate_account()?;
|
||||
// Either launch route is acceptable, but a half-filled profile is not:
|
||||
// silently falling back to the shell command would hide the mistake, so
|
||||
// ANY profile that has been touched must be complete.
|
||||
if self.game_profile != GameProfile::default() {
|
||||
self.game_profile.validate()?;
|
||||
} else if self.game_launch_command.trim().is_empty() {
|
||||
return Err(
|
||||
"No game configured. Fill in the game profile, or set a launch command, \
|
||||
in the Config tab."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
self.validate_local_services()
|
||||
}
|
||||
|
||||
pub fn validate_account(&self) -> Result<(), String> {
|
||||
if self.fut_persona_id == 0 {
|
||||
return Err("No EA persona ID configured. Set the account in the Config tab.".into());
|
||||
}
|
||||
if self.fut_persona_name.trim().is_empty() {
|
||||
return Err("No EA persona name configured. Set the account in the Config tab.".into());
|
||||
}
|
||||
if self.fut_account_level == 0 {
|
||||
return Err("EA account level must be at least 1.".into());
|
||||
}
|
||||
if self.fut_account_experience_max == 0
|
||||
|| self.fut_account_experience > self.fut_account_experience_max
|
||||
{
|
||||
return Err("EA account XP must not exceed a nonzero XP maximum.".into());
|
||||
}
|
||||
if self.fut_account_funds > self.fut_account_funds_cap {
|
||||
return Err("EA account funds must not exceed the funds cap.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The exact `openfut.cfg` bytes to write for the hook, or an error if the
|
||||
/// server isn't validly configured (never emits a loopback fallback).
|
||||
///
|
||||
/// The deployed FIFA 17 hook reads this structured format through the same
|
||||
/// shared parser, so changing a destination port never requires recompiling
|
||||
/// the DLL. Fixed EA source ports remain protocol signatures in the hook.
|
||||
pub fn hook_cfg_contents(&self) -> Result<String, String> {
|
||||
self.validate_server()?;
|
||||
Ok(self.server_config().to_cfg_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_has_no_server_and_blocks_launch() {
|
||||
let c = LauncherConfig::default();
|
||||
assert!(c.openfut_server_host.is_empty());
|
||||
let err = c.validate_server().unwrap_err();
|
||||
assert!(err.contains("No OpenFUT server configured"));
|
||||
assert!(c.hook_cfg_contents().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_server_roundtrips_into_hook_cfg() {
|
||||
let c = LauncherConfig {
|
||||
openfut_server_host: "192.168.1.50".into(),
|
||||
openfut_https_port: 9443,
|
||||
openfut_blaze_redirector_port: 43127,
|
||||
openfut_blaze_main_port: 43130,
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
let cfg = c
|
||||
.hook_cfg_contents()
|
||||
.expect("valid server should produce cfg");
|
||||
let parsed = openfut_common::ServerConfig::parse(&cfg).unwrap();
|
||||
assert_eq!(parsed.host, "192.168.1.50");
|
||||
assert_eq!(parsed.ports, c.server_config().ports);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_server_changes_hook_cfg_no_rebuild() {
|
||||
// Models the Server A -> Server B acceptance test at the config layer:
|
||||
// only the value changes; the same code path produces the new cfg.
|
||||
let mut c = LauncherConfig {
|
||||
openfut_server_host: "10.0.0.1".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
let a = c.hook_cfg_contents().unwrap();
|
||||
c.openfut_server_host = "10.0.0.2".into();
|
||||
let b = c.hook_cfg_contents().unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(
|
||||
openfut_common::ServerConfig::parse(&b).unwrap().host,
|
||||
"10.0.0.2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_target_none_until_configured() {
|
||||
let mut c = LauncherConfig::default();
|
||||
assert!(c.health_target().is_none());
|
||||
c.openfut_server_host = "10.10.0.120".into();
|
||||
let (host, port) = c.health_target().expect("configured host yields a target");
|
||||
assert_eq!(host, "10.10.0.120");
|
||||
assert_eq!(port, c.openfut_https_port);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_hook_redirect_ip_field_is_read() {
|
||||
// Old configs stored the address under `hook_redirect_ip`; serde alias
|
||||
// must map it onto the new field so upgrades keep working.
|
||||
let json = r#"{
|
||||
"core_binary":"","bridge_binary":"","core_database_url":"",
|
||||
"core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"",
|
||||
"bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true,
|
||||
"hook_dll_path":"","fifa_game_dir":"","hook_redirect_ip":"192.168.5.5"
|
||||
}"#;
|
||||
let c: LauncherConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(c.openfut_server_host, "192.168.5.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_services_require_tools_dir_and_python() {
|
||||
let mut c = LauncherConfig::default();
|
||||
c.fifa17_tools_dir.clear();
|
||||
assert!(c
|
||||
.validate_local_services()
|
||||
.unwrap_err()
|
||||
.contains("tools dir"));
|
||||
|
||||
c.fifa17_tools_dir = "/tmp/fifa17-tools".into();
|
||||
c.fifa17_python.clear();
|
||||
assert!(c.validate_local_services().unwrap_err().contains("Python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_services_accept_explicit_configuration() {
|
||||
let c = LauncherConfig {
|
||||
fifa17_tools_dir: "/tmp/fifa17-tools".into(),
|
||||
fifa17_python: "/usr/bin/python3".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
assert!(c.validate_local_services().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_config_requires_server_local_services_and_command() {
|
||||
let mut c = LauncherConfig::default();
|
||||
assert!(c.validate_launch_config().is_err());
|
||||
|
||||
c.openfut_server_host = "10.10.0.120".into();
|
||||
c.fut_persona_id = 12345678;
|
||||
c.fut_persona_name = "TEST_USER".into();
|
||||
assert!(c
|
||||
.validate_launch_config()
|
||||
.unwrap_err()
|
||||
.contains("launch command"));
|
||||
|
||||
c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into();
|
||||
c.fifa17_tools_dir.clear();
|
||||
assert!(c
|
||||
.validate_launch_config()
|
||||
.unwrap_err()
|
||||
.contains("tools dir"));
|
||||
|
||||
c.fifa17_tools_dir = "/home/alex/Documents/OpenFUT/fifa17-recon/tools".into();
|
||||
c.fifa17_python = "/usr/bin/python3".into();
|
||||
assert!(c.validate_launch_config().is_ok());
|
||||
}
|
||||
|
||||
/// An old config.json has no `game_profile` key at all. It must keep
|
||||
/// launching exactly as before rather than failing to parse or silently
|
||||
/// switching route.
|
||||
#[test]
|
||||
fn a_config_without_a_game_profile_still_uses_the_shell_command() {
|
||||
let json = r#"{
|
||||
"core_binary":"","bridge_binary":"","core_database_url":"",
|
||||
"core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"",
|
||||
"bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true,
|
||||
"hook_dll_path":"","fifa_game_dir":"","openfut_server_host":"10.0.0.1",
|
||||
"game_launch_command":"/home/u/launch.sh"
|
||||
}"#;
|
||||
let c: LauncherConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!c.game_profile.configured());
|
||||
assert_eq!(c.game_profile, GameProfile::default());
|
||||
assert!(c.ea_hostnames.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_profile_satisfies_launch_without_a_shell_command() {
|
||||
let mut c = LauncherConfig {
|
||||
openfut_server_host: "10.10.0.120".into(),
|
||||
fut_persona_id: 1,
|
||||
fut_persona_name: "X".into(),
|
||||
fifa17_tools_dir: "/tmp/tools".into(),
|
||||
fifa17_python: "/usr/bin/python3".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
c.game_launch_command.clear();
|
||||
assert!(
|
||||
c.validate_launch_config().is_err(),
|
||||
"neither route configured"
|
||||
);
|
||||
|
||||
c.game_profile = GameProfile {
|
||||
runner: "umu-run".into(),
|
||||
executable: "FIFA17.exe".into(),
|
||||
game_dir: "/mnt/games/FIFA 17".into(),
|
||||
..GameProfile::default()
|
||||
};
|
||||
assert!(c.game_profile.configured());
|
||||
assert!(c.validate_launch_config().is_ok());
|
||||
}
|
||||
|
||||
/// The trap this guards: a profile filled in halfway would fail
|
||||
/// `configured()` and quietly fall through to the shell command, so the user
|
||||
/// edits the profile and nothing they change has any effect.
|
||||
#[test]
|
||||
fn a_half_filled_profile_is_an_error_not_a_silent_fallback() {
|
||||
let mut c = LauncherConfig {
|
||||
openfut_server_host: "10.10.0.120".into(),
|
||||
fut_persona_id: 1,
|
||||
fut_persona_name: "X".into(),
|
||||
fifa17_tools_dir: "/tmp/tools".into(),
|
||||
fifa17_python: "/usr/bin/python3".into(),
|
||||
game_launch_command: "/home/u/launch.sh".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
c.game_profile.runner = "umu-run".into(); // and nothing else
|
||||
let err = c.validate_launch_config().unwrap_err();
|
||||
assert!(err.contains("executable"), "{err}");
|
||||
}
|
||||
|
||||
/// The exact profile block deployed to the FIFA 17 machine.
|
||||
///
|
||||
/// `load()` swallows a parse error and returns `Default` — so a config this
|
||||
/// binary cannot read would not produce an error, it would silently discard
|
||||
/// the user's persona, server and ports. That makes "the shipped config
|
||||
/// actually deserializes" a property worth asserting, not assuming.
|
||||
#[test]
|
||||
fn the_deployed_fifa17_profile_parses_exactly() {
|
||||
let json = r#"{
|
||||
"core_binary":"","bridge_binary":"","core_database_url":"",
|
||||
"core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"",
|
||||
"bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true,
|
||||
"hook_dll_path":"","fifa_game_dir":"",
|
||||
"openfut_server_host":"10.10.0.120",
|
||||
"ea_hostnames":["easw.easports.com"],
|
||||
"ea_redirect_probe_ip":"159.153.51.20",
|
||||
"game_profile":{
|
||||
"env":{"GAMEID":"fifa17","PROTONPATH":"UMU-Proton-10.0-4","STEAM_COMPAT_CONFIG":"sdlinput"},
|
||||
"executable":"FIFA17.exe",
|
||||
"game_dir":"/mnt/games/FIFA 17",
|
||||
"license":{
|
||||
"generator":"_fifa17.exe",
|
||||
"path":"drive_c/ProgramData/Electronic Arts/EA Services/License/1027460.dlf",
|
||||
"timeout_secs":60
|
||||
},
|
||||
"prefix_links":[{"link":"dosdevices/w:","target":"/mnt"}],
|
||||
"runner":"umu-run",
|
||||
"wine_prefix":"/home/alex/Games/umu/fifa17"
|
||||
}
|
||||
}"#;
|
||||
let c: LauncherConfig = serde_json::from_str(json).expect("deployed config must parse");
|
||||
let p = &c.game_profile;
|
||||
assert!(p.configured());
|
||||
assert!(p.validate().is_ok());
|
||||
assert_eq!(p.runner, "umu-run");
|
||||
assert_eq!(
|
||||
p.env.get("STEAM_COMPAT_CONFIG").map(String::as_str),
|
||||
Some("sdlinput")
|
||||
);
|
||||
assert_eq!(p.prefix_links.len(), 1);
|
||||
let lic = p.license.as_ref().expect("licence block");
|
||||
assert_eq!(lic.timeout_secs, 60);
|
||||
assert!(lic.path.ends_with("1027460.dlf"));
|
||||
assert_eq!(c.ea_redirect_probe_ip, "159.153.51.20");
|
||||
}
|
||||
|
||||
/// `configured()` alone decides which launch route runs, so it is pinned
|
||||
/// directly rather than only through `validate_launch_config`. All three
|
||||
/// fields are required: a profile missing any of them cannot start a game.
|
||||
#[test]
|
||||
fn configured_requires_runner_executable_and_dir() {
|
||||
let mut p = GameProfile::default();
|
||||
assert!(!p.configured());
|
||||
p.runner = "umu-run".into();
|
||||
assert!(!p.configured(), "runner alone is not launchable");
|
||||
p.executable = "G.exe".into();
|
||||
assert!(!p.configured(), "no game_dir is not launchable");
|
||||
p.game_dir = "/games/G".into();
|
||||
assert!(p.configured());
|
||||
// Whitespace is not configuration.
|
||||
p.executable = " ".into();
|
||||
assert!(!p.configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_links_must_be_relative_and_have_a_prefix() {
|
||||
let mut p = GameProfile {
|
||||
runner: "umu-run".into(),
|
||||
executable: "G.exe".into(),
|
||||
game_dir: "/games/G".into(),
|
||||
prefix_links: vec![PrefixLink {
|
||||
link: "dosdevices/w:".into(),
|
||||
target: "/mnt".into(),
|
||||
}],
|
||||
..GameProfile::default()
|
||||
};
|
||||
assert!(
|
||||
p.validate().unwrap_err().contains("wine_prefix"),
|
||||
"links without a prefix have nowhere to go"
|
||||
);
|
||||
|
||||
p.wine_prefix = "/prefix".into();
|
||||
assert!(p.validate().is_ok());
|
||||
|
||||
// An absolute link would be created outside the prefix entirely.
|
||||
p.prefix_links[0].link = "/etc/w:".into();
|
||||
assert!(p.validate().unwrap_err().contains("relative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_requires_a_valid_ea_account() {
|
||||
let mut c = LauncherConfig::default();
|
||||
assert!(c.validate_account().unwrap_err().contains("persona ID"));
|
||||
c.fut_persona_id = 12345678;
|
||||
assert!(c.validate_account().unwrap_err().contains("persona name"));
|
||||
c.fut_persona_name = "TEST_USER".into();
|
||||
assert!(c.validate_account().is_ok());
|
||||
c.fut_account_experience = 1001;
|
||||
assert!(c.validate_account().unwrap_err().contains("XP"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Launch the game directly, without an external shell script.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! The launcher used to shell out to a user-written script (`game_launch_command`)
|
||||
//! that set the Proton environment, prepared the Wine prefix, regenerated the
|
||||
//! DRM licence and finally ran the game. That script lived on the user's Desktop
|
||||
//! — and on 2026-08-11 it was moved to the Trash, after which every launch failed
|
||||
//! with `sh: No such file or directory`. Three unrelated client-side faults that
|
||||
//! morning each looked like "the game crashed"; none of them were.
|
||||
//!
|
||||
//! Everything the script did is mechanical and belongs inside the launcher, where
|
||||
//! it cannot be deleted, is covered by tests, and reports failures into the same
|
||||
//! log buffer as the rest of the launch.
|
||||
//!
|
||||
//! # What stays out of this file
|
||||
//!
|
||||
//! Every FIFA-17 fact — the runner, the executable, the prefix path, the `w:`
|
||||
//! drive symlink, the licence file id — is [`GameProfile`] *data*, not code.
|
||||
//! OpenFUT is not a FIFA 17 project; FIFA 17 is its first reference target. A
|
||||
//! second game must be a different profile, never a second branch in here.
|
||||
//!
|
||||
//! `game_launch_command` remains as an escape hatch: an unconfigured profile
|
||||
//! falls back to it, so an existing working setup cannot be broken by upgrading.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::config::GameProfile;
|
||||
use crate::logs::LogBuffer;
|
||||
|
||||
type Log = Arc<Mutex<LogBuffer>>;
|
||||
|
||||
fn say(log: &Log, msg: impl Into<String>) {
|
||||
log.lock().unwrap().push(msg.into());
|
||||
}
|
||||
|
||||
/// Prepare the prefix, satisfy the licence precondition, and start the game.
|
||||
///
|
||||
/// Returns once the game process has been spawned; its output continues to
|
||||
/// stream into `log` on background threads.
|
||||
pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
profile.validate().map_err(anyhow::Error::msg)?;
|
||||
|
||||
let game_dir = PathBuf::from(&profile.game_dir);
|
||||
if !game_dir.is_dir() {
|
||||
anyhow::bail!("game_dir does not exist: {}", game_dir.display());
|
||||
}
|
||||
|
||||
prepare_prefix(profile, log)?;
|
||||
ensure_license(profile, log)?;
|
||||
|
||||
let mut cmd = Command::new(&profile.runner);
|
||||
cmd.arg(&profile.executable)
|
||||
.current_dir(&game_dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
for (k, v) in &profile.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
if !profile.wine_prefix.trim().is_empty() {
|
||||
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
||||
}
|
||||
|
||||
say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] launching {} {} (cwd {})",
|
||||
profile.runner,
|
||||
profile.executable,
|
||||
game_dir.display()
|
||||
),
|
||||
);
|
||||
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
|
||||
stream(child, log.clone(), "[launcher] game process exited.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the Wine prefix's `dosdevices` entries the profile asks for.
|
||||
///
|
||||
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
|
||||
/// an existing link is replaced, so re-running is harmless.
|
||||
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let prefix = PathBuf::from(&profile.wine_prefix);
|
||||
for link in &profile.prefix_links {
|
||||
let path = prefix.join(&link.link);
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("prefix link has no parent: {}", link.link))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
// Replace rather than fail: `ln -sfn` semantics. Only ever remove a
|
||||
// symlink — refusing on a real file avoids destroying prefix contents
|
||||
// if a profile is misconfigured.
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_file(&path)?,
|
||||
Ok(_) => anyhow::bail!(
|
||||
"refusing to replace {}: it exists and is not a symlink",
|
||||
path.display()
|
||||
),
|
||||
Err(_) => {}
|
||||
}
|
||||
std::os::unix::fs::symlink(&link.target, &path)?;
|
||||
say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] prefix link {} -> {}",
|
||||
path.display(),
|
||||
link.target
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make sure the DRM licence file exists, running the generator if it does not.
|
||||
///
|
||||
/// A crashed or failed launch deletes the licence, so this runs before every
|
||||
/// launch rather than only on first setup — that is the behaviour the shell
|
||||
/// script proved, and it is why a crash is normally self-healing on the next try.
|
||||
fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
let Some(lic) = &profile.license else {
|
||||
return Ok(());
|
||||
};
|
||||
let path = resolve_under_prefix(&profile.wine_prefix, &lic.path);
|
||||
if non_empty_file(&path) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] licence missing ({}) — running {} to regenerate it",
|
||||
path.display(),
|
||||
lic.generator
|
||||
),
|
||||
);
|
||||
|
||||
let mut cmd = Command::new(&profile.runner);
|
||||
cmd.arg(&lic.generator)
|
||||
.current_dir(&profile.game_dir)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
for (k, v) in &profile.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
if !profile.wine_prefix.trim().is_empty() {
|
||||
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
||||
}
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("could not start licence generator: {e}"))?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(lic.timeout_secs.max(1));
|
||||
while Instant::now() < deadline {
|
||||
if non_empty_file(&path) {
|
||||
stop_generator(&mut child, lic, log);
|
||||
say(log, "[launcher] licence regenerated.");
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
stop_generator(&mut child, lic, log);
|
||||
anyhow::bail!(
|
||||
"{} did not create {} within {}s. Run it manually, choose GENERATE, then launch again.",
|
||||
lic.generator,
|
||||
path.display(),
|
||||
lic.timeout_secs
|
||||
)
|
||||
}
|
||||
|
||||
/// Stop the licence generator and the Windows process it started.
|
||||
///
|
||||
/// Killing the runner is not enough: it launches the executable through Proton,
|
||||
/// so the `.exe` outlives its parent. The shell script used `pkill -f` for this
|
||||
/// and it is reproduced deliberately — the pattern is a Windows executable name,
|
||||
/// which cannot match the launcher or a shell running it. (A `pkill -f` pattern
|
||||
/// that *can* match its own caller is a real hazard; this one cannot.)
|
||||
fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
match Command::new("pkill").arg("-f").arg(&lic.generator).status() {
|
||||
Ok(_) => {}
|
||||
Err(e) => say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] note: could not run pkill for {}: {e}",
|
||||
lic.generator
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A relative licence path is taken as relative to the Wine prefix; an absolute
|
||||
/// one is used as given.
|
||||
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() || prefix.trim().is_empty() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
Path::new(prefix).join(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// The script's `[[ -s FILE ]]`: present *and* non-empty. A zero-byte licence is
|
||||
/// as useless as a missing one, and treating it as valid would skip the
|
||||
/// regeneration that fixes it.
|
||||
fn non_empty_file(path: &Path) -> bool {
|
||||
std::fs::metadata(path)
|
||||
.map(|m| m.len() > 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Pump a child's stdout and stderr into the log buffer and reap it.
|
||||
pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let buf = Arc::clone(&log);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
let buf = Arc::clone(&log);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
log.lock().unwrap().push(exit_msg.to_string());
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{LicenseCheck, PrefixLink};
|
||||
|
||||
fn log() -> Log {
|
||||
Arc::new(Mutex::new(LogBuffer::new()))
|
||||
}
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let d =
|
||||
std::env::temp_dir().join(format!("openfut-launch-test-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_licence_path_is_resolved_under_the_prefix() {
|
||||
assert_eq!(
|
||||
resolve_under_prefix("/p", "drive_c/lic.dlf"),
|
||||
PathBuf::from("/p/drive_c/lic.dlf")
|
||||
);
|
||||
// Absolute wins, so a profile can point outside the prefix.
|
||||
assert_eq!(
|
||||
resolve_under_prefix("/p", "/elsewhere/lic.dlf"),
|
||||
PathBuf::from("/elsewhere/lic.dlf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_byte_licence_does_not_count_as_present() {
|
||||
let d = tmpdir("empty-lic");
|
||||
let f = d.join("lic.dlf");
|
||||
std::fs::write(&f, b"").unwrap();
|
||||
assert!(
|
||||
!non_empty_file(&f),
|
||||
"an empty licence must trigger regeneration"
|
||||
);
|
||||
std::fs::write(&f, b"x").unwrap();
|
||||
assert!(non_empty_file(&f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_links_are_created_and_are_idempotent() {
|
||||
let d = tmpdir("links");
|
||||
let prefix = d.join("prefix");
|
||||
let target = d.join("target");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "true".into(),
|
||||
executable: "x.exe".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: prefix.to_string_lossy().into(),
|
||||
prefix_links: vec![PrefixLink {
|
||||
link: "dosdevices/w:".into(),
|
||||
target: target.to_string_lossy().into(),
|
||||
}],
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
prepare_prefix(&profile, &log()).expect("first run creates the link");
|
||||
let link = prefix.join("dosdevices/w:");
|
||||
assert!(std::fs::symlink_metadata(&link)
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink());
|
||||
|
||||
// Re-running must not fail — the launcher prepares the prefix on EVERY
|
||||
// launch, so a second launch would break if this were not idempotent.
|
||||
prepare_prefix(&profile, &log()).expect("second run replaces the link");
|
||||
assert_eq!(std::fs::read_link(&link).unwrap(), target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_file_where_a_link_belongs_is_refused_not_deleted() {
|
||||
let d = tmpdir("clobber");
|
||||
let prefix = d.join("prefix");
|
||||
std::fs::create_dir_all(prefix.join("dosdevices")).unwrap();
|
||||
let occupied = prefix.join("dosdevices/w:");
|
||||
std::fs::write(&occupied, b"important").unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "true".into(),
|
||||
executable: "x.exe".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: prefix.to_string_lossy().into(),
|
||||
prefix_links: vec![PrefixLink {
|
||||
link: "dosdevices/w:".into(),
|
||||
target: "/tmp".into(),
|
||||
}],
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
assert!(prepare_prefix(&profile, &log()).is_err());
|
||||
assert_eq!(
|
||||
std::fs::read(&occupied).unwrap(),
|
||||
b"important",
|
||||
"a misconfigured profile must not destroy prefix contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_present_licence_skips_the_generator_entirely() {
|
||||
let d = tmpdir("lic-present");
|
||||
let lic = d.join("lic.dlf");
|
||||
std::fs::write(&lic, b"valid").unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "/nonexistent/runner".into(), // would fail if it were run
|
||||
executable: "x.exe".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: d.to_string_lossy().into(),
|
||||
license: Some(LicenseCheck {
|
||||
path: "lic.dlf".into(),
|
||||
generator: "_gen.exe".into(),
|
||||
timeout_secs: 1,
|
||||
}),
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
// Proves the skip: the runner path is invalid, so reaching the generator
|
||||
// would error. Ok() means it never tried.
|
||||
ensure_license(&profile, &log()).expect("present licence must short-circuit");
|
||||
}
|
||||
|
||||
/// The whole point of the licence step: a missing licence must actually run
|
||||
/// the generator and wait for it. Without this, deleting `ensure_license`
|
||||
/// entirely would still pass every other test in this file.
|
||||
#[test]
|
||||
fn a_missing_licence_runs_the_generator_and_waits_for_it() {
|
||||
let d = tmpdir("lic-regen");
|
||||
let lic = d.join("lic.dlf");
|
||||
let gen = d.join("gen.sh");
|
||||
// Sleeps first, so passing requires actually waiting rather than
|
||||
// happening to observe a file that was already there. The target path
|
||||
// is baked in: `generator` is passed as ONE argument, exactly as
|
||||
// `umu-run "_fifa17.exe"` is.
|
||||
std::fs::write(
|
||||
&gen,
|
||||
format!(
|
||||
"#!/bin/sh\nsleep 1\nprintf licensed > '{}'\n",
|
||||
lic.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "/bin/sh".into(),
|
||||
executable: "unused".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: d.to_string_lossy().into(),
|
||||
license: Some(LicenseCheck {
|
||||
generator: gen.to_string_lossy().into(),
|
||||
path: "lic.dlf".into(),
|
||||
timeout_secs: 10,
|
||||
}),
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
assert!(!non_empty_file(&lic));
|
||||
ensure_license(&profile, &log()).expect("generator should produce the licence");
|
||||
assert!(non_empty_file(&lic), "licence was not created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_generator_that_never_delivers_times_out_with_an_actionable_error() {
|
||||
let d = tmpdir("lic-timeout");
|
||||
let gen = d.join("gen.sh");
|
||||
std::fs::write(&gen, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
let profile = GameProfile {
|
||||
runner: "/bin/sh".into(),
|
||||
executable: "unused".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: d.to_string_lossy().into(),
|
||||
license: Some(LicenseCheck {
|
||||
generator: gen.to_string_lossy().into(), // runs, writes nothing
|
||||
path: "lic.dlf".into(),
|
||||
timeout_secs: 1,
|
||||
}),
|
||||
..GameProfile::default()
|
||||
};
|
||||
let err = ensure_license(&profile, &log()).unwrap_err().to_string();
|
||||
assert!(err.contains("did not create"), "{err}");
|
||||
assert!(
|
||||
err.contains("GENERATE"),
|
||||
"the error must say what to do: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_refuses_a_missing_game_dir_before_touching_anything() {
|
||||
let profile = GameProfile {
|
||||
runner: "true".into(),
|
||||
executable: "x.exe".into(),
|
||||
game_dir: "/definitely/not/here".into(),
|
||||
..GameProfile::default()
|
||||
};
|
||||
let err = launch(&profile, &log()).unwrap_err().to_string();
|
||||
assert!(err.contains("game_dir does not exist"), "{err}");
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
//! Read-only health monitoring of the (remote) OpenFUT server.
|
||||
//!
|
||||
//! The launcher no longer *controls* the servers — they run elsewhere (e.g. in
|
||||
//! Docker on the server host). This module polls the configured server in a
|
||||
//! background thread and exposes a snapshot the UI can render. It never starts,
|
||||
//! stops, or assumes anything about how the server is hosted; it only asks
|
||||
//! "can the FIFA client reach it right now?".
|
||||
|
||||
use std::{
|
||||
net::{TcpStream, ToSocketAddrs},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// A snapshot of the last health probe, rendered by the dashboard.
|
||||
#[derive(Clone)]
|
||||
pub struct HealthState {
|
||||
/// None = not yet checked / no target; Some(true/false) = reachable or not.
|
||||
pub reachable: Option<bool>,
|
||||
pub detail: String,
|
||||
pub last_checked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for HealthState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
reachable: None,
|
||||
detail: "No server configured.".into(),
|
||||
last_checked: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background poller. Holds a shared target (host, port) the UI can update when
|
||||
/// the user changes the server address, and a shared state the UI reads.
|
||||
pub struct HealthMonitor {
|
||||
pub state: Arc<Mutex<HealthState>>,
|
||||
target: Arc<Mutex<Option<(String, u16)>>>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl HealthMonitor {
|
||||
pub fn new() -> Self {
|
||||
let state = Arc::new(Mutex::new(HealthState::default()));
|
||||
let target: Arc<Mutex<Option<(String, u16)>>> = Arc::new(Mutex::new(None));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let t_state = Arc::clone(&state);
|
||||
let t_target = Arc::clone(&target);
|
||||
let t_running = Arc::clone(&running);
|
||||
thread::spawn(move || {
|
||||
while t_running.load(Ordering::Relaxed) {
|
||||
let target = t_target.lock().unwrap().clone();
|
||||
match target {
|
||||
None => {
|
||||
*t_state.lock().unwrap() = HealthState::default();
|
||||
}
|
||||
Some((host, port)) => {
|
||||
let snapshot = probe(&host, port);
|
||||
*t_state.lock().unwrap() = snapshot;
|
||||
}
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
state,
|
||||
target,
|
||||
running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the monitor at a new server address (host + bridge port). Passing
|
||||
/// None (e.g. no server configured) puts it back into the idle state.
|
||||
pub fn set_target(&self, target: Option<(String, u16)>) {
|
||||
*self.target.lock().unwrap() = target;
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> HealthState {
|
||||
self.state.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HealthMonitor {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single reachability probe: DNS-resolve host:port and attempt a bounded TCP
|
||||
/// connect. A successful connect proves the FIFA client can reach the bridge.
|
||||
fn probe(host: &str, port: u16) -> HealthState {
|
||||
let now = Some(Instant::now());
|
||||
let addrs = match (host, port).to_socket_addrs() {
|
||||
Ok(a) => a.collect::<Vec<_>>(),
|
||||
Err(e) => {
|
||||
return HealthState {
|
||||
reachable: Some(false),
|
||||
detail: format!("Cannot resolve {host}: {e}"),
|
||||
last_checked: now,
|
||||
};
|
||||
}
|
||||
};
|
||||
if addrs.is_empty() {
|
||||
return HealthState {
|
||||
reachable: Some(false),
|
||||
detail: format!("{host} resolved to no addresses"),
|
||||
last_checked: now,
|
||||
};
|
||||
}
|
||||
for addr in &addrs {
|
||||
if TcpStream::connect_timeout(addr, CONNECT_TIMEOUT).is_ok() {
|
||||
return HealthState {
|
||||
reachable: Some(true),
|
||||
detail: format!("Reachable at {addr}"),
|
||||
last_checked: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
HealthState {
|
||||
reachable: Some(false),
|
||||
detail: format!("{host}:{port} not reachable"),
|
||||
last_checked: now,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! FIFA 17 local companion services — LSX (Origin emulator) + autopatch
|
||||
//! (ProtoSSL cert-verify memory patcher). Both are inherently local to the game
|
||||
//! machine and are managed by the launcher as child processes, mirroring the way
|
||||
//! `setup::launch_game` spawns and log-streams the game.
|
||||
//!
|
||||
//! WHY THESE TWO ARE LOCAL (and the rest is not): the heavy FUT responders
|
||||
//! (Blaze / UTAS / roster / POW) run in the server container. LSX must stay here
|
||||
//! because the game dials it on the hardcoded loopback `127.0.0.1:4216`;
|
||||
//! autopatch must stay here because it writes `/proc/<FIFA17.exe>/mem`.
|
||||
//!
|
||||
//! Lifecycle: each service is a long-running daemon. We keep the `Child` handle
|
||||
//! so the UI can show running/stopped and stop them. Both run as the launcher
|
||||
//! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous
|
||||
//! Polkit prompt delaying cert patching until after FIFA's first TLS attempt.
|
||||
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
use crate::logs::LogBuffer;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct CommandParts {
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which companion service. The `str` values are used in log prefixes.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
pub enum Service {
|
||||
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
|
||||
Lsx,
|
||||
/// autopatch — patches FIFA17.exe process memory after host ptrace arming.
|
||||
Autopatch,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Service::Lsx => "LSX",
|
||||
Service::Autopatch => "autopatch",
|
||||
}
|
||||
}
|
||||
|
||||
/// The responder script filename inside the tools dir.
|
||||
fn script(self) -> &'static str {
|
||||
match self {
|
||||
Service::Lsx => "lsx_responder_v2.py",
|
||||
Service::Autopatch => "autopatch.py",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts {
|
||||
let mut args = vec![tools_dir
|
||||
.join(service.script())
|
||||
.to_string_lossy()
|
||||
.into_owned()];
|
||||
if service == Service::Autopatch {
|
||||
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
|
||||
}
|
||||
CommandParts {
|
||||
program: python.to_string(),
|
||||
args,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stop_work<F>(work: F) -> mpsc::Receiver<anyhow::Result<()>>
|
||||
where
|
||||
F: FnOnce() -> anyhow::Result<()> + Send + 'static,
|
||||
{
|
||||
let (send, receive) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = send.send(work());
|
||||
});
|
||||
receive
|
||||
}
|
||||
|
||||
fn wait_for_listener_ready(
|
||||
child: &mut Child,
|
||||
address: SocketAddr,
|
||||
timeout: Duration,
|
||||
) -> anyhow::Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
// Let immediate startup/bind errors surface before accepting an occupied
|
||||
// port as evidence that this child became ready.
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
loop {
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.map_err(|error| anyhow::anyhow!("could not inspect LSX startup: {error}"))?
|
||||
{
|
||||
anyhow::bail!("LSX exited before becoming ready ({status}); port 4216 may be in use");
|
||||
}
|
||||
match TcpListener::bind(address) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => return Ok(()),
|
||||
Err(error) => anyhow::bail!("could not probe LSX listener {address}: {error}"),
|
||||
Ok(listener) => drop(listener),
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"LSX did not bind {address} within {} ms",
|
||||
timeout.as_millis()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// A managed companion service process.
|
||||
#[derive(Default)]
|
||||
pub struct ManagedService {
|
||||
child: Option<Child>,
|
||||
stopping: Option<mpsc::Receiver<anyhow::Result<()>>>,
|
||||
}
|
||||
|
||||
impl ManagedService {
|
||||
/// Wrap an already-spawned child.
|
||||
pub fn from_child(child: Child) -> Self {
|
||||
Self {
|
||||
child: Some(child),
|
||||
stopping: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True while the child is spawned and has not yet exited. Reaps the exit
|
||||
/// status if it has, so the UI reflects a service that died on its own.
|
||||
pub fn running(&mut self, log: &Arc<Mutex<LogBuffer>>, label: &str) -> bool {
|
||||
if let Some(result) = self.stopping.as_ref() {
|
||||
match result.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] {label} stopped."));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] failed to stop {label}: {error}"));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => return true,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
log.lock().unwrap().push(format!(
|
||||
"[launcher] {label} stop worker exited unexpectedly."
|
||||
));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match self.child.as_mut() {
|
||||
None => false,
|
||||
Some(c) => match c.try_wait() {
|
||||
Ok(None) => true,
|
||||
Ok(Some(status)) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] {label} exited ({status})."));
|
||||
self.child = None;
|
||||
false
|
||||
}
|
||||
Err(_) => true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stopping(&self) -> bool {
|
||||
self.stopping.is_some()
|
||||
}
|
||||
|
||||
/// Begin stopping the service without waiting on the egui UI thread.
|
||||
pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) {
|
||||
if self.stopping.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let label = service.label();
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] stopping {label}…"));
|
||||
|
||||
self.stopping = Some(dispatch_stop_work(move || {
|
||||
child
|
||||
.kill()
|
||||
.map_err(|error| anyhow::anyhow!("kill failed: {error}"))?;
|
||||
|
||||
child
|
||||
.wait()
|
||||
.map_err(|error| anyhow::anyhow!("reap failed: {error}"))?;
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedService {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut c) = self.child.take() {
|
||||
let _ = c.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a companion service. `python` is the interpreter, `tools_dir` the
|
||||
/// directory holding the responder scripts. Streams stdout+stderr into `log`.
|
||||
/// Returns an error (without spawning) if the tools dir or script is missing.
|
||||
pub fn spawn(
|
||||
service: Service,
|
||||
python: &str,
|
||||
tools_dir: &str,
|
||||
persona_id: u64,
|
||||
persona_name: &str,
|
||||
log: Arc<Mutex<LogBuffer>>,
|
||||
) -> anyhow::Result<Child> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
let dir = Path::new(tools_dir);
|
||||
if !dir.is_dir() {
|
||||
anyhow::bail!(
|
||||
"FIFA 17 tools dir not found: {} (set it in the Config tab)",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
let script_path = dir.join(service.script());
|
||||
if !script_path.exists() {
|
||||
anyhow::bail!(
|
||||
"{} not found in tools dir: {}",
|
||||
service.script(),
|
||||
script_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let label = service.label();
|
||||
|
||||
// Both services use the configured interpreter and absolute script path;
|
||||
// neither invents a Python installation path. Autopatch receives launcher
|
||||
// ownership and a per-user runtime log so stale root-owned /tmp files cannot
|
||||
// block startup.
|
||||
let parts = command_parts(service, python, dir);
|
||||
let mut cmd = Command::new(&parts.program);
|
||||
cmd.args(&parts.args);
|
||||
if service == Service::Lsx {
|
||||
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
|
||||
.env("FUT_PERSONA_NAME", persona_name);
|
||||
} else if service == Service::Autopatch {
|
||||
let log_path = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("openfut-autopatch.log");
|
||||
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
|
||||
}
|
||||
// Put each companion in its own process group for lifecycle isolation.
|
||||
cmd.process_group(0);
|
||||
cmd.current_dir(dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
log.lock().unwrap().push(format!(
|
||||
"[launcher] starting {label}: {} {}",
|
||||
python,
|
||||
script_path.display(),
|
||||
));
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.script()))?;
|
||||
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let buf = Arc::clone(&log);
|
||||
let lbl = label.to_string();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(format!("[{lbl}] {line}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
let buf = Arc::clone(&log);
|
||||
let lbl = label.to_string();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(format!("[{lbl}] {line}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if service == Service::Lsx {
|
||||
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
|
||||
if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(error);
|
||||
}
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lsx_runs_python_directly() {
|
||||
let parts = command_parts(Service::Lsx, "/usr/bin/python3", Path::new("/tmp/tools"));
|
||||
assert_eq!(parts.program, "/usr/bin/python3");
|
||||
assert_eq!(parts.args, vec!["/tmp/tools/lsx_responder_v2.py"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autopatch_runs_python_directly_with_launcher_ownership() {
|
||||
let parts = command_parts(
|
||||
Service::Autopatch,
|
||||
"/usr/bin/python3",
|
||||
Path::new("/tmp/tools"),
|
||||
);
|
||||
assert_eq!(parts.program, "/usr/bin/python3");
|
||||
assert_eq!(
|
||||
parts.args,
|
||||
vec![
|
||||
"/tmp/tools/autopatch.py",
|
||||
"--launcher-pid",
|
||||
&std::process::id().to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_work_is_dispatched_without_blocking_the_caller() {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let started = Instant::now();
|
||||
let done = dispatch_stop_work(|| {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
Ok(())
|
||||
});
|
||||
|
||||
assert!(started.elapsed() < Duration::from_millis(100));
|
||||
assert!(done.try_recv().is_err());
|
||||
assert!(done.recv_timeout(Duration::from_secs(1)).unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_rejects_an_lsx_child_that_exits_before_binding() {
|
||||
let mut child = Command::new("sh")
|
||||
.args(["-c", "exit 7"])
|
||||
.spawn()
|
||||
.expect("spawn short-lived child");
|
||||
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0));
|
||||
let error = wait_for_listener_ready(&mut child, address, Duration::from_secs(1))
|
||||
.expect_err("exited child must not be reported ready");
|
||||
assert!(error.to_string().contains("exited before becoming ready"));
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -1,7 +1,13 @@
|
||||
mod account_sync;
|
||||
mod app;
|
||||
mod arm;
|
||||
mod config;
|
||||
mod game_launch;
|
||||
mod health;
|
||||
mod local_services;
|
||||
mod logs;
|
||||
mod process;
|
||||
mod netcheck;
|
||||
mod preflight;
|
||||
mod setup;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! "Test Connection" support: verify the configured OpenFUT server is actually
|
||||
//! reachable before the user launches FIFA.
|
||||
//!
|
||||
//! This resolves the configured host through the SAME shared path the hook uses
|
||||
//! ([`openfut_common::ServerConfig::resolve`]) and then does a bounded TCP
|
||||
//! connect to the OpenFUT destination port(s). It never falls back to loopback:
|
||||
//! if the server isn't configured/resolvable, it reports that plainly.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::time::Duration;
|
||||
|
||||
use openfut_common::ServerConfig;
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Outcome of a connection test, suitable for showing in the UI.
|
||||
pub struct TestOutcome {
|
||||
pub ok: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Resolve `cfg` and attempt to reach the OpenFUT server. Checks the HTTPS
|
||||
/// destination port (the one EA :443 traffic is redirected to) since that is the
|
||||
/// service the client relies on first. On success, also reports whether the core
|
||||
/// `/health` endpoint answered (best-effort; a plain-text probe, TLS not spoken).
|
||||
pub fn test_connection(cfg: &ServerConfig) -> TestOutcome {
|
||||
let resolved = match cfg.resolve() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return TestOutcome {
|
||||
ok: false,
|
||||
message: format!("Cannot resolve OpenFUT server: {e}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let addr = SocketAddr::from((resolved.redirect_ip, resolved.ports.https));
|
||||
match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
|
||||
Ok(mut stream) => {
|
||||
// Best-effort HTTP probe of /health. The bridge front door speaks
|
||||
// TLS, so a plaintext request may not get a clean 200 — a successful
|
||||
// TCP connect already proves reachability, so we don't fail on this.
|
||||
let health = probe_health(&mut stream);
|
||||
let detail = match health {
|
||||
Some(true) => " (core /health responded OK)".to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
TestOutcome {
|
||||
ok: true,
|
||||
message: format!(
|
||||
"Reachable: {}:{} is accepting connections{detail}.",
|
||||
resolved.redirect_ip, resolved.ports.https
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(e) => TestOutcome {
|
||||
ok: false,
|
||||
message: format!(
|
||||
"Could not reach {}:{} — {e}. Check the server is running and the \
|
||||
address/port are correct.",
|
||||
resolved.redirect_ip, resolved.ports.https
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_health(stream: &mut TcpStream) -> Option<bool> {
|
||||
let _ = stream.set_read_timeout(Some(CONNECT_TIMEOUT));
|
||||
let _ = stream.set_write_timeout(Some(CONNECT_TIMEOUT));
|
||||
let req = "GET /health HTTP/1.0\r\nConnection: close\r\n\r\n";
|
||||
stream.write_all(req.as_bytes()).ok()?;
|
||||
let mut buf = [0u8; 512];
|
||||
let n = stream.read(&mut buf).ok()?;
|
||||
let text = String::from_utf8_lossy(&buf[..n]);
|
||||
Some(text.contains("200") || text.contains("\"status\""))
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Pre-launch checks for the client-side state FIFA depends on.
|
||||
//!
|
||||
//! # Why
|
||||
//!
|
||||
//! On 2026-08-11 the game machine rebooted. Everything `client_arm.sh` sets —
|
||||
//! `ptrace_scope=0`, the DNAT of EA's hardcoded redirector IP, the
|
||||
//! `easw.easports.com` mapping — is volatile and was silently gone. The launcher
|
||||
//! started, the local services started, the game started, and forty minutes later
|
||||
//! the only symptom was FIFA's own dialog: *"the servers for this title have been
|
||||
//! shut down"*. Nothing in the stack said anything, because nothing was looking.
|
||||
//!
|
||||
//! Every one of those conditions is observable **without privilege**. This module
|
||||
//! looks, and reports before the user clicks Launch.
|
||||
//!
|
||||
//! # Deliberately not checked here
|
||||
//!
|
||||
//! Certificate parity across the FIFA-facing TLS services — the fault that cost
|
||||
//! three redirector gates — is the single most valuable check available, but the
|
||||
//! launcher has no TLS dependency (`account_sync` speaks plaintext HTTP by hand)
|
||||
//! and adding one is a decision, not a detail. `scripts/check-tls-parity.sh` on
|
||||
//! the server covers it in the meantime.
|
||||
//!
|
||||
//! # Advisory, not a gate
|
||||
//!
|
||||
//! Results colour the UI; they never disable Launch. A preflight that is itself
|
||||
//! wrong must not be able to lock the user out of their own game.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::LauncherConfig;
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
Pass,
|
||||
/// Genuinely wrong, but something else in the stack covers it, so the game
|
||||
/// can still work. Kept distinct from [`State::Fail`] because a checker that
|
||||
/// cries "this will fail" and is then contradicted by a working game teaches
|
||||
/// the user to ignore it — which is worse than not checking at all.
|
||||
Warn,
|
||||
Fail,
|
||||
/// Not configured, so there is nothing to assert. Never reported as a pass:
|
||||
/// "we did not look" and "we looked and it was fine" must not look alike.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Check {
|
||||
pub name: String,
|
||||
pub state: State,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Check {
|
||||
fn pass(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Pass,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn fail(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Fail,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn warn(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Warn,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn skip(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Skipped,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run every applicable check. Order is the order the game exercises them.
|
||||
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
||||
vec![
|
||||
ptrace_scope(cfg),
|
||||
ea_redirect(cfg),
|
||||
hostname_mapping(cfg),
|
||||
backend_reachable(cfg),
|
||||
]
|
||||
}
|
||||
|
||||
/// Checks that will stop the game working.
|
||||
pub fn failures(checks: &[Check]) -> usize {
|
||||
checks.iter().filter(|c| c.state == State::Fail).count()
|
||||
}
|
||||
|
||||
/// Checks that are wrong but survivable.
|
||||
pub fn warnings(checks: &[Check]) -> usize {
|
||||
checks.iter().filter(|c| c.state == State::Warn).count()
|
||||
}
|
||||
|
||||
/// autopatch writes to FIFA's process memory; Yama blocks that unless
|
||||
/// `ptrace_scope` is 0. At 1 the patch silently does nothing and the game fails
|
||||
/// its TLS handshake much later, with no message naming the cause.
|
||||
fn ptrace_scope(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "ptrace_scope (autopatch)";
|
||||
// `fifa17_tools_dir` carries a conventional default, so a non-empty value
|
||||
// does not mean the tools are installed. Key off the directory actually
|
||||
// existing: that is what decides whether autopatch will run at all, and it
|
||||
// keeps this from failing on a machine that never uses local services.
|
||||
let tools = cfg.fifa17_tools_dir.trim();
|
||||
if tools.is_empty() || !std::path::Path::new(tools).is_dir() {
|
||||
return Check::skip(NAME, "no local services installed");
|
||||
}
|
||||
match std::fs::read_to_string(PTRACE_SCOPE) {
|
||||
Ok(v) => ptrace_verdict(&v),
|
||||
// Not every kernel has Yama. Absent means unenforced, which is what we want.
|
||||
Err(_) => Check::skip(NAME, "Yama not present on this kernel"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision, split from the file read so it can be tested.
|
||||
///
|
||||
/// Reading `/proc` in a test would assert facts about the machine running the
|
||||
/// suite rather than about this code — and left inline, "any value is fine"
|
||||
/// was a mutation no test could catch.
|
||||
fn ptrace_verdict(raw: &str) -> Check {
|
||||
const NAME: &str = "ptrace_scope (autopatch)";
|
||||
let v = raw.trim();
|
||||
if v == "0" {
|
||||
Check::pass(NAME, "0 — autopatch can attach")
|
||||
} else {
|
||||
Check::fail(
|
||||
NAME,
|
||||
format!("{v} — autopatch cannot patch FIFA. Click 'Arm client'."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// FIFA dials EA's redirector by hardcoded IP. Armed, that address is DNAT'd to
|
||||
/// the OpenFUT server and connects instantly; unarmed it leaves the LAN and
|
||||
/// times out — which is exactly the "servers have been shut down" dialog.
|
||||
///
|
||||
/// This tests the *effect* rather than reading firewall rules, so it needs no
|
||||
/// privilege and stays honest about what the game will actually experience.
|
||||
fn ea_redirect(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "EA redirector IP is redirected";
|
||||
let ip = cfg.ea_redirect_probe_ip.trim();
|
||||
if ip.is_empty() {
|
||||
return Check::skip(NAME, "no probe IP configured");
|
||||
}
|
||||
let Ok(addr) = ip.parse::<IpAddr>() else {
|
||||
return Check::fail(NAME, format!("ea_redirect_probe_ip is not an IP: {ip:?}"));
|
||||
};
|
||||
let port = cfg.openfut_blaze_redirector_port;
|
||||
match TcpStream::connect_timeout(&SocketAddr::new(addr, port), PROBE_TIMEOUT) {
|
||||
Ok(_) => Check::pass(NAME, format!("{ip}:{port} answered — redirect is in place")),
|
||||
Err(e) => Check::fail(
|
||||
NAME,
|
||||
format!("{ip}:{port} did not answer ({e}). Click 'Arm client'."),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The dead EA hostnames should resolve to the OpenFUT server.
|
||||
///
|
||||
/// Resolution is done with `getaddrinfo`, the same call the game makes, so a
|
||||
/// duplicate `/etc/hosts` line that shadows the OpenFUT one is caught by its
|
||||
/// effect. Parsing `/etc/hosts` would miss it: the file can contain the right
|
||||
/// line and still resolve to the wrong address, because the first match wins.
|
||||
///
|
||||
/// # Why a warning and not a failure
|
||||
///
|
||||
/// Measured, not assumed. On 2026-08-11 this reported `easw.easports.com ->
|
||||
/// ::1,127.0.0.1` and the game reached the FUT hub regardless. The reason is in
|
||||
/// `client_arm.sh`'s own header: the responders run with `OPENFUT_ADVERTISE`
|
||||
/// set, so after the first redirected contact the game is handed the server's
|
||||
/// *address* for every later hop and stops using the hostname. The name is only
|
||||
/// CardsDLL's built-in fallback.
|
||||
///
|
||||
/// So this is a real misconfiguration worth fixing and not a reason to expect
|
||||
/// failure. Reporting it as fatal, and then being contradicted by a working
|
||||
/// game, is how a checklist trains its user to ignore it.
|
||||
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "EA hostnames point at OpenFUT";
|
||||
if cfg.ea_hostnames.is_empty() {
|
||||
return Check::skip(NAME, "no EA hostnames configured");
|
||||
}
|
||||
let server = cfg.openfut_server_host.trim();
|
||||
if server.is_empty() {
|
||||
return Check::skip(NAME, "no OpenFUT server configured");
|
||||
}
|
||||
let want = match resolve(server) {
|
||||
Ok(ips) if !ips.is_empty() => ips,
|
||||
_ => {
|
||||
return Check::fail(
|
||||
NAME,
|
||||
format!("cannot resolve the OpenFUT server {server:?}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let mut wrong = Vec::new();
|
||||
for host in &cfg.ea_hostnames {
|
||||
match resolve(host) {
|
||||
Ok(got) if got.iter().any(|ip| want.contains(ip)) => {}
|
||||
Ok(got) => wrong.push(format!(
|
||||
"{host} -> {} (expected {})",
|
||||
join(&got),
|
||||
join(&want)
|
||||
)),
|
||||
Err(e) => wrong.push(format!("{host} -> unresolvable ({e})")),
|
||||
}
|
||||
}
|
||||
|
||||
if wrong.is_empty() {
|
||||
Check::pass(
|
||||
NAME,
|
||||
format!("{} host(s) resolve to {server}", cfg.ea_hostnames.len()),
|
||||
)
|
||||
} else {
|
||||
Check::warn(
|
||||
NAME,
|
||||
format!(
|
||||
"{}. Look for an earlier /etc/hosts line shadowing it. \
|
||||
Usually survivable: the server advertises its address, so the \
|
||||
game stops using this name after the first hop.",
|
||||
wrong.join("; ")
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The server side of the same question: are the ports the game will use open?
|
||||
fn backend_reachable(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "OpenFUT server reachable";
|
||||
let host = cfg.openfut_server_host.trim();
|
||||
if host.is_empty() {
|
||||
return Check::skip(NAME, "no OpenFUT server configured");
|
||||
}
|
||||
let ports = [
|
||||
("blaze redirector", cfg.openfut_blaze_redirector_port),
|
||||
("account sync", cfg.openfut_account_sync_port),
|
||||
];
|
||||
let mut dead = Vec::new();
|
||||
for (label, port) in ports {
|
||||
if !connects(host, port) {
|
||||
dead.push(format!("{label} :{port}"));
|
||||
}
|
||||
}
|
||||
if dead.is_empty() {
|
||||
Check::pass(NAME, format!("{host}: all {} ports answering", ports.len()))
|
||||
} else {
|
||||
Check::fail(NAME, format!("{host}: no answer on {}", dead.join(", ")))
|
||||
}
|
||||
}
|
||||
|
||||
fn connects(host: &str, port: u16) -> bool {
|
||||
match (host, port).to_socket_addrs() {
|
||||
Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(host: &str) -> std::io::Result<Vec<IpAddr>> {
|
||||
Ok((host, 0u16).to_socket_addrs()?.map(|a| a.ip()).collect())
|
||||
}
|
||||
|
||||
fn join(ips: &[IpAddr]) -> String {
|
||||
ips.iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> LauncherConfig {
|
||||
LauncherConfig::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unconfigured_launcher_skips_rather_than_passes() {
|
||||
// The distinction that matters: a fresh config must not display four
|
||||
// green ticks. "Not checked" is not "checked and fine".
|
||||
let mut c = cfg();
|
||||
// `default()` points this at a conventional path whose existence varies
|
||||
// by machine. Pin it so the assertion is about the code, not this box.
|
||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
||||
let checks = run(&c);
|
||||
assert!(
|
||||
checks.iter().all(|k| k.state == State::Skipped),
|
||||
"{checks:#?}"
|
||||
);
|
||||
assert_eq!(failures(&checks), 0, "nothing configured is not a failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ptrace_scope_zero_lets_autopatch_work() {
|
||||
assert_eq!(ptrace_verdict("0\n").state, State::Pass);
|
||||
// 1 is the default on most distributions and is exactly the state that
|
||||
// let autopatch fail silently for forty minutes on 2026-08-11.
|
||||
assert_eq!(ptrace_verdict("1\n").state, State::Fail);
|
||||
assert_eq!(ptrace_verdict("2").state, State::Fail);
|
||||
assert_eq!(ptrace_verdict("3").state, State::Fail);
|
||||
assert!(ptrace_verdict("1").detail.contains("Arm client"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptrace_is_skipped_when_the_tools_dir_does_not_exist() {
|
||||
// Regression: the gate used to be "is the field non-empty", and the
|
||||
// field has a default — so this check ran (and failed) on machines that
|
||||
// never use autopatch at all.
|
||||
let mut c = cfg();
|
||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
||||
assert_eq!(ptrace_scope(&c).state, State::Skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
|
||||
let mut c = cfg();
|
||||
c.ea_redirect_probe_ip = "not-an-ip".into();
|
||||
let check = ea_redirect(&c);
|
||||
assert_eq!(check.state, State::Fail);
|
||||
assert!(check.detail.contains("not an IP"), "{}", check.detail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_check_is_skipped_without_a_server_but_not_passed() {
|
||||
let mut c = cfg();
|
||||
c.ea_hostnames = vec!["easw.easports.com".into()];
|
||||
assert_eq!(hostname_mapping(&c).state, State::Skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_check_detects_a_host_pointing_somewhere_else() {
|
||||
// localhost and 127.0.0.1 resolve without a network; this is the
|
||||
// shadowed-/etc/hosts shape without depending on the real one.
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.2".into();
|
||||
c.ea_hostnames = vec!["localhost".into()];
|
||||
let check = hostname_mapping(&c);
|
||||
// Warn, not Fail: observed on 2026-08-11 to be survivable, because the
|
||||
// server advertises its address after the first hop.
|
||||
assert_eq!(check.state, State::Warn, "{}", check.detail);
|
||||
assert!(check.detail.contains("localhost -> "), "{}", check.detail);
|
||||
}
|
||||
|
||||
/// A shadowed hostname must not be counted as a reason to expect failure.
|
||||
/// This is the exact case the first version got wrong.
|
||||
#[test]
|
||||
fn a_shadowed_hostname_is_a_warning_not_a_failure() {
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.2".into();
|
||||
c.ea_hostnames = vec!["localhost".into()];
|
||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
||||
let checks = run(&c);
|
||||
assert_eq!(failures(&checks), 0, "must not be reported as fatal");
|
||||
assert_eq!(warnings(&checks), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_check_passes_when_it_points_at_the_server() {
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.1".into();
|
||||
c.ea_hostnames = vec!["localhost".into()];
|
||||
// `localhost` may resolve to ::1 as well; the check requires only that
|
||||
// one resolved address matches, which mirrors what connecting does.
|
||||
assert_eq!(hostname_mapping(&c).state, State::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptrace_check_is_skipped_when_local_services_are_not_configured() {
|
||||
let mut c = cfg();
|
||||
c.fifa17_tools_dir.clear();
|
||||
assert_eq!(ptrace_scope(&c).state, State::Skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_backend_port_is_reported_as_a_failure() {
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.1".into();
|
||||
// Port 1 requires root to bind, so nothing is listening on it.
|
||||
c.openfut_blaze_redirector_port = 1;
|
||||
c.openfut_account_sync_port = 1;
|
||||
let check = backend_reachable(&c);
|
||||
assert_eq!(check.state, State::Fail, "{}", check.detail);
|
||||
assert!(check.detail.contains("no answer on"), "{}", check.detail);
|
||||
}
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
use std::{
|
||||
io::{BufRead, BufReader},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
};
|
||||
|
||||
use crate::logs::LogBuffer;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ServiceStatus {
|
||||
Stopped,
|
||||
Starting,
|
||||
Running,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl ServiceStatus {
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
ServiceStatus::Stopped => "Stopped",
|
||||
ServiceStatus::Starting => "Starting…",
|
||||
ServiceStatus::Running => "Running",
|
||||
ServiceStatus::Failed(_) => "Failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(&self) -> egui::Color32 {
|
||||
match self {
|
||||
ServiceStatus::Running => egui::Color32::from_rgb(80, 200, 120),
|
||||
ServiceStatus::Starting => egui::Color32::from_rgb(255, 200, 0),
|
||||
ServiceStatus::Failed(_) => egui::Color32::from_rgb(220, 60, 60),
|
||||
ServiceStatus::Stopped => egui::Color32::from_rgb(150, 150, 150),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ServiceHandle {
|
||||
child: Option<Child>,
|
||||
pub status: Arc<Mutex<ServiceStatus>>,
|
||||
}
|
||||
|
||||
impl ServiceHandle {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
child: None,
|
||||
status: Arc::new(Mutex::new(ServiceStatus::Stopped)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
&mut self,
|
||||
binary: &str,
|
||||
env_pairs: &[(String, String)],
|
||||
log_buf: Arc<Mutex<LogBuffer>>,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.is_running() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
*self.status.lock().unwrap() = ServiceStatus::Starting;
|
||||
|
||||
let mut cmd = Command::new(binary);
|
||||
for (k, v) in env_pairs {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
*self.status.lock().unwrap() = ServiceStatus::Failed(e.to_string());
|
||||
e
|
||||
})?;
|
||||
|
||||
// Drain stdout
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let buf = Arc::clone(&log_buf);
|
||||
let status = Arc::clone(&self.status);
|
||||
thread::spawn(move || {
|
||||
*status.lock().unwrap() = ServiceStatus::Running;
|
||||
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drain stderr
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let buf = Arc::clone(&log_buf);
|
||||
thread::spawn(move || {
|
||||
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.child = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
*self.status.lock().unwrap() = ServiceStatus::Stopped;
|
||||
}
|
||||
|
||||
pub fn is_running(&mut self) -> bool {
|
||||
if let Some(child) = &mut self.child {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
// process exited
|
||||
self.child = None;
|
||||
*self.status.lock().unwrap() = ServiceStatus::Stopped;
|
||||
false
|
||||
}
|
||||
Ok(None) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> ServiceStatus {
|
||||
self.status.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServiceHandle {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
+79
-37
@@ -1,25 +1,7 @@
|
||||
use std::{path::{Path, PathBuf}, process::Command};
|
||||
|
||||
// ── Port 443 capability ───────────────────────────────────────────────────────
|
||||
|
||||
/// Check whether the bridge binary already has cap_net_bind_service set.
|
||||
pub fn bridge_has_cap443(binary: &Path) -> bool {
|
||||
std::process::Command::new("getcap")
|
||||
.arg(binary)
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("cap_net_bind_service"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Grant cap_net_bind_service to the bridge binary so it can bind port 443
|
||||
/// without running as root. Uses pkexec (or sudo as fallback).
|
||||
pub fn setcap_bridge_443(binary: &Path) -> anyhow::Result<()> {
|
||||
let script = format!(
|
||||
"setcap cap_net_bind_service=+ep '{}'",
|
||||
binary.to_string_lossy()
|
||||
);
|
||||
run_elevated(&script)
|
||||
}
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
// ── Cert installation ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -67,17 +49,13 @@ fn try_wine_certutil(cert_src: &Path) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_elevated(script: &str) -> anyhow::Result<()> {
|
||||
let status = Command::new("pkexec")
|
||||
.args(["sh", "-c", script])
|
||||
.status();
|
||||
pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> {
|
||||
let status = Command::new("pkexec").args(["sh", "-c", script]).status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok(()),
|
||||
_ => {
|
||||
let s = Command::new("sudo")
|
||||
.args(["sh", "-c", script])
|
||||
.status()?;
|
||||
let s = Command::new("sudo").args(["sh", "-c", script]).status()?;
|
||||
if s.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -90,10 +68,13 @@ fn run_elevated(script: &str) -> anyhow::Result<()> {
|
||||
// ── DLL hook deployment ───────────────────────────────────────────────────────
|
||||
|
||||
/// Deploy openfut_hook.dll into the FIFA 23 game directory and write
|
||||
/// openfut.cfg with the redirect IP the hook will use.
|
||||
/// openfut.cfg with the structured server configuration the hook reads.
|
||||
/// `cfg_contents` must be the full `openfut.cfg` body (see
|
||||
/// `LauncherConfig::hook_cfg_contents`) — this function does not invent any
|
||||
/// address itself, so a missing server can never silently become loopback.
|
||||
/// Uses `version.dll` as the hijack name — FIFA 23 loads it but defers to
|
||||
/// the system copy, so Proton picks up our local one first.
|
||||
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, redirect_ip: &str) -> anyhow::Result<()> {
|
||||
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
|
||||
if !dll_src.exists() {
|
||||
anyhow::bail!(
|
||||
"Hook DLL not found at {}. Build it first with:\n\
|
||||
@@ -104,17 +85,18 @@ pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, redirect_ip: &str) -> an
|
||||
}
|
||||
std::fs::create_dir_all(game_dir)?;
|
||||
std::fs::copy(dll_src, game_dir.join("version.dll"))?;
|
||||
std::fs::write(game_dir.join("openfut.cfg"), redirect_ip)?;
|
||||
std::fs::write(game_dir.join("openfut.cfg"), cfg_contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update only openfut.cfg without redeploying the DLL.
|
||||
pub fn update_hook_config(game_dir: &Path, redirect_ip: &str) -> anyhow::Result<()> {
|
||||
/// Update only openfut.cfg without redeploying the DLL. `cfg_contents` is the
|
||||
/// full structured `openfut.cfg` body.
|
||||
pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
|
||||
let cfg = game_dir.join("openfut.cfg");
|
||||
if !cfg.exists() {
|
||||
anyhow::bail!("Hook DLL not deployed yet — deploy first.");
|
||||
}
|
||||
std::fs::write(cfg, redirect_ip)?;
|
||||
std::fs::write(cfg, cfg_contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -134,5 +116,65 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
|
||||
|
||||
/// The Steam launch options the user needs to paste in to enable the override.
|
||||
/// Proton loads local DLLs named in WINEDLLOVERRIDES ahead of system ones.
|
||||
pub const STEAM_LAUNCH_OPTIONS: &str =
|
||||
"WINEDLLOVERRIDES=\"version=n,b\" %command%";
|
||||
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
|
||||
|
||||
// ── Game launch ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Launch the game via the user-provided shell command. Runs `sh -c <command>`
|
||||
/// (optionally from `workdir`), streaming stdout+stderr into `log_buf` on a
|
||||
/// background thread. The launcher does not assume Steam vs umu-run vs a custom
|
||||
/// script — whatever the user configured is what runs.
|
||||
pub fn launch_game(
|
||||
command: &str,
|
||||
workdir: &str,
|
||||
log_buf: std::sync::Arc<std::sync::Mutex<crate::logs::LogBuffer>>,
|
||||
) -> anyhow::Result<()> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
if command.trim().is_empty() {
|
||||
anyhow::bail!("No game launch command configured (set it in the Config tab).");
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(command);
|
||||
if !workdir.trim().is_empty() {
|
||||
cmd.current_dir(workdir);
|
||||
}
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
log_buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] launching game: {command}"));
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let buf = std::sync::Arc::clone(&log_buf);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
let buf = std::sync::Arc::clone(&log_buf);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Reap the child in the background so a finished game doesn't linger as a
|
||||
// zombie; we don't block the UI on it.
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
log_buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("[launcher] game process exited.".to_string());
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user