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:
+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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user