1cd4f18e92
The launcher shelled out to `python3 lsx_responder_v2.py` and `python3 autopatch.py` from a configured tools directory. Both are now Rust binaries built from this workspace (openfut-lsx, openfut-autopatch), so the launch contract loses the interpreter and the script directory entirely: nothing to locate, nothing to configure, and no way to run a stale checkout's copy of a responder. Service::script() becomes Service::binary(), and resolve_binary() prefers a sibling of the running launcher -- what a workspace build and any sane install layout both produce -- falling back to the bare name so a PATH install still works. It returns the bare name rather than failing so that spawn() stays the single place a missing binary is reported, instead of two error paths for one condition. foreign_pid() now matches an argv entry's FILE NAME rather than a suffix, so `/path/to/openfut-lsx` matches while an unrelated argument that merely ends with the same text does not. It deliberately still reads argv and not comm: comm is truncated to 15 characters by the kernel, which would misreport both of these names -- the same trap that made an earlier `pgrep -f` guard match its own shell. Dead configuration removed rather than left vestigial: fifa17_python and fifa17_tools_dir, their Settings controls, and validate_local_services(), whose only two checks were those fields. A validation hook that can only return Ok(()) would claim the launcher verifies local-service configuration when there is none. The preflight tools-dir gate is gone too, while the ptrace_scope check it gated is kept -- that check is real and repairable via "Arm client"; only the gate died. The env contract is unchanged, so the binaries are drop-in: LSX still receives FUT_PERSONA_ID/FUT_PERSONA_NAME (the persona has to agree with Blaze's LoginResponse.SESS.PDTL and UTAS's userInfo.personaId), autopatch still receives OPENFUT_AUTOPATCH_LOG under XDG_RUNTIME_DIR and --launcher-pid so it cannot outlive its owner, and each companion still gets its own process group. 74 tests green.
675 lines
27 KiB
Rust
675 lines
27 KiB
Rust
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 {
|
|
pub core_binary: String,
|
|
pub bridge_binary: String,
|
|
pub core_database_url: String,
|
|
pub core_data_dir: String,
|
|
pub core_listen_addr: String,
|
|
pub bridge_listen_addr: String,
|
|
pub bridge_captures_dir: String,
|
|
pub bridge_core_url: String,
|
|
pub bridge_tls_enabled: bool,
|
|
/// Path to the built openfut_hook.dll (Windows DLL for Proton injection).
|
|
pub hook_dll_path: String,
|
|
/// FIFA 23 game folder inside the Proton prefix (where the DLL is deployed).
|
|
pub fifa_game_dir: 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>,
|
|
}
|
|
|
|
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 {
|
|
fn default() -> Self {
|
|
let base = dirs::home_dir()
|
|
.map(|h| h.join("Documents/OpenFUT"))
|
|
.unwrap_or_default();
|
|
|
|
Self {
|
|
core_binary: base
|
|
.join("openfut-core/target/release/openfut-core")
|
|
.to_string_lossy()
|
|
.into(),
|
|
bridge_binary: base
|
|
.join("openfut-bridge/target/release/openfut-bridge")
|
|
.to_string_lossy()
|
|
.into(),
|
|
core_database_url: "sqlite://openfut.db".into(),
|
|
core_data_dir: base
|
|
.join("openfut-core/data")
|
|
.to_string_lossy()
|
|
.into(),
|
|
core_listen_addr: "127.0.0.1:8080".into(),
|
|
bridge_listen_addr: "0.0.0.0:8765".into(),
|
|
bridge_captures_dir: base
|
|
.join("openfut-bridge/captures")
|
|
.to_string_lossy()
|
|
.into(),
|
|
bridge_core_url: "http://127.0.0.1:8080".into(),
|
|
bridge_tls_enabled: true,
|
|
hook_dll_path: dirs::home_dir()
|
|
.map(|h| h.join("Documents/openfut-launcher/openfut-hook/target/x86_64-pc-windows-gnu/release/openfut_hook.dll"))
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.into(),
|
|
fifa_game_dir: dirs::home_dir()
|
|
.map(|h| h.join(".steam/steam/steamapps/common/FIFA 23"))
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl LauncherConfig {
|
|
pub fn config_path() -> std::path::PathBuf {
|
|
dirs::config_dir()
|
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
|
.join("openfut-launcher")
|
|
.join("config.json")
|
|
}
|
|
|
|
pub fn load() -> Self {
|
|
let path = Self::config_path();
|
|
std::fs::read_to_string(&path)
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn save(&self) {
|
|
let path = Self::config_path();
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
if let Ok(json) = serde_json::to_string_pretty(self) {
|
|
let _ = std::fs::write(path, json);
|
|
}
|
|
}
|
|
|
|
/// 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))
|
|
}
|
|
}
|
|
|
|
/// The config the account monitor should poll with, or None when no server
|
|
/// is configured. Returns a clone so the background thread owns its own
|
|
/// snapshot and never races the UI's live config.
|
|
pub fn account_target(&self) -> Option<LauncherConfig> {
|
|
if self.openfut_server_host.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(self.clone())
|
|
}
|
|
}
|
|
|
|
/// 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 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 Settings."
|
|
.into(),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn validate_account(&self) -> Result<(), String> {
|
|
if self.fut_persona_id == 0 {
|
|
return Err("No account yet. Create one from the Get started tab.".into());
|
|
}
|
|
if self.fut_persona_name.trim().is_empty() {
|
|
return Err("Account has no persona name. Recreate it from Get started.".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(())
|
|
}
|
|
|
|
/// Whether an account has been claimed from the server (see
|
|
/// [`crate::account_sync::discover`]). Distinct from
|
|
/// [`Self::validate_account`], which also polices the derived EASFC values:
|
|
/// this answers only "does this install know who is playing?".
|
|
pub fn account_configured(&self) -> bool {
|
|
self.fut_persona_id != 0 && !self.fut_persona_name.trim().is_empty()
|
|
}
|
|
|
|
/// Whether the launcher should open on the guided first-run flow instead of
|
|
/// the dashboard. Keyed on the two things a new user cannot be expected to
|
|
/// guess: where the server is, and who they are.
|
|
pub fn needs_onboarding(&self) -> bool {
|
|
self.validate_server().is_err() || !self.account_configured()
|
|
}
|
|
|
|
/// 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 launch_config_requires_server_account_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();
|
|
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(),
|
|
..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(),
|
|
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();
|
|
// A fresh install has no account, and must say so rather than launching
|
|
// FIFA as persona 0.
|
|
assert!(!c.account_configured());
|
|
assert!(c.validate_account().is_err());
|
|
c.fut_persona_id = 12345678;
|
|
assert!(
|
|
!c.account_configured(),
|
|
"an id without a name is not an account"
|
|
);
|
|
assert!(c.validate_account().unwrap_err().contains("persona name"));
|
|
c.fut_persona_name = "TEST_USER".into();
|
|
assert!(c.account_configured());
|
|
assert!(c.validate_account().is_ok());
|
|
c.fut_account_experience = 1001;
|
|
assert!(c.validate_account().unwrap_err().contains("XP"));
|
|
}
|
|
|
|
#[test]
|
|
fn onboarding_is_needed_until_both_server_and_account_are_known() {
|
|
// Drives which tab the launcher opens on, so the two halves must both
|
|
// count: a server with no account is still a dead end for a new user.
|
|
let mut c = LauncherConfig::default();
|
|
assert!(c.needs_onboarding());
|
|
c.openfut_server_host = "10.10.0.120".into();
|
|
assert!(
|
|
c.needs_onboarding(),
|
|
"a server alone cannot launch anything"
|
|
);
|
|
c.fut_persona_id = 33_068_179;
|
|
c.fut_persona_name = "CAGE".into();
|
|
assert!(!c.needs_onboarding());
|
|
c.openfut_server_host.clear();
|
|
assert!(c.needs_onboarding(), "losing the server reopens the flow");
|
|
}
|
|
}
|