357501f549
Release-readiness pass on the launcher, driven by the end state "open it,
create an account, launch the game".
Fixes a silent correctness bug. `openfut.cfg` in the game dir is the only
server address the *game* can see, but it was written only by Setup's deploy
and its "Save & Update hook" button. Changing the server anywhere else left
FIFA connecting to the previous host while every panel in the launcher showed
the new one online. Now:
- `write_hook_config` reconciles the file from the live config, and runs
fail-closed before every launch, so the file and the UI cannot disagree at
the moment it matters;
- saving Settings pushes the address into the hook immediately;
- a `hook_config` preflight check reads the file back and warns, naming both
addresses, instead of leaving the drift invisible;
- Settings shows the same fact inline, and Save is enabled by drift alone —
a message saying "Save to update it" beside a disabled button is a dead end.
Account creation is now server-authoritative. `account_sync::discover` POSTs
`/openfut/account/sync` with the persona fields *omitted*, which makes the host
answer with the persona it was started with, its club, and the Core coin
balance. The launcher adopts that answer, so it never invents an identity and
the persona the game authenticates with is by construction the one the server
expects. Claiming is gated on the address being valid, NOT on the health pill:
that pill probes the HTTPS port while this talks to the account port, so gating
on it disabled the button on servers that answer it perfectly well.
UX consolidation:
- new Welcome ("Get started") tab: three numbered steps — connect, claim an
account, connect FIFA — each showing live state, ending in the launch CTA;
a fresh install opens on it and it leaves the nav rail once satisfied;
- Config renamed Settings, and made the single owner of the server address:
Setup's duplicate editors (same fields, different save semantics) are now a
read-only summary with actions;
- the dashboard offers account creation in place instead of naming a tab, and
the stale "set the host in the Setup tab" pointers are corrected.
Locks move to parking_lot per project rule (already the convention in
openfut-utas-host and openfut-identity); 47 poisoning unwraps go away.
Verified: 58 tests pass, fmt clean, clippy clean apart from one pre-existing
lint. Driven through the real UI under Xvfb as a fresh install — typed a server,
clicked Create my account, and the config on disk came back with persona
33068179/CAGE claimed from the live host; clicking Save rewrote a stale
`openfut.cfg` from host=10.10.0.99 to host=127.0.0.1.
749 lines
30 KiB
Rust
749 lines
30 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>,
|
|
|
|
// ── 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 {
|
|
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(),
|
|
fifa17_tools_dir: base
|
|
.join("fifa17-recon/tools")
|
|
.to_string_lossy()
|
|
.into(),
|
|
fifa17_python: default_python(),
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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 Settings.".into());
|
|
}
|
|
if self.fifa17_python.trim().is_empty() {
|
|
return Err("No Python interpreter configured. Set it in Settings.".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 Settings."
|
|
.into(),
|
|
);
|
|
}
|
|
self.validate_local_services()
|
|
}
|
|
|
|
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 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();
|
|
// 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");
|
|
}
|
|
}
|