6 Commits

Author SHA1 Message Date
funman300 1cd4f18e92 feat: spawn the Rust companion binaries, not Python scripts
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.
2026-08-18 05:30:35 +00:00
funman300 c5424158b9 fix: launcher-spawned FIFA never loaded the hook, so its Blaze ports were inert
The hook ships as a `version.dll` proxy in the game directory, and Proton prefers a
local DLL over its builtin ONLY when WINEDLLOVERRIDES names it -- exactly what
setup::STEAM_LAUNCH_OPTIONS documents ("version=n,b"). Steam users get it from their
launch options. When the launcher spawns the runner itself it applied profile.env and
WINEPREFIX but never the override, so the hook silently did not load.

The failure mode is worse than "hook missing", which is why it went unnoticed: with
no hook there is no port rewrite, so the openfut.cfg the launcher writes two seconds
earlier is inert and the game falls back to EA's real Blaze ports, where /etc/hosts
(10.10.0.120 easw.easports.com) quietly routes it to whatever answers there. The
launch looks completely healthy -- correct log lines, game boots, FUT loads -- while
talking to a different server than the one configured. Production only works here by
accident of the hosts file.

Observed end-to-end: openfut.cfg written 19:50:00 with blaze 42327/42330, FIFA started
19:50:03, and the process was ESTAB to 10.10.0.120:42130 -- production.
/proc/<pid>/maps showed the mapped version.dll was Proton's own
(compatibilitytools.d/UMU-Proton-10.0-4/files/lib/wine/x86_64-windows/version.dll),
not the game-dir hook, and no hook log existed for the Proton prefix at all.

launch() now always sets WINEDLLOVERRIDES, appending to any profile value and
deferring to a profile that pins `version=` itself, so an operator disabling the
hijack on purpose is not silently overruled. Four tests cover absent, unrelated,
explicit and blank-string cases.

Also worth noting for future debugging: ~/.wine/drive_c/openfut_hook.log is stale and
belongs to a non-Proton prefix. It is not evidence about a umu/Proton launch, and
reading it as current is how this was nearly misdiagnosed.
2026-08-18 02:57:29 +00:00
funman300 3174fe4c1f launcher: one Launch button, driven by an explicit launch state machine
The launcher used to make the user perform OpenFUT's internal launch order by
hand — Start LSX, Start autopatch, Run pre-launch checks, "Arm client", then a
button called *Start Services & Launch Game*. Those are implementation details
of how FIFA 17 is persuaded to talk to OpenFUT, and getting the order wrong
produced failures that surfaced much later as "the game crashed": autopatch
started before ptrace_scope is 0 silently patches nothing at all.

The normal flow is now: open the launcher, read one status card, press
**Launch FIFA 17**.

New `launch` module holds the sequence as a state machine (Phase: Idle,
Checking, PreparingClient, StartingServices, Validating, Launching, Running,
Failed) and runs it on a worker thread, so the UI thread never blocks on a
socket, a Polkit prompt or a process spawn. The UI renders that state; it does
not coordinate services.

Every step asks what is already true before acting:

  - a healthy service is reused, never restarted;
  - client preparation is skipped when the checks it would repair already pass,
    which also avoids a pointless password prompt;
  - the hook config is reconciled from the current settings.

It stops at the first failed step and never starts FIFA into a client it knows
is broken. Preparation deliberately runs BEFORE autopatch, against the order in
the brief, because autopatch cannot write FIFA's memory until arming has set
ptrace_scope and would otherwise "succeed" while doing nothing.

Ownership is now tracked, which the old model could not express: it only knew
about children it had spawned, so a service started by hand for a debugging
session read as "stopped" and starting it again just collided on the port.
`ServiceSupervisor` observes our own child first, then scans /proc for a foreign
instance, and reports `ServiceRuntime { running, started_by_launcher, pid,
detail }`. `stop_permitted` refuses to kill anything the launcher did not start,
under any cleanup policy. `CleanupPolicy` states the shipped behaviour — leave
launcher-started services running for the next launch — instead of leaving it to
chance, and the FIFA-exit path goes through it.

Readiness comes from observation, never from a button press: LSX is ready only
when the port FIFA dials is actually held, and "we have not looked" renders as
"Not checked yet", never as green.

Manual controls all survive under **Advanced / Diagnostics** — per-service
start/stop/restart with PIDs and ownership, "Prepare client" (the old "Arm
client", renamed; internals still say arm), "Run pre-launch checks", "View
logs", and a new "Launch game only" escape hatch for debugging a launch the
sequence refuses.

Tests: 73 pass (15 new). Sequencing and ownership are unit-tested through a
`LaunchOps` fake, so "don't launch after a failed step", "don't restart healthy
services" and "don't kill what we didn't start" hold without a FIFA install, a
Polkit agent or root.

Exercised live under Xvfb: the card shows four observed rows and one button; a
launch stopped at LSX with "127.0.0.1:4216 is held by an unrelated process",
listed every step's verdict, and did NOT start the game; Advanced showed a real
pre-existing autopatch as "Running (foreign) · pid 382382 · started outside this
launcher" with Stop/Restart disabled.
2026-08-17 22:44:21 +00:00
funman300 504ceeec87 launcher: stop the shadowed-hostname test asserting the local machine's ports
It counted Pass/Warn/Fail across the whole preflight run, and `backend_reachable`
opens real sockets — so the aggregate silently asserted that the machine running
the suite has the OpenFUT blaze-redirector and account ports open. True on the
server host, false everywhere else, including the game machine where anyone
building the launcher would run it. Predates this branch; found by running the
suite on .105 instead of only here. Now asserts the hostname check itself, which
is what the test is named for.
2026-08-17 22:06:41 +00:00
funman300 cbf697bcd5 launcher: make the shadowed-hostname preflight test machine-independent
The new hook-config check warns when the deployed openfut.cfg disagrees with
the configured server, and this test counts warnings across every check. On the
game machine — which by definition has a hook deployed — that second warning
broke the assertion. Caught by running the suite on .105 rather than only on
the server host. Pins the game dir for the same reason the tools dir is pinned.
2026-08-17 22:05:15 +00:00
funman300 357501f549 launcher: guided first-run flow, server-owned settings, hook-config reconcile
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.
2026-08-17 21:46:43 +00:00
14 changed files with 3062 additions and 814 deletions
+3
View File
@@ -13,3 +13,6 @@ serde_json = "1"
dirs = "5" dirs = "5"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
openfut-common = { path = "openfut-common" } openfut-common = { path = "openfut-common" }
# parking_lot over std::sync: every lock here is taken and used immediately, so
# the poisoning unwrap at each call site is pure noise (project rule).
parking_lot = "0.12"
+7 -6
View File
@@ -8,10 +8,11 @@
//! target the UI re-points when the server config changes, and a shared state //! target the UI re-points when the server config changes, and a shared state
//! snapshot the UI renders each frame. //! snapshot the UI renders each frame.
use parking_lot::Mutex;
use std::{ use std::{
sync::{ sync::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
Arc, Mutex, Arc,
}, },
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant},
@@ -68,15 +69,15 @@ impl AccountMonitor {
let t_running = Arc::clone(&running); let t_running = Arc::clone(&running);
thread::spawn(move || { thread::spawn(move || {
while t_running.load(Ordering::Relaxed) { while t_running.load(Ordering::Relaxed) {
let target = t_target.lock().unwrap().clone(); let target = t_target.lock().clone();
match target { match target {
None => { None => {
// No server configured — reset to the idle prompt state. // No server configured — reset to the idle prompt state.
*t_state.lock().unwrap() = AccountState::default(); *t_state.lock() = AccountState::default();
} }
Some(config) => { Some(config) => {
let result = account_sync::sync(&config); let result = account_sync::sync(&config);
let mut state = t_state.lock().unwrap(); let mut state = t_state.lock();
state.configured = true; state.configured = true;
state.last_checked = Some(Instant::now()); state.last_checked = Some(Instant::now());
match result { match result {
@@ -107,11 +108,11 @@ impl AccountMonitor {
/// Point the monitor at a new server/account. `None` (no server configured) /// Point the monitor at a new server/account. `None` (no server configured)
/// puts it back into the idle prompt state. /// puts it back into the idle prompt state.
pub fn set_target(&self, target: Option<LauncherConfig>) { pub fn set_target(&self, target: Option<LauncherConfig>) {
*self.target.lock().unwrap() = target; *self.target.lock() = target;
} }
pub fn snapshot(&self) -> AccountState { pub fn snapshot(&self) -> AccountState {
self.state.lock().unwrap().clone() self.state.lock().clone()
} }
} }
+175 -21
View File
@@ -7,11 +7,18 @@ use std::time::Duration;
const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync"; const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync";
const TIMEOUT: Duration = Duration::from_secs(3); const TIMEOUT: Duration = Duration::from_secs(3);
/// The launcher's view of the account, sent on every sync.
///
/// `persona_id`/`persona_name` are `Option` because omitting them is meaningful:
/// the server then answers with the persona *it* is configured for, which is how
/// first-run account creation learns an identity instead of inventing one.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct AccountSyncRequest<'a> { struct AccountSyncRequest<'a> {
persona_id: u64, #[serde(skip_serializing_if = "Option::is_none")]
persona_name: &'a str, persona_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
persona_name: Option<&'a str>,
level: u32, level: u32,
experience: u32, experience: u32,
experience_max: u32, experience_max: u32,
@@ -54,7 +61,64 @@ pub struct AccountSummary {
pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> { pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
config.validate_server()?; config.validate_server()?;
config.validate_account()?; config.validate_account()?;
let account = post(
config,
&AccountSyncRequest {
persona_id: Some(config.fut_persona_id),
persona_name: Some(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,
},
)?;
// The server echoes the persona it selected. A different one means the two
// sides disagree about who is playing, which must never pass silently.
if account.persona_id != config.fut_persona_id {
return Err(format!(
"account server selected persona {} instead of {}",
account.persona_id, config.fut_persona_id
));
}
Ok(account)
}
/// Ask the server which account it serves, for first-run account creation.
///
/// Sending no persona makes the server fall back to the one it was started with
/// and answer with its real club and Core coin balance. That is the whole reason
/// the launcher never has to invent a persona id: the identity that matters is
/// the server's, and this is how it is claimed.
pub fn discover(config: &LauncherConfig) -> Result<AccountSummary, String> {
config.validate_server()?;
let account = post(
config,
&AccountSyncRequest {
persona_id: None,
persona_name: None,
level: config.fut_account_level.max(1),
experience: config.fut_account_experience,
experience_max: config.fut_account_experience_max.max(1),
account_funds: config.fut_account_funds,
account_funds_cap: config.fut_account_funds_cap,
},
)?;
if account.persona_id == 0 {
return Err(
"account server returned no persona — is it configured with \
a persona id?"
.to_string(),
);
}
if account.persona_name.trim().is_empty() {
return Err("account server returned an empty persona name".to_string());
}
Ok(account)
}
/// One bounded POST to `/openfut/account/sync`, returning the account summary.
fn post(config: &LauncherConfig, body: &AccountSyncRequest<'_>) -> Result<AccountSummary, String> {
let host = config.openfut_server_host.trim(); let host = config.openfut_server_host.trim();
let port = config.openfut_account_sync_port; let port = config.openfut_account_sync_port;
let address = (host, port) let address = (host, port)
@@ -71,16 +135,8 @@ pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
.set_write_timeout(Some(TIMEOUT)) .set_write_timeout(Some(TIMEOUT))
.map_err(|error| format!("cannot set account sync timeout: {error}"))?; .map_err(|error| format!("cannot set account sync timeout: {error}"))?;
let payload = serde_json::to_vec(&AccountSyncRequest { let payload = serde_json::to_vec(body)
persona_id: config.fut_persona_id, .map_err(|error| format!("cannot encode account sync request: {error}"))?;
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!( 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", "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",
@@ -107,21 +163,15 @@ pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
.and_then(|line| line.split_whitespace().nth(1)) .and_then(|line| line.split_whitespace().nth(1))
.and_then(|value| value.parse::<u16>().ok()) .and_then(|value| value.parse::<u16>().ok())
.ok_or_else(|| "account server returned a malformed status line".to_string())?; .ok_or_else(|| "account server returned a malformed status line".to_string())?;
let body = &response[separator + 4..]; let response_body = &response[separator + 4..];
if !(200..300).contains(&status) { if !(200..300).contains(&status) {
let detail = String::from_utf8_lossy(body); let detail = String::from_utf8_lossy(response_body);
return Err(format!( return Err(format!(
"account server rejected sync (HTTP {status}): {detail}" "account server rejected sync (HTTP {status}): {detail}"
)); ));
} }
let envelope: AccountSyncResult = serde_json::from_slice(body) let envelope: AccountSyncResult = serde_json::from_slice(response_body)
.map_err(|error| format!("account server returned invalid JSON: {error}"))?; .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) Ok(envelope.account)
} }
@@ -185,4 +235,108 @@ mod tests {
assert_eq!(selected.unopened_packs, 1); assert_eq!(selected.unopened_packs, 1);
server.join().unwrap(); server.join().unwrap();
} }
/// Serve exactly one `/openfut/account/sync` POST, handing the decoded
/// request text to `inspect` and replying with `body`.
fn serve_once(
inspect: impl FnOnce(&str) + Send + 'static,
body: &'static str,
) -> (u16, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = 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;
}
}
}
inspect(&String::from_utf8_lossy(&request));
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();
});
(port, handle)
}
#[test]
fn discover_omits_the_persona_so_the_server_names_its_own() {
// The point of first-run discovery: the launcher must not send a guessed
// persona, because the server would echo the guess straight back.
let (port, server) = serve_once(
|request| {
assert!(!request.contains("personaId"), "{request}");
assert!(!request.contains("personaName"), "{request}");
},
r#"{"status":"OK","account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC","level":1,"experience":0,"accountFunds":0,"coins":29876776,"unopenedPacks":0}}"#,
);
let config = LauncherConfig {
openfut_server_host: "127.0.0.1".into(),
openfut_account_sync_port: port,
..LauncherConfig::default()
};
// Deliberately an unconfigured account: discovery must work before one
// exists, which is the whole reason it does not call `validate_account`.
assert_eq!(config.fut_persona_id, 0);
let found = discover(&config).unwrap();
assert_eq!(found.persona_id, 33_068_179);
assert_eq!(found.persona_name, "CAGE");
assert_eq!(found.club_name, "OpenFUT");
assert_eq!(found.coins, 29_876_776);
server.join().unwrap();
}
#[test]
fn discover_rejects_a_server_that_names_no_persona() {
// A zero persona would otherwise be written into the config as a real
// account and fail much later, at launch, as a mismatch.
let (port, server) = serve_once(
|_| {},
r#"{"status":"OK","account":{"personaId":0,"personaName":"","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#,
);
let config = LauncherConfig {
openfut_server_host: "127.0.0.1".into(),
openfut_account_sync_port: port,
..LauncherConfig::default()
};
let error = discover(&config).unwrap_err();
assert!(error.contains("no persona"), "{error}");
server.join().unwrap();
}
#[test]
fn sync_refuses_a_server_that_selects_a_different_persona() {
let (port, server) = serve_once(
|_| {},
r#"{"status":"OK","account":{"personaId":999,"personaName":"OTHER","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#,
);
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(),
..LauncherConfig::default()
};
let error = sync(&config).unwrap_err();
assert!(error.contains("999"), "{error}");
server.join().unwrap();
}
} }
+1221 -557
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -106,14 +106,16 @@ pub(crate) fn arming_summary(
pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> { pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
let server = cfg.openfut_server_host.trim(); let server = cfg.openfut_server_host.trim();
if server.is_empty() { if server.is_empty() {
anyhow::bail!("Set the OpenFUT server host in the Config tab before arming."); anyhow::bail!("Set the OpenFUT server host in Settings before arming.");
} }
let ea_ip = cfg.ea_redirect_probe_ip.trim(); let ea_ip = cfg.ea_redirect_probe_ip.trim();
if ea_ip.is_empty() { if ea_ip.is_empty() {
anyhow::bail!("Set the EA redirector IP (Config tab) before arming."); anyhow::bail!("Set the EA redirector IP (Settings) before arming.");
} }
if cfg.ea_hostnames.is_empty() { 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."); anyhow::bail!(
"Add at least one EA hostname (e.g. easw.easports.com) in Settings before arming."
);
} }
let redirector_port = cfg.openfut_blaze_redirector_port; let redirector_port = cfg.openfut_blaze_redirector_port;
let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?; let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?;
+47 -80
View File
@@ -168,26 +168,6 @@ pub struct LauncherConfig {
/// Dead EA hostnames that must resolve to `openfut_server_host`. /// Dead EA hostnames that must resolve to `openfut_server_host`.
#[serde(default)] #[serde(default)]
pub ea_hostnames: Vec<String>, 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 { fn default_https_port() -> u16 {
@@ -271,11 +251,6 @@ impl Default for LauncherConfig {
game_profile: GameProfile::default(), game_profile: GameProfile::default(),
ea_redirect_probe_ip: String::new(), ea_redirect_probe_ip: String::new(),
ea_hostnames: Vec::new(), ea_hostnames: Vec::new(),
fifa17_tools_dir: base
.join("fifa17-recon/tools")
.to_string_lossy()
.into(),
fifa17_python: default_python(),
} }
} }
} }
@@ -354,19 +329,6 @@ impl LauncherConfig {
self.server_config().validate().map_err(|e| e.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 /// Validate every configuration value required by the one-button FIFA 17
/// launch path. Runtime state such as hook deployment is checked by the UI. /// launch path. Runtime state such as hook deployment is checked by the UI.
pub fn validate_launch_config(&self) -> Result<(), String> { pub fn validate_launch_config(&self) -> Result<(), String> {
@@ -380,19 +342,19 @@ impl LauncherConfig {
} else if self.game_launch_command.trim().is_empty() { } else if self.game_launch_command.trim().is_empty() {
return Err( return Err(
"No game configured. Fill in the game profile, or set a launch command, \ "No game configured. Fill in the game profile, or set a launch command, \
in the Config tab." in Settings."
.into(), .into(),
); );
} }
self.validate_local_services() Ok(())
} }
pub fn validate_account(&self) -> Result<(), String> { pub fn validate_account(&self) -> Result<(), String> {
if self.fut_persona_id == 0 { if self.fut_persona_id == 0 {
return Err("No EA persona ID configured. Set the account in the Config tab.".into()); return Err("No account yet. Create one from the Get started tab.".into());
} }
if self.fut_persona_name.trim().is_empty() { if self.fut_persona_name.trim().is_empty() {
return Err("No EA persona name configured. Set the account in the Config tab.".into()); return Err("Account has no persona name. Recreate it from Get started.".into());
} }
if self.fut_account_level == 0 { if self.fut_account_level == 0 {
return Err("EA account level must be at least 1.".into()); return Err("EA account level must be at least 1.".into());
@@ -408,6 +370,21 @@ impl LauncherConfig {
Ok(()) 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 /// 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). /// server isn't validly configured (never emits a loopback fallback).
/// ///
@@ -493,31 +470,7 @@ mod tests {
} }
#[test] #[test]
fn local_services_require_tools_dir_and_python() { fn launch_config_requires_server_account_and_command() {
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(); let mut c = LauncherConfig::default();
assert!(c.validate_launch_config().is_err()); assert!(c.validate_launch_config().is_err());
@@ -530,14 +483,6 @@ mod tests {
.contains("launch command")); .contains("launch command"));
c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into(); 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()); assert!(c.validate_launch_config().is_ok());
} }
@@ -565,8 +510,6 @@ mod tests {
openfut_server_host: "10.10.0.120".into(), openfut_server_host: "10.10.0.120".into(),
fut_persona_id: 1, fut_persona_id: 1,
fut_persona_name: "X".into(), fut_persona_name: "X".into(),
fifa17_tools_dir: "/tmp/tools".into(),
fifa17_python: "/usr/bin/python3".into(),
..LauncherConfig::default() ..LauncherConfig::default()
}; };
c.game_launch_command.clear(); c.game_launch_command.clear();
@@ -594,8 +537,6 @@ mod tests {
openfut_server_host: "10.10.0.120".into(), openfut_server_host: "10.10.0.120".into(),
fut_persona_id: 1, fut_persona_id: 1,
fut_persona_name: "X".into(), 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(), game_launch_command: "/home/u/launch.sh".into(),
..LauncherConfig::default() ..LauncherConfig::default()
}; };
@@ -696,12 +637,38 @@ mod tests {
#[test] #[test]
fn launch_requires_a_valid_ea_account() { fn launch_requires_a_valid_ea_account() {
let mut c = LauncherConfig::default(); let mut c = LauncherConfig::default();
assert!(c.validate_account().unwrap_err().contains("persona ID")); // 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; 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")); assert!(c.validate_account().unwrap_err().contains("persona name"));
c.fut_persona_name = "TEST_USER".into(); c.fut_persona_name = "TEST_USER".into();
assert!(c.account_configured());
assert!(c.validate_account().is_ok()); assert!(c.validate_account().is_ok());
c.fut_account_experience = 1001; c.fut_account_experience = 1001;
assert!(c.validate_account().unwrap_err().contains("XP")); 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");
}
} }
+81 -10
View File
@@ -23,10 +23,12 @@
//! `game_launch_command` remains as an escape hatch: an unconfigured profile //! `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. //! falls back to it, so an existing working setup cannot be broken by upgrading.
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex}; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::config::GameProfile; use crate::config::GameProfile;
@@ -35,14 +37,19 @@ use crate::logs::LogBuffer;
type Log = Arc<Mutex<LogBuffer>>; type Log = Arc<Mutex<LogBuffer>>;
fn say(log: &Log, msg: impl Into<String>) { fn say(log: &Log, msg: impl Into<String>) {
log.lock().unwrap().push(msg.into()); log.lock().push(msg.into());
} }
/// Prepare the prefix, satisfy the licence precondition, and start the game. /// Prepare the prefix, satisfy the licence precondition, and start the game.
/// ///
/// Returns once the game process has been spawned; its output continues to /// Returns once the game process has been spawned; its output continues to
/// stream into `log` on background threads. /// stream into `log` on background threads. `on_exit` fires when the process
pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { /// ends, which is how the launch state machine leaves its Running state.
pub fn launch(
profile: &GameProfile,
log: &Log,
on_exit: impl FnOnce() + Send + 'static,
) -> anyhow::Result<()> {
profile.validate().map_err(anyhow::Error::msg)?; profile.validate().map_err(anyhow::Error::msg)?;
let game_dir = PathBuf::from(&profile.game_dir); let game_dir = PathBuf::from(&profile.game_dir);
@@ -61,6 +68,7 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
for (k, v) in &profile.env { for (k, v) in &profile.env {
cmd.env(k, v); cmd.env(k, v);
} }
cmd.env("WINEDLLOVERRIDES", hook_dll_overrides(&profile.env));
if !profile.wine_prefix.trim().is_empty() { if !profile.wine_prefix.trim().is_empty() {
cmd.env("WINEPREFIX", &profile.wine_prefix); cmd.env("WINEPREFIX", &profile.wine_prefix);
} }
@@ -78,10 +86,39 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
let child = cmd let child = cmd
.spawn() .spawn()
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?; .map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
stream(child, log.clone(), "[launcher] game process exited."); stream(
child,
log.clone(),
"[launcher] game process exited.",
on_exit,
);
Ok(()) Ok(())
} }
/// The `WINEDLLOVERRIDES` value the game must be started with.
///
/// The hook ships as a `version.dll` proxy inside the game directory, and Proton
/// prefers a local DLL over its own builtin ONLY when `WINEDLLOVERRIDES` names it
/// (see `setup::STEAM_LAUNCH_OPTIONS`). Steam users get that from their launch
/// options; when the launcher spawns the runner itself, nothing else supplies it.
///
/// Without it the failure is silent and badly misleading: the hook never loads, so
/// the `openfut.cfg` the launcher just wrote is inert, the game ignores the
/// configured Blaze ports, and `/etc/hosts` quietly routes it to whatever answers
/// on EA's real ports. It looks like a working launch against the configured
/// server while actually talking to a different one.
///
/// A profile that already pins `version=` wins: an operator overriding the hijack
/// deliberately must not be silently overruled.
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
const HOOK: &str = "version=n,b";
match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) {
Some(existing) if existing.contains("version=") => existing.to_string(),
Some(existing) if !existing.is_empty() => format!("{existing};{HOOK}"),
_ => HOOK.to_string(),
}
}
/// Create the Wine prefix's `dosdevices` entries the profile asks for. /// Create the Wine prefix's `dosdevices` entries the profile asks for.
/// ///
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`: /// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
@@ -221,12 +258,17 @@ fn non_empty_file(path: &Path) -> bool {
} }
/// Pump a child's stdout and stderr into the log buffer and reap it. /// 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) { pub fn stream(
mut child: Child,
log: Log,
exit_msg: &'static str,
on_exit: impl FnOnce() + Send + 'static,
) {
if let Some(out) = child.stdout.take() { if let Some(out) = child.stdout.take() {
let buf = Arc::clone(&log); let buf = Arc::clone(&log);
std::thread::spawn(move || { std::thread::spawn(move || {
for line in BufReader::new(out).lines().map_while(Result::ok) { for line in BufReader::new(out).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line); buf.lock().push(line);
} }
}); });
} }
@@ -234,13 +276,14 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
let buf = Arc::clone(&log); let buf = Arc::clone(&log);
std::thread::spawn(move || { std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) { for line in BufReader::new(err).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line); buf.lock().push(line);
} }
}); });
} }
std::thread::spawn(move || { std::thread::spawn(move || {
let _ = child.wait(); let _ = child.wait();
log.lock().unwrap().push(exit_msg.to_string()); log.lock().push(exit_msg.to_string());
on_exit();
}); });
} }
@@ -443,7 +486,35 @@ mod tests {
game_dir: "/definitely/not/here".into(), game_dir: "/definitely/not/here".into(),
..GameProfile::default() ..GameProfile::default()
}; };
let err = launch(&profile, &log()).unwrap_err().to_string(); let err = launch(&profile, &log(), || {}).unwrap_err().to_string();
assert!(err.contains("game_dir does not exist"), "{err}"); assert!(err.contains("game_dir does not exist"), "{err}");
} }
#[test]
fn a_profile_without_overrides_still_gets_the_hook_hijack() {
// The regression this guards: FIFA launched from the launcher ignored the
// configured Blaze ports entirely, because Proton loaded its own builtin
// version.dll and the hook proxy never ran. The launch looked healthy.
assert_eq!(hook_dll_overrides(&BTreeMap::new()), "version=n,b");
}
#[test]
fn unrelated_overrides_are_preserved_and_appended_to() {
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "d3d11=n".to_string())]);
assert_eq!(hook_dll_overrides(&env), "d3d11=n;version=n,b");
}
#[test]
fn an_explicit_version_override_is_never_overruled() {
// An operator disabling the hijack on purpose must win, otherwise the
// setting is a lie.
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "version=b".to_string())]);
assert_eq!(hook_dll_overrides(&env), "version=b");
}
#[test]
fn a_blank_override_is_treated_as_absent_rather_than_appended_to() {
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), " ".to_string())]);
assert_eq!(hook_dll_overrides(&env), "version=n,b");
}
} }
+7 -6
View File
@@ -6,11 +6,12 @@
//! stops, or assumes anything about how the server is hosted; it only asks //! stops, or assumes anything about how the server is hosted; it only asks
//! "can the FIFA client reach it right now?". //! "can the FIFA client reach it right now?".
use parking_lot::Mutex;
use std::{ use std::{
net::{TcpStream, ToSocketAddrs}, net::{TcpStream, ToSocketAddrs},
sync::{ sync::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
Arc, Mutex, Arc,
}, },
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant},
@@ -57,14 +58,14 @@ impl HealthMonitor {
let t_running = Arc::clone(&running); let t_running = Arc::clone(&running);
thread::spawn(move || { thread::spawn(move || {
while t_running.load(Ordering::Relaxed) { while t_running.load(Ordering::Relaxed) {
let target = t_target.lock().unwrap().clone(); let target = t_target.lock().clone();
match target { match target {
None => { None => {
*t_state.lock().unwrap() = HealthState::default(); *t_state.lock() = HealthState::default();
} }
Some((host, port)) => { Some((host, port)) => {
let snapshot = probe(&host, port); let snapshot = probe(&host, port);
*t_state.lock().unwrap() = snapshot; *t_state.lock() = snapshot;
} }
} }
thread::sleep(POLL_INTERVAL); thread::sleep(POLL_INTERVAL);
@@ -81,11 +82,11 @@ impl HealthMonitor {
/// Point the monitor at a new server address (host + bridge port). Passing /// 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. /// None (e.g. no server configured) puts it back into the idle state.
pub fn set_target(&self, target: Option<(String, u16)>) { pub fn set_target(&self, target: Option<(String, u16)>) {
*self.target.lock().unwrap() = target; *self.target.lock() = target;
} }
pub fn snapshot(&self) -> HealthState { pub fn snapshot(&self) -> HealthState {
self.state.lock().unwrap().clone() self.state.lock().clone()
} }
} }
+940
View File
@@ -0,0 +1,940 @@
//! The launch sequence, as an explicit state machine.
//!
//! # Why this exists
//!
//! The launcher used to make the user perform OpenFUT's internal launch order by
//! hand: start LSX, start autopatch, run pre-launch checks, "Arm client", then
//! press a button called *Start Services & Launch Game*. Every one of those is an
//! implementation detail of how FIFA 17 is persuaded to talk to OpenFUT, and
//! getting the order wrong produced failures that surfaced much later as "the
//! game crashed" — autopatch started before `ptrace_scope` was 0 silently does
//! nothing at all.
//!
//! So the sequence lives here, once, and the UI renders it. One button.
//!
//! # Ordering, and where it deviates from the obvious
//!
//! Client preparation (`arm`) runs BEFORE autopatch, not after: autopatch writes
//! `/proc/<FIFA17.exe>/mem`, which Yama forbids until arming sets
//! `kernel.yama.ptrace_scope=0`. Starting autopatch first would "succeed" and
//! then quietly fail to patch anything.
//!
//! # Idempotence
//!
//! Every step asks what is already true before acting. A healthy service is
//! reused, never restarted; client preparation is skipped when the checks it
//! would repair already pass, which also avoids an unnecessary Polkit prompt.
//!
//! # Testability
//!
//! The effects — spawning services, elevating for arming, writing the hook
//! config, starting the game — sit behind [`LaunchOps`]. [`run_sequence`] is
//! therefore a pure decision procedure over observed state, and the sequencing
//! rules that matter (don't launch after a failed step, don't restart healthy
//! services, don't kill what we didn't start) are unit-testable without a FIFA
//! install, a Polkit agent, or root.
use std::sync::Arc;
use crate::config::LauncherConfig;
use crate::fifa17_capability::Fifa17ClientCapabilities;
use crate::local_services::{
CapabilityWiring, Ensured, Service, ServiceRuntime, ServiceSupervisor, SpawnSpec,
};
use crate::logs::LogBuffer;
use crate::preflight::{self, Check, State};
use parking_lot::Mutex;
/// Where the launch sequence is. Rendered directly by the UI; the UI never
/// coordinates services itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Phase {
/// Nothing in flight. Readiness still comes from observed state, not from
/// having been here.
#[default]
Idle,
/// Looking at the world: checks + service + hook state.
Checking,
/// Elevated client preparation in flight (this is what shows a password
/// prompt).
PreparingClient,
StartingServices,
/// Re-checking after repair, before committing to a launch.
Validating,
Launching,
/// FIFA is up. Left when the process exits.
Running,
Failed,
}
impl Phase {
/// Whether a launch is under way, i.e. the primary button must not start a
/// second one.
pub fn busy(self) -> bool {
matches!(
self,
Phase::Checking
| Phase::PreparingClient
| Phase::StartingServices
| Phase::Validating
| Phase::Launching
)
}
}
/// One step of the sequence, in execution order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Step {
Server,
ClientFiles,
ClientPreparation,
Lsx,
Autopatch,
FinalChecks,
Game,
}
impl Step {
/// User-facing name. Deliberately not the internal vocabulary: "arm" is
/// implementation terminology and never appears in the normal flow.
pub fn label(self) -> &'static str {
match self {
Step::Server => "OpenFUT server",
Step::ClientFiles => "Client files",
Step::ClientPreparation => "Client preparation",
Step::Lsx => "LSX",
Step::Autopatch => "Autopatch",
Step::FinalChecks => "Final checks",
Step::Game => "FIFA 17",
}
}
}
/// How a step ended. `Skipped` is a success that did nothing — the state it
/// would have produced was already true.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Done(String),
Skipped(String),
Failed(String),
}
impl Outcome {
pub fn ok(&self) -> bool {
!matches!(self, Outcome::Failed(_))
}
pub fn detail(&self) -> &str {
match self {
Outcome::Done(d) | Outcome::Skipped(d) | Outcome::Failed(d) => d,
}
}
}
/// Everything the UI needs to render the launch surface.
#[derive(Debug, Clone, Default)]
pub struct LaunchState {
pub phase: Phase,
/// Steps attempted by the most recent run, in order.
pub steps: Vec<(Step, Outcome)>,
/// One-line reason the run failed, for the top of the failure card. The
/// per-step detail carries the specifics.
pub failure: Option<String>,
/// The most recent preflight results and when they were taken. Cached
/// because the checks open sockets with timeouts and cannot run per frame.
pub checks: Option<Vec<Check>>,
pub checks_age: Option<std::time::Instant>,
}
impl LaunchState {
fn begin(&mut self, phase: Phase) {
self.phase = phase;
self.steps.clear();
self.failure = None;
}
fn record(&mut self, step: Step, outcome: Outcome) {
if let Outcome::Failed(reason) = &outcome {
self.failure = Some(format!("{}: {reason}", step.label()));
}
self.steps.push((step, outcome));
}
}
/// The effects the sequence performs. Implemented for real by [`RealOps`] and
/// substituted in tests.
pub trait LaunchOps {
/// Confirm the configured OpenFUT server is answering AND select the account
/// for this session. The server is remote by design, so this is a network
/// fact, never "is something local up". Returns a user-facing summary.
fn connect_server(&mut self) -> Result<String, String>;
/// Version.dll + a readable openfut.cfg. `Err` is a hard stop: without them
/// FIFA talks to EA, not OpenFUT.
fn ensure_client_files(&mut self) -> Result<String, String>;
/// Which of the arming-repairable checks are currently failing.
fn run_checks(&mut self) -> Vec<Check>;
/// Elevated client preparation (`arm`). Returns what it changed.
fn prepare_client(&mut self) -> Result<Vec<String>, String>;
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String>;
fn start_game(&mut self) -> Result<(), String>;
}
/// Checks that client preparation is able to repair. A failure in any of these
/// means "prepare the client", not "give up".
fn preparation_repairs(check: &Check) -> bool {
const REPAIRABLE: [&str; 3] = [
"ptrace_scope (autopatch)",
"EA redirector IP is redirected",
"EA hostnames point at OpenFUT",
];
REPAIRABLE.contains(&check.name.as_str())
}
/// Run the whole sequence, publishing progress into `state` as it goes.
///
/// Returns whether FIFA was started. Stops at the first failed step: launching
/// into a known-broken client produces a session that fails minutes later with
/// no message naming the cause, which is precisely the failure mode this
/// launcher exists to prevent.
pub fn run_sequence(ops: &mut dyn LaunchOps, state: &Arc<Mutex<LaunchState>>) -> bool {
macro_rules! step {
($phase:expr, $step:expr, $body:expr) => {{
state.lock().phase = $phase;
let outcome: Outcome = $body;
let ok = outcome.ok();
state.lock().record($step, outcome);
if !ok {
state.lock().phase = Phase::Failed;
return false;
}
}};
}
state.lock().begin(Phase::Checking);
// ── The server, which is remote and not ours to start ────────────────────
step!(Phase::Checking, Step::Server, {
match ops.connect_server() {
Ok(detail) => Outcome::Done(detail),
Err(e) => Outcome::Failed(e),
}
});
// ── The hook the game loads, reconciled with the current settings ────────
step!(Phase::Checking, Step::ClientFiles, {
match ops.ensure_client_files() {
Ok(detail) => Outcome::Done(detail),
Err(e) => Outcome::Failed(e),
}
});
// ── Client preparation, only if something it repairs is broken ───────────
let checks = ops.run_checks();
let broken: Vec<String> = checks
.iter()
.filter(|c| c.state == State::Fail && preparation_repairs(c))
.map(|c| c.name.clone())
.collect();
{
let mut guard = state.lock();
guard.checks = Some(checks);
guard.checks_age = Some(std::time::Instant::now());
}
step!(Phase::PreparingClient, Step::ClientPreparation, {
if broken.is_empty() {
Outcome::Skipped("already prepared".into())
} else {
match ops.prepare_client() {
Ok(changes) => Outcome::Done(format!("{} change(s) applied", changes.len())),
Err(e) => Outcome::Failed(e),
}
}
});
// ── Companion services, in dependency order ─────────────────────────────
for (service, step) in [
(Service::Lsx, Step::Lsx),
(Service::Autopatch, Step::Autopatch),
] {
step!(Phase::StartingServices, step, {
match ops.ensure_service(service) {
Ok(Ensured::Reused) => Outcome::Skipped("already running".into()),
Ok(Ensured::Started) => Outcome::Done("started".into()),
Err(e) => Outcome::Failed(e),
}
});
}
// ── Validate what the repairs were supposed to fix ──────────────────────
step!(Phase::Validating, Step::FinalChecks, {
let checks = ops.run_checks();
let failed: Vec<String> = checks
.iter()
.filter(|c| c.state == State::Fail)
.map(|c| c.name.clone())
.collect();
{
let mut guard = state.lock();
guard.checks = Some(checks);
guard.checks_age = Some(std::time::Instant::now());
}
if failed.is_empty() {
Outcome::Done("all checks pass".into())
} else {
Outcome::Failed(format!("still failing: {}", failed.join(", ")))
}
});
step!(Phase::Launching, Step::Game, {
match ops.start_game() {
Ok(()) => Outcome::Done("started".into()),
Err(e) => Outcome::Failed(e),
}
});
state.lock().phase = Phase::Running;
true
}
/// Observe the world without changing it, for the status rows on open and after
/// a settings change. Shares [`run_sequence`]'s notion of what "ready" means so
/// the two cannot drift apart.
pub fn refresh_checks(ops: &mut dyn LaunchOps, state: &Arc<Mutex<LaunchState>>) {
state.lock().phase = Phase::Checking;
let checks = ops.run_checks();
let mut guard = state.lock();
guard.checks = Some(checks);
guard.checks_age = Some(std::time::Instant::now());
guard.phase = Phase::Idle;
}
/// What happens to launcher-started services when FIFA exits.
///
/// Exists so the answer is a stated policy rather than an oversight. The shipped
/// value stops nothing:
///
/// * The companion services are reusable across launches — LSX has to be holding
/// :4216 before FIFA dials it, and the next launch would only start them again.
/// * A service the launcher did NOT start is never in the stop list under any
/// value of this policy.
///
/// Client preparation is deliberately absent, and is never reverted: it is host
/// state (`ptrace_scope`, a DNAT, `/etc/hosts`) that `client_arm.sh` also leaves
/// set and that every subsequent launch needs. A flag for it would be a flag
/// nothing honours.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CleanupPolicy {
pub stop_launcher_started_services: bool,
}
/// Which services cleanup is allowed to stop after `FIFA` exits: only ones this
/// launcher started, and only if the policy says so.
pub fn services_to_stop(
policy: CleanupPolicy,
runtimes: &[(Service, ServiceRuntime)],
) -> Vec<Service> {
if !policy.stop_launcher_started_services {
return Vec::new();
}
runtimes
.iter()
.filter(|(_, r)| r.running && r.started_by_launcher)
.map(|(s, _)| *s)
.collect()
}
/// Summary of one dependency for the main card.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Readiness {
Ready,
Busy,
Attention,
/// Never looked, or the answer is stale. Never rendered as Ready.
Unknown,
}
/// Client-integration readiness from the cached checks. `Unknown` until a run has
/// actually happened: "we did not look" must not look like "we looked and it was
/// fine".
pub fn client_integration(state: &LaunchState) -> Readiness {
if matches!(state.phase, Phase::PreparingClient) {
return Readiness::Busy;
}
match &state.checks {
None => Readiness::Unknown,
Some(checks) => {
let relevant: Vec<&Check> = checks.iter().filter(|c| preparation_repairs(c)).collect();
if relevant.iter().any(|c| c.state == State::Fail) {
Readiness::Attention
} else if relevant.iter().all(|c| c.state == State::Skipped) {
// Nothing configured to check, so nothing was verified.
Readiness::Unknown
} else {
Readiness::Ready
}
}
}
}
/// Overall readiness for the card's headline pill. Anything short of every
/// dependency being observed-good is not Ready.
pub fn overall(
phase: Phase,
server: Readiness,
integration: Readiness,
services: Readiness,
hook: Readiness,
) -> Readiness {
if phase == Phase::Running {
return Readiness::Ready;
}
if phase.busy() {
return Readiness::Busy;
}
let parts = [server, integration, services, hook];
if parts.contains(&Readiness::Attention) {
Readiness::Attention
} else if parts.contains(&Readiness::Unknown) {
Readiness::Unknown
} else {
Readiness::Ready
}
}
/// [`LaunchOps`] against the actual machine.
///
/// Holds a snapshot of the config: a launch must not change its mind halfway
/// through because the user edited a field while it ran.
pub struct RealOps {
config: LauncherConfig,
services: Arc<Mutex<ServiceSupervisor>>,
logs: Arc<Mutex<LogBuffer>>,
caps: Arc<Mutex<Fifa17ClientCapabilities>>,
state: Arc<Mutex<LaunchState>>,
}
impl RealOps {
fn say(&self, message: impl Into<String>) {
self.logs.lock().push(message.into());
}
}
impl LaunchOps for RealOps {
fn connect_server(&mut self) -> Result<String, String> {
self.config.validate_server()?;
if preflight::backend_reachable(&self.config).state == State::Fail {
return Err(format!(
"{} is not answering — is the OpenFUT server running?",
self.config.openfut_server_host
));
}
// Selecting the account is part of connecting: LSX and FIFA both
// authenticate as this persona, and a launch with the wrong one produces
// a session that looks fine and belongs to nobody.
let account = crate::account_sync::sync(&self.config)?;
self.say(format!(
"[launcher] account synchronized: {}/{} FUT-coins={} unopened-packs={}",
account.persona_id, account.persona_name, account.coins, account.unopened_packs
));
Ok(format!(
"{} · {}",
self.config.openfut_server_host, account.persona_name
))
}
fn ensure_client_files(&mut self) -> Result<String, String> {
let game_dir = std::path::PathBuf::from(&self.config.fifa_game_dir);
if !crate::setup::hook_dll_deployed(&game_dir) {
return Err("network hook is not deployed — use Setup to deploy it".into());
}
// The file the game reads is reconciled here, and only here: this is the
// one moment it is guaranteed to agree with the settings on screen.
let contents = self.config.hook_cfg_contents()?;
crate::setup::update_hook_config(&game_dir, &contents).map_err(|e| {
format!(
"cannot write {} in {}: {e}",
crate::setup::HOOK_CFG_FILE,
self.config.fifa_game_dir
)
})?;
Ok(format!(
"hook → {}:{}",
self.config.openfut_server_host, self.config.openfut_https_port
))
}
fn run_checks(&mut self) -> Vec<Check> {
preflight::run(&self.config)
}
fn prepare_client(&mut self) -> Result<Vec<String>, String> {
match crate::arm::arm(&self.config) {
Ok(changes) => {
for change in &changes {
self.say(format!("[launcher] prepared: {change}"));
}
Ok(changes)
}
Err(e) => Err(e.to_string()),
}
}
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
let spec = SpawnSpec {
persona_id: self.config.fut_persona_id,
persona_name: self.config.fut_persona_name.clone(),
// Only autopatch advertises the verified resolver guard, so only it
// receives the shared capability sink.
capability: match service {
Service::Autopatch => Some(CapabilityWiring {
server_host: self.config.openfut_server_host.clone(),
account_sync_port: self.config.openfut_account_sync_port,
sink: Arc::clone(&self.caps),
}),
Service::Lsx => None,
},
};
self.services.lock().ensure_running(service, spec)
}
fn start_game(&mut self) -> Result<(), String> {
// A new FIFA process starts with UNKNOWN capability: never inherit the
// previous launch's. The autopatch stdout reader repopulates it.
*self.caps.lock() = Default::default();
let state = Arc::clone(&self.state);
let logs = Arc::clone(&self.logs);
let services = Arc::clone(&self.services);
let on_exit = move || {
// Cleanup goes through the policy rather than through habit, so the
// list can never include a service this launcher did not start.
let runtimes: Vec<_> = {
let mut supervisor = services.lock();
[Service::Lsx, Service::Autopatch]
.into_iter()
.map(|s| {
let runtime = supervisor.observe(s);
(s, runtime)
})
.collect()
};
for service in services_to_stop(CleanupPolicy::default(), &runtimes) {
if let Err(e) = services.lock().stop(service) {
logs.lock().push(format!("[launcher] cleanup: {e}"));
}
}
state.lock().phase = Phase::Idle;
logs.lock()
.push("[launcher] FIFA exited; launcher back to Ready.".to_string());
};
// Prefer the native profile; fall back to the user's shell command so an
// existing working setup keeps working after an upgrade.
if self.config.game_profile.configured() {
crate::game_launch::launch(&self.config.game_profile, &self.logs, on_exit)
.map_err(|e| e.to_string())
} else {
crate::setup::launch_game(
&self.config.game_launch_command,
&self.config.game_launch_workdir,
Arc::clone(&self.logs),
on_exit,
)
.map_err(|e| e.to_string())
}
}
}
/// Drives [`run_sequence`] on a worker thread. The UI thread never blocks on a
/// socket, a Polkit prompt or a process spawn.
pub struct Controller {
pub state: Arc<Mutex<LaunchState>>,
pub services: Arc<Mutex<ServiceSupervisor>>,
}
impl Controller {
pub fn new(logs: Arc<Mutex<LogBuffer>>) -> Self {
Self {
state: Arc::new(Mutex::new(LaunchState::default())),
services: Arc::new(Mutex::new(ServiceSupervisor::new(logs))),
}
}
pub fn snapshot(&self) -> LaunchState {
self.state.lock().clone()
}
fn ops(
&self,
config: &LauncherConfig,
logs: &Arc<Mutex<LogBuffer>>,
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
) -> RealOps {
RealOps {
config: config.clone(),
services: Arc::clone(&self.services),
logs: Arc::clone(logs),
caps: Arc::clone(caps),
state: Arc::clone(&self.state),
}
}
/// Start the full sequence. Ignored while one is already in flight or the
/// game is up — the button reflects that state rather than queueing work.
pub fn launch(
&self,
config: &LauncherConfig,
logs: &Arc<Mutex<LogBuffer>>,
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
) {
{
let phase = self.state.lock().phase;
if phase.busy() || phase == Phase::Running {
return;
}
}
let mut ops = self.ops(config, logs, caps);
let state = Arc::clone(&self.state);
std::thread::spawn(move || {
run_sequence(&mut ops, &state);
});
}
/// Re-observe without changing anything, for startup and after a settings
/// change. Skipped while a launch owns the state.
pub fn refresh(
&self,
config: &LauncherConfig,
logs: &Arc<Mutex<LogBuffer>>,
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
) {
{
let phase = self.state.lock().phase;
if phase.busy() || phase == Phase::Running {
return;
}
}
let mut ops = self.ops(config, logs, caps);
let state = Arc::clone(&self.state);
std::thread::spawn(move || {
refresh_checks(&mut ops, &state);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Records what the sequence asked for, and answers however the test wants.
#[derive(Default)]
#[allow(clippy::type_complexity)]
struct FakeOps {
server_up: bool,
client_files: Option<Result<String, String>>,
checks: Vec<Check>,
checks_after_prepare: Option<Vec<Check>>,
prepare_result: Option<Result<Vec<String>, String>>,
service_result: Vec<(Service, Result<Ensured, String>)>,
game_result: Option<Result<(), String>>,
// Observed calls
prepared: usize,
started: Vec<Service>,
game_started: usize,
check_runs: usize,
}
fn check(name: &str, state: State) -> Check {
Check {
name: name.into(),
state,
detail: String::new(),
}
}
fn ready_ops() -> FakeOps {
FakeOps {
server_up: true,
client_files: Some(Ok("deployed".into())),
checks: vec![
check("ptrace_scope (autopatch)", State::Pass),
check("EA redirector IP is redirected", State::Pass),
check("EA hostnames point at OpenFUT", State::Pass),
],
prepare_result: Some(Ok(vec!["one".into()])),
game_result: Some(Ok(())),
..FakeOps::default()
}
}
impl LaunchOps for FakeOps {
fn connect_server(&mut self) -> Result<String, String> {
if self.server_up {
Ok("connected".into())
} else {
Err("not reachable — is the OpenFUT server running?".into())
}
}
fn ensure_client_files(&mut self) -> Result<String, String> {
self.client_files
.clone()
.unwrap_or_else(|| Err("no client-files result configured".into()))
}
fn run_checks(&mut self) -> Vec<Check> {
self.check_runs += 1;
match (&self.checks_after_prepare, self.prepared) {
(Some(after), n) if n > 0 => after.clone(),
_ => self.checks.clone(),
}
}
fn prepare_client(&mut self) -> Result<Vec<String>, String> {
self.prepared += 1;
self.prepare_result
.clone()
.unwrap_or_else(|| Err("no prepare configured".into()))
}
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
self.started.push(service);
self.service_result
.iter()
.find(|(s, _)| *s == service)
.map(|(_, r)| r.clone())
.unwrap_or(Ok(Ensured::Started))
}
fn start_game(&mut self) -> Result<(), String> {
self.game_started += 1;
self.game_result
.clone()
.unwrap_or_else(|| Err("no game result configured".into()))
}
}
fn state() -> Arc<Mutex<LaunchState>> {
Arc::new(Mutex::new(LaunchState::default()))
}
#[test]
fn a_cold_client_is_prepared_and_started_in_dependency_order() {
let mut ops = FakeOps {
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Pass)]),
..ready_ops()
};
let st = state();
assert!(run_sequence(&mut ops, &st));
assert_eq!(
ops.prepared, 1,
"a failing repairable check must be repaired"
);
// Preparation before autopatch: autopatch cannot write FIFA's memory
// until arming has set ptrace_scope, and would silently no-op.
assert_eq!(ops.started, vec![Service::Lsx, Service::Autopatch]);
assert_eq!(ops.game_started, 1);
assert_eq!(st.lock().phase, Phase::Running);
}
#[test]
fn an_already_prepared_client_is_not_prepared_again() {
let mut ops = ready_ops();
let st = state();
assert!(run_sequence(&mut ops, &st));
assert_eq!(ops.prepared, 0, "no password prompt for work already done");
let steps = &st.lock().steps;
let prep = steps
.iter()
.find(|(s, _)| *s == Step::ClientPreparation)
.expect("preparation step recorded")
.1
.clone();
assert!(matches!(prep, Outcome::Skipped(_)), "{prep:?}");
}
#[test]
fn healthy_services_are_reused_rather_than_restarted() {
let mut ops = FakeOps {
service_result: vec![
(Service::Lsx, Ok(Ensured::Reused)),
(Service::Autopatch, Ok(Ensured::Reused)),
],
..ready_ops()
};
let st = state();
assert!(run_sequence(&mut ops, &st));
for step in [Step::Lsx, Step::Autopatch] {
let outcome = st
.lock()
.steps
.iter()
.find(|(s, _)| *s == step)
.expect("service step recorded")
.1
.clone();
assert!(
matches!(outcome, Outcome::Skipped(_)),
"{step:?} {outcome:?}"
);
}
assert_eq!(ops.game_started, 1);
}
#[test]
fn an_unreachable_server_stops_the_launch_before_anything_is_touched() {
let mut ops = FakeOps {
server_up: false,
..ready_ops()
};
let st = state();
assert!(!run_sequence(&mut ops, &st));
assert_eq!(ops.prepared, 0);
assert!(ops.started.is_empty(), "nothing may be started");
assert_eq!(ops.game_started, 0);
assert_eq!(st.lock().phase, Phase::Failed);
assert!(st.lock().failure.as_deref().unwrap().contains("server"));
}
#[test]
fn a_service_that_fails_to_start_stops_the_launch() {
let mut ops = FakeOps {
service_result: vec![(Service::Autopatch, Err("autopatch: boom".into()))],
..ready_ops()
};
let st = state();
assert!(!run_sequence(&mut ops, &st));
assert_eq!(ops.game_started, 0, "FIFA must not start without autopatch");
let failure = st.lock().failure.clone().unwrap();
assert!(failure.contains("Autopatch"), "{failure}");
}
#[test]
fn failed_client_preparation_stops_the_launch() {
let mut ops = FakeOps {
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
prepare_result: Some(Err("pkexec: dismissed".into())),
..ready_ops()
};
let st = state();
assert!(!run_sequence(&mut ops, &st));
assert!(ops.started.is_empty());
assert_eq!(ops.game_started, 0);
}
#[test]
fn a_check_still_failing_after_repair_stops_the_launch() {
// Preparation ran and claimed success, but the state it was supposed to
// fix is still broken. Launching here is how a session dies later with
// no message naming the cause.
let mut ops = FakeOps {
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Fail)]),
..ready_ops()
};
let st = state();
assert!(!run_sequence(&mut ops, &st));
assert_eq!(ops.game_started, 0);
let failure = st.lock().failure.clone().unwrap();
assert!(failure.contains("still failing"), "{failure}");
}
#[test]
fn client_files_failure_stops_the_launch() {
let mut ops = FakeOps {
client_files: Some(Err("cannot write openfut.cfg".into())),
..ready_ops()
};
let st = state();
assert!(!run_sequence(&mut ops, &st));
assert_eq!(ops.game_started, 0);
assert!(ops.started.is_empty());
}
#[test]
fn cleanup_never_stops_a_service_the_launcher_did_not_start() {
let foreign = ServiceRuntime {
running: true,
started_by_launcher: false,
pid: Some(4242),
detail: None,
};
let ours = ServiceRuntime {
running: true,
started_by_launcher: true,
pid: Some(99),
detail: None,
};
let runtimes = [(Service::Lsx, foreign), (Service::Autopatch, ours)];
// Even under the most aggressive policy, a foreign service is untouched.
let aggressive = CleanupPolicy {
stop_launcher_started_services: true,
};
assert_eq!(
services_to_stop(aggressive, &runtimes),
vec![Service::Autopatch]
);
// And the shipped policy keeps both alive for the next launch.
assert!(services_to_stop(CleanupPolicy::default(), &runtimes).is_empty());
}
#[test]
fn readiness_is_never_green_while_a_dependency_is_not() {
assert_eq!(
overall(
Phase::Idle,
Readiness::Ready,
Readiness::Ready,
Readiness::Attention,
Readiness::Ready
),
Readiness::Attention
);
// Never checked is not the same as checked and fine.
assert_eq!(
overall(
Phase::Idle,
Readiness::Ready,
Readiness::Unknown,
Readiness::Ready,
Readiness::Ready
),
Readiness::Unknown
);
assert_eq!(
overall(
Phase::Idle,
Readiness::Ready,
Readiness::Ready,
Readiness::Ready,
Readiness::Ready
),
Readiness::Ready
);
// A running game reports Ready even though a launch is not in flight.
assert_eq!(
overall(
Phase::Running,
Readiness::Unknown,
Readiness::Unknown,
Readiness::Unknown,
Readiness::Unknown
),
Readiness::Ready
);
}
#[test]
fn client_integration_is_unknown_until_checks_have_run() {
let mut st = LaunchState::default();
assert_eq!(client_integration(&st), Readiness::Unknown);
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Fail)]);
assert_eq!(client_integration(&st), Readiness::Attention);
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Pass)]);
assert_eq!(client_integration(&st), Readiness::Ready);
// Only skipped checks means nothing was actually verified.
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Skipped)]);
assert_eq!(client_integration(&st), Readiness::Unknown);
}
}
+428 -79
View File
@@ -13,11 +13,12 @@
//! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous //! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous
//! Polkit prompt delaying cert patching until after FIFA's first TLS attempt. //! Polkit prompt delaying cert patching until after FIFA's first TLS attempt.
use parking_lot::Mutex;
use std::{ use std::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener}, net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
path::Path, path::{Path, PathBuf},
process::{Child, Command, Stdio}, process::{Child, Command, Stdio},
sync::{mpsc, Arc, Mutex}, sync::{mpsc, Arc},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
@@ -28,6 +29,10 @@ use crate::fifa17_capability::{
}; };
use crate::logs::LogBuffer; use crate::logs::LogBuffer;
/// The loopback endpoint LSX must own. FIFA dials this exact address and nothing
/// else, so "is LSX ready?" is answerable without asking LSX anything.
pub const LSX_ADDR: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
struct CommandParts { struct CommandParts {
program: String, program: String,
@@ -35,7 +40,7 @@ struct CommandParts {
} }
/// Which companion service. The `str` values are used in log prefixes. /// Which companion service. The `str` values are used in log prefixes.
#[derive(Copy, Clone, PartialEq, Eq)] #[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Service { pub enum Service {
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged. /// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
Lsx, Lsx,
@@ -51,25 +56,48 @@ impl Service {
} }
} }
/// The responder script filename inside the tools dir. /// The companion's executable name.
fn script(self) -> &'static str { ///
/// These were Python responder scripts run through a configured interpreter. They
/// are now Rust binaries built from this workspace (`openfut-lsx`,
/// `openfut-autopatch`), which removes the interpreter and the tools directory
/// from the launch contract entirely: no `python3` to locate, no script path to
/// configure, and no chance of running a stale checkout's copy.
fn binary(self) -> &'static str {
match self { match self {
Service::Lsx => "lsx_responder_v2.py", Service::Lsx => "openfut-lsx",
Service::Autopatch => "autopatch.py", Service::Autopatch => "openfut-autopatch",
} }
} }
} }
fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts { /// Absolute path to a companion binary.
let mut args = vec![tools_dir ///
.join(service.script()) /// Prefers a sibling of the running launcher, which is what a workspace build and any
.to_string_lossy() /// sane install layout both produce, and falls back to the bare name so a
.into_owned()]; /// PATH-installed binary still works. Returning the bare name rather than failing
/// keeps `spawn` responsible for reporting a missing binary, with one error message
/// instead of two.
fn resolve_binary(service: Service) -> PathBuf {
let name = service.binary();
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(Path::to_path_buf)) {
let sibling = dir.join(name);
if sibling.is_file() {
return sibling;
}
}
PathBuf::from(name)
}
fn command_parts(service: Service) -> CommandParts {
let mut args = Vec::new();
if service == Service::Autopatch { if service == Service::Autopatch {
// autopatch exits when the launcher does, so it cannot outlive its owner and
// keep writing to a client the launcher no longer manages.
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]); args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
} }
CommandParts { CommandParts {
program: python.to_string(), program: resolve_binary(service).to_string_lossy().into_owned(),
args, args,
} }
} }
@@ -138,22 +166,19 @@ impl ManagedService {
if let Some(result) = self.stopping.as_ref() { if let Some(result) = self.stopping.as_ref() {
match result.try_recv() { match result.try_recv() {
Ok(Ok(())) => { Ok(Ok(())) => {
log.lock() log.lock().push(format!("[launcher] {label} stopped."));
.unwrap()
.push(format!("[launcher] {label} stopped."));
self.stopping = None; self.stopping = None;
return false; return false;
} }
Ok(Err(error)) => { Ok(Err(error)) => {
log.lock() log.lock()
.unwrap()
.push(format!("[launcher] failed to stop {label}: {error}")); .push(format!("[launcher] failed to stop {label}: {error}"));
self.stopping = None; self.stopping = None;
return false; return false;
} }
Err(mpsc::TryRecvError::Empty) => return true, Err(mpsc::TryRecvError::Empty) => return true,
Err(mpsc::TryRecvError::Disconnected) => { Err(mpsc::TryRecvError::Disconnected) => {
log.lock().unwrap().push(format!( log.lock().push(format!(
"[launcher] {label} stop worker exited unexpectedly." "[launcher] {label} stop worker exited unexpectedly."
)); ));
self.stopping = None; self.stopping = None;
@@ -168,7 +193,6 @@ impl ManagedService {
Ok(None) => true, Ok(None) => true,
Ok(Some(status)) => { Ok(Some(status)) => {
log.lock() log.lock()
.unwrap()
.push(format!("[launcher] {label} exited ({status}).")); .push(format!("[launcher] {label} exited ({status})."));
self.child = None; self.child = None;
false false
@@ -182,6 +206,11 @@ impl ManagedService {
self.stopping.is_some() self.stopping.is_some()
} }
/// PID of the child this launcher owns, if it owns one.
pub fn pid(&self) -> Option<u32> {
self.child.as_ref().map(Child::id)
}
/// Begin stopping the service without waiting on the egui UI thread. /// Begin stopping the service without waiting on the egui UI thread.
pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) { pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) {
if self.stopping.is_some() { if self.stopping.is_some() {
@@ -189,9 +218,7 @@ impl ManagedService {
} }
if let Some(mut child) = self.child.take() { if let Some(mut child) = self.child.take() {
let label = service.label(); let label = service.label();
log.lock() log.lock().push(format!("[launcher] stopping {label}…"));
.unwrap()
.push(format!("[launcher] stopping {label}…"));
self.stopping = Some(dispatch_stop_work(move || { self.stopping = Some(dispatch_stop_work(move || {
child child
@@ -224,17 +251,248 @@ pub struct CapabilityWiring {
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>, pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
} }
/// Spawn a companion service. `python` is the interpreter, `tools_dir` the /// What is actually true about one companion service right now.
/// directory holding the responder scripts. Streams stdout+stderr into `log`. ///
/// Returns an error (without spawning) if the tools dir or script is missing. /// Deliberately observed, never remembered: a button press is not evidence that
/// a service is up, and a service that died on its own must not keep showing
/// green because the launcher once started it successfully.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ServiceRuntime {
pub running: bool,
/// True only while THIS launcher owns the live process. Decides whether
/// cleanup is allowed to touch it: a service someone started by hand for a
/// debugging session must survive a launch/exit cycle.
pub started_by_launcher: bool,
pub pid: Option<u32>,
/// Observed supporting detail for the Advanced panel. Only ever facts the
/// launcher actually established.
pub detail: Option<String>,
}
impl ServiceRuntime {
/// Whether this service is usable for a launch, as opposed to merely alive.
/// For LSX that means the port FIFA dials is genuinely held.
pub fn ready(&self) -> bool {
self.running
}
}
/// True when something holds LSX's fixed loopback port.
pub fn lsx_port_busy() -> bool {
match TcpListener::bind(LSX_ADDR) {
Err(error) => error.kind() == std::io::ErrorKind::AddrInUse,
Ok(listener) => {
drop(listener);
false
}
}
}
/// PID of a process running `service`'s companion binary that this launcher does
/// not own, if there is one.
///
/// Scans `/proc` — no extra dependency, no privilege, and no guessing: a service
/// left running by a previous launcher instance or started by hand from a shell
/// is a real state the UI has to be able to report, and cleanup has to respect.
///
/// Matches argv entries rather than `comm`, because `comm` is truncated to 15
/// characters by the kernel and would misreport these names.
pub fn foreign_pid(service: Service, ours: Option<u32>) -> Option<u32> {
let binary = service.binary();
let self_pid = std::process::id();
let entries = std::fs::read_dir("/proc").ok()?;
for entry in entries.flatten() {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
continue;
};
if pid == self_pid || Some(pid) == ours {
continue;
}
let Ok(cmdline) = std::fs::read(entry.path().join("cmdline")) else {
continue;
};
if cmdline.split(|b| *b == 0).any(|arg| {
// Compare the file name, so `/path/to/openfut-lsx` matches while an
// unrelated argument that merely ends with the same text does not.
Path::new(&*String::from_utf8_lossy(arg))
.file_name()
.is_some_and(|n| n == binary)
}) {
return Some(pid);
}
}
None
}
/// Whether a stop request may touch this service.
///
/// Pure, so the ownership rule is testable without a process: refusing to kill
/// something the launcher did not start is the whole reason ownership is tracked,
/// and it must not depend on what happens to be running on the test machine.
pub fn stop_permitted(runtime: &ServiceRuntime, label: &str) -> Result<(), String> {
if runtime.running && !runtime.started_by_launcher {
return Err(format!(
"{label} was started outside this launcher{} — stop it where it was started.",
match runtime.pid {
Some(pid) => format!(" (pid {pid})"),
None => String::new(),
}
));
}
Ok(())
}
/// Owns both companion services and answers "what is running, and who started
/// it?" for the whole launcher.
///
/// Exists so the launch sequence and the Advanced panel act on the same objects.
/// Two independent copies of that state is how a UI ends up claiming Ready while
/// the process is dead.
pub struct ServiceSupervisor {
lsx: ManagedService,
autopatch: ManagedService,
log: Arc<Mutex<LogBuffer>>,
}
/// Whether [`ServiceSupervisor::ensure_running`] had to do anything.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ensured {
/// Already up — left strictly alone.
Reused,
Started,
}
impl ServiceSupervisor {
pub fn new(log: Arc<Mutex<LogBuffer>>) -> Self {
Self {
lsx: ManagedService::default(),
autopatch: ManagedService::default(),
log,
}
}
fn slot(&mut self, service: Service) -> &mut ManagedService {
match service {
Service::Lsx => &mut self.lsx,
Service::Autopatch => &mut self.autopatch,
}
}
/// Observe one service: our own child first, then any foreign instance.
pub fn observe(&mut self, service: Service) -> ServiceRuntime {
let log = Arc::clone(&self.log);
let slot = self.slot(service);
if slot.stopping() {
return ServiceRuntime {
running: true,
started_by_launcher: true,
pid: None,
detail: Some("stopping".into()),
};
}
let ours = slot.pid();
if slot.running(&log, service.label()) {
let mut runtime = ServiceRuntime {
running: true,
started_by_launcher: true,
pid: ours,
detail: None,
};
if service == Service::Lsx {
runtime.detail = Some(if lsx_port_busy() {
format!("holding {LSX_ADDR}")
} else {
// Alive but not listening: real, and not "ready".
runtime.running = false;
format!("process alive but {LSX_ADDR} is not held")
});
}
return runtime;
}
match foreign_pid(service, ours) {
Some(pid) => ServiceRuntime {
running: true,
started_by_launcher: false,
pid: Some(pid),
detail: Some("started outside this launcher".into()),
},
None if service == Service::Lsx && lsx_port_busy() => ServiceRuntime {
running: false,
started_by_launcher: false,
pid: None,
detail: Some(format!("{LSX_ADDR} is held by an unrelated process")),
},
None => ServiceRuntime::default(),
}
}
/// Start `service` only if it is not already usable. Never restarts a healthy
/// service, and never adopts a foreign one as ours.
pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result<Ensured, String> {
let runtime = self.observe(service);
if runtime.ready() {
self.log.lock().push(format!(
"[launcher] {} already running{} — reusing it.",
service.label(),
match runtime.pid {
Some(pid) => format!(" (pid {pid})"),
None => String::new(),
}
));
return Ok(Ensured::Reused);
}
if let Some(detail) = runtime.detail.filter(|_| !runtime.running) {
// No service-name prefix: every caller already renders the service it
// asked about, and the launch card would print "LSX: LSX: …".
return Err(detail);
}
let child = spawn(
service,
spec.persona_id,
&spec.persona_name,
spec.capability,
Arc::clone(&self.log),
)
.map_err(|e| e.to_string())?;
*self.slot(service) = ManagedService::from_child(child);
Ok(Ensured::Started)
}
/// Stop a service the launcher owns. A foreign process is reported, never
/// killed: the launcher did not start it and does not know who needs it.
pub fn stop(&mut self, service: Service) -> Result<(), String> {
let runtime = self.observe(service);
stop_permitted(&runtime, service.label())?;
let log = Arc::clone(&self.log);
self.slot(service).stop(&log, service);
Ok(())
}
pub fn stopping(&mut self, service: Service) -> bool {
self.slot(service).stopping()
}
}
/// Everything [`spawn`] needs, bundled so the launch sequence can hand it over
/// as one value per service.
pub struct SpawnSpec {
pub persona_id: u64,
pub persona_name: String,
pub capability: Option<CapabilityWiring>,
}
/// Spawn a companion service and stream its stdout+stderr into `log`.
///
/// Returns an error without spawning if the binary is missing, which is the only
/// precondition left now that the companions are workspace binaries rather than
/// Python scripts run from a configured tools directory.
/// ///
/// `capability` is the backend-registration wiring + shared per-FIFA-process /// `capability` is the backend-registration wiring + shared per-FIFA-process
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the /// capability sink — `Some(..)` for autopatch (whose stdout advertises the
/// verified resolver guard) and `None` for LSX. /// verified resolver guard) and `None` for LSX.
pub fn spawn( pub fn spawn(
service: Service, service: Service,
python: &str,
tools_dir: &str,
persona_id: u64, persona_id: u64,
persona_name: &str, persona_name: &str,
capability: Option<CapabilityWiring>, capability: Option<CapabilityWiring>,
@@ -242,35 +500,28 @@ pub fn spawn(
) -> anyhow::Result<Child> { ) -> anyhow::Result<Child> {
use std::io::{BufRead, BufReader}; 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(); let label = service.label();
let parts = command_parts(service);
let program = Path::new(&parts.program);
// Only a resolved absolute path can be checked up front; a bare name is left to
// the OS to resolve through PATH, and a failure there is reported by spawn below.
if program.is_absolute() && !program.is_file() {
anyhow::bail!(
"{label} binary not found: {} — build the workspace so it sits beside the launcher",
program.display()
);
}
// 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); let mut cmd = Command::new(&parts.program);
cmd.args(&parts.args); cmd.args(&parts.args);
if service == Service::Lsx { if service == Service::Lsx {
// The persona LSX reports has to equal what Blaze returns in
// LoginResponse.SESS.PDTL and what UTAS serves as userInfo.personaId; the
// constraint is cross-layer agreement, not any particular value.
cmd.env("FUT_PERSONA_ID", persona_id.to_string()) cmd.env("FUT_PERSONA_ID", persona_id.to_string())
.env("FUT_PERSONA_NAME", persona_name); .env("FUT_PERSONA_NAME", persona_name);
} else if service == Service::Autopatch { } else if service == Service::Autopatch {
// A per-user runtime log, so a stale root-owned /tmp file cannot block startup.
let log_path = std::env::var_os("XDG_RUNTIME_DIR") let log_path = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from) .map(std::path::PathBuf::from)
.unwrap_or_else(std::env::temp_dir) .unwrap_or_else(std::env::temp_dir)
@@ -279,19 +530,24 @@ pub fn spawn(
} }
// Put each companion in its own process group for lifecycle isolation. // Put each companion in its own process group for lifecycle isolation.
cmd.process_group(0); cmd.process_group(0);
cmd.current_dir(dir) cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
.stdout(Stdio::piped())
.stderr(Stdio::piped());
log.lock().unwrap().push(format!( log.lock().push(format!(
"[launcher] starting {label}: {} {}", "[launcher] starting {label}: {}{}",
python, parts.program,
script_path.display(), parts
.args
.iter()
.fold(String::new(), |mut acc, a| {
acc.push(' ');
acc.push_str(a);
acc
}),
)); ));
let mut child = cmd let mut child = cmd
.spawn() .spawn()
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.script()))?; .map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.binary()))?;
if let Some(out) = child.stdout.take() { if let Some(out) = child.stdout.take() {
let buf = Arc::clone(&log); let buf = Arc::clone(&log);
@@ -304,7 +560,7 @@ pub fn spawn(
let mut registered = false; let mut registered = false;
for line in BufReader::new(out).lines().map_while(Result::ok) { for line in BufReader::new(out).lines().map_while(Result::ok) {
// Every raw line is still mirrored into the log, as before. // Every raw line is still mirrored into the log, as before.
buf.lock().unwrap().push(format!("[{lbl}] {line}")); buf.lock().push(format!("[{lbl}] {line}"));
let Some(wiring) = cap_wiring.as_ref() else { let Some(wiring) = cap_wiring.as_ref() else {
continue; continue;
@@ -317,9 +573,9 @@ pub fn spawn(
}; };
registered = true; registered = true;
let fifa_pid = parse_fifa_pid(&line).unwrap_or(0); let fifa_pid = parse_fifa_pid(&line).unwrap_or(0);
wiring.sink.lock().unwrap().empty_mypacks_resolver = Some(version); wiring.sink.lock().empty_mypacks_resolver = Some(version);
{ {
let mut log = buf.lock().unwrap(); let mut log = buf.lock();
log.push(format!( log.push(format!(
"[fifa17] resolver capability verified for FIFA pid {fifa_pid}" "[fifa17] resolver capability verified for FIFA pid {fifa_pid}"
)); ));
@@ -336,11 +592,9 @@ pub fn spawn(
) { ) {
Ok(()) => buf Ok(()) => buf
.lock() .lock()
.unwrap()
.push("[fifa17] capability registered with backend".to_string()), .push("[fifa17] capability registered with backend".to_string()),
Err(error) => buf Err(error) => buf
.lock() .lock()
.unwrap()
.push(format!("[fifa17] capability registration failed: {error}")), .push(format!("[fifa17] capability registration failed: {error}")),
} }
} }
@@ -351,20 +605,19 @@ pub fn spawn(
let lbl = label.to_string(); let lbl = label.to_string();
std::thread::spawn(move || { std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) { for line in BufReader::new(err).lines().map_while(Result::ok) {
buf.lock().unwrap().push(format!("[{lbl}] {line}")); buf.lock().push(format!("[{lbl}] {line}"));
} }
}); });
} }
if service == Service::Lsx { if service == Service::Lsx {
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216)); let address = LSX_ADDR;
if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) { if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) {
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
return Err(error); return Err(error);
} }
log.lock() log.lock()
.unwrap()
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string()); .push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
} }
@@ -376,27 +629,38 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn lsx_runs_python_directly() { fn lsx_runs_its_own_binary_with_no_arguments() {
let parts = command_parts(Service::Lsx, "/usr/bin/python3", Path::new("/tmp/tools")); let parts = command_parts(Service::Lsx);
assert_eq!(parts.program, "/usr/bin/python3"); assert_eq!(
assert_eq!(parts.args, vec!["/tmp/tools/lsx_responder_v2.py"]); Path::new(&parts.program).file_name().unwrap(),
"openfut-lsx"
);
assert!(parts.args.is_empty(), "{:?}", parts.args);
} }
#[test] #[test]
fn autopatch_runs_python_directly_with_launcher_ownership() { fn autopatch_runs_its_own_binary_with_launcher_ownership() {
let parts = command_parts( let parts = command_parts(Service::Autopatch);
Service::Autopatch, assert_eq!(
"/usr/bin/python3", Path::new(&parts.program).file_name().unwrap(),
Path::new("/tmp/tools"), "openfut-autopatch"
); );
assert_eq!(parts.program, "/usr/bin/python3"); // The launcher pid is how autopatch learns to exit with its owner.
assert_eq!( assert_eq!(
parts.args, parts.args,
vec![ vec!["--launcher-pid", &std::process::id().to_string()]
"/tmp/tools/autopatch.py", );
"--launcher-pid", }
&std::process::id().to_string(),
] #[test]
fn a_companion_binary_is_looked_up_by_file_name_not_a_suffix_match() {
// Guards the foreign-process scan: an argv entry that merely ends with the
// binary name (a log path, say) must not be mistaken for the service.
assert_eq!(Service::Lsx.binary(), "openfut-lsx");
assert_eq!(Service::Autopatch.binary(), "openfut-autopatch");
assert_eq!(
Path::new("/var/log/my-openfut-lsx").file_name().unwrap(),
"my-openfut-lsx"
); );
} }
@@ -426,4 +690,89 @@ mod tests {
.expect_err("exited child must not be reported ready"); .expect_err("exited child must not be reported ready");
assert!(error.to_string().contains("exited before becoming ready")); assert!(error.to_string().contains("exited before becoming ready"));
} }
fn supervisor() -> ServiceSupervisor {
ServiceSupervisor::new(Arc::new(Mutex::new(LogBuffer::new())))
}
#[test]
fn a_service_this_launcher_never_started_is_never_reported_as_ours() {
// The old model only knew about children it spawned, so it could not tell
// "stopped" from "running, but not mine". Note this box may genuinely have
// a foreign responder running — that is a real observation, and the
// invariant is about ownership, not about it being absent.
let mut sup = supervisor();
let runtime = sup.observe(Service::Autopatch);
assert!(
!runtime.started_by_launcher,
"nothing was spawned here, so nothing may claim launcher ownership"
);
}
#[test]
fn a_launcher_owned_child_is_observed_as_ours_and_reaped_when_it_dies() {
let mut sup = supervisor();
let child = Command::new("sh")
.args(["-c", "sleep 30"])
.spawn()
.expect("spawn long-lived child");
let pid = child.id();
sup.autopatch = ManagedService::from_child(child);
let runtime = sup.observe(Service::Autopatch);
assert!(runtime.running);
assert!(runtime.started_by_launcher, "we spawned it");
assert_eq!(runtime.pid, Some(pid));
// Stopping is allowed precisely because it is ours.
sup.stop(Service::Autopatch).expect("ours to stop");
}
#[test]
fn stopping_a_foreign_service_is_refused_rather_than_killing_it() {
// A service someone started by hand for a debugging session must survive a
// launch/exit cycle, and the refusal has to say where to stop it. Asserted
// on the pure rule so it holds regardless of what this machine is running.
let foreign = ServiceRuntime {
running: true,
started_by_launcher: false,
pid: Some(4242),
detail: None,
};
let error = stop_permitted(&foreign, "autopatch").unwrap_err();
assert!(error.contains("started outside this launcher"), "{error}");
assert!(error.contains("4242"), "{error}");
let ours = ServiceRuntime {
running: true,
started_by_launcher: true,
pid: Some(99),
detail: None,
};
assert!(stop_permitted(&ours, "autopatch").is_ok());
// Stopping something that is not running is a harmless no-op.
assert!(stop_permitted(&ServiceRuntime::default(), "autopatch").is_ok());
assert!(
crate::launch::services_to_stop(
crate::launch::CleanupPolicy {
stop_launcher_started_services: true,
},
&[(Service::Autopatch, foreign)],
)
.is_empty(),
"a foreign service is never in the stop list"
);
}
#[test]
fn foreign_pid_ignores_the_launcher_process_itself() {
// The scan matches on the responder script name; this process is not one,
// and must never be reported as a service.
assert_ne!(foreign_pid(Service::Lsx, None), Some(std::process::id()));
assert_ne!(
foreign_pid(Service::Autopatch, None),
Some(std::process::id())
);
}
} }
+2 -1
View File
@@ -1,11 +1,12 @@
mod account_sync;
mod account_monitor; mod account_monitor;
mod account_sync;
mod app; mod app;
mod arm; mod arm;
mod config; mod config;
mod fifa17_capability; mod fifa17_capability;
mod game_launch; mod game_launch;
mod health; mod health;
mod launch;
mod local_services; mod local_services;
mod logs; mod logs;
mod netcheck; mod netcheck;
+122 -36
View File
@@ -88,10 +88,11 @@ impl Check {
/// Run every applicable check. Order is the order the game exercises them. /// Run every applicable check. Order is the order the game exercises them.
pub fn run(cfg: &LauncherConfig) -> Vec<Check> { pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![ vec![
ptrace_scope(cfg), ptrace_scope(),
ea_redirect(cfg), ea_redirect(cfg),
hostname_mapping(cfg), hostname_mapping(cfg),
backend_reachable(cfg), backend_reachable(cfg),
hook_config(cfg),
] ]
} }
@@ -108,16 +109,12 @@ pub fn warnings(checks: &[Check]) -> usize {
/// autopatch writes to FIFA's process memory; Yama blocks that unless /// 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 /// `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. /// its TLS handshake much later, with no message naming the cause.
fn ptrace_scope(cfg: &LauncherConfig) -> Check { ///
/// Unconditional. autopatch is a workspace binary that ships alongside the
/// launcher, so there is no configuration that could make this inapplicable —
/// every launch runs it.
fn ptrace_scope() -> Check {
const NAME: &str = "ptrace_scope (autopatch)"; 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) { match std::fs::read_to_string(PTRACE_SCOPE) {
Ok(v) => ptrace_verdict(&v), Ok(v) => ptrace_verdict(&v),
// Not every kernel has Yama. Absent means unenforced, which is what we want. // Not every kernel has Yama. Absent means unenforced, which is what we want.
@@ -238,7 +235,7 @@ fn hostname_mapping(cfg: &LauncherConfig) -> Check {
} }
/// The server side of the same question: are the ports the game will use open? /// The server side of the same question: are the ports the game will use open?
fn backend_reachable(cfg: &LauncherConfig) -> Check { pub(crate) fn backend_reachable(cfg: &LauncherConfig) -> Check {
const NAME: &str = "OpenFUT server reachable"; const NAME: &str = "OpenFUT server reachable";
let host = cfg.openfut_server_host.trim(); let host = cfg.openfut_server_host.trim();
if host.is_empty() { if host.is_empty() {
@@ -261,6 +258,50 @@ fn backend_reachable(cfg: &LauncherConfig) -> Check {
} }
} }
/// The deployed `openfut.cfg` is the only server address the *game* can see.
///
/// Every panel in this launcher reads the in-memory config, so a settings change
/// that never reached the file produces the worst possible failure: the UI shows
/// the new server online while FIFA connects to the old one. Compare the two.
fn hook_config(cfg: &LauncherConfig) -> Check {
const NAME: &str = "Hook server address";
let game_dir = cfg.fifa_game_dir.trim();
if game_dir.is_empty() {
return Check::skip(NAME, "no FIFA game dir configured");
}
let Some(body) = crate::setup::read_hook_config(std::path::Path::new(game_dir)) else {
return Check::skip(
NAME,
format!("no {} deployed yet", crate::setup::HOOK_CFG_FILE),
);
};
let deployed = match openfut_common::ServerConfig::parse(&body) {
Ok(parsed) => parsed,
// Unparseable means the hook cannot read it either, and nothing else in
// the stack recovers from that — so this one is a genuine failure.
Err(e) => {
return Check::fail(
NAME,
format!("{} is unreadable: {e}", crate::setup::HOOK_CFG_FILE),
)
}
};
let wanted = cfg.server_config();
if deployed == wanted {
return Check::pass(NAME, format!("hook redirects to {}", wanted.host));
}
// Warn, not fail: the launch path rewrites this file before starting the
// game, so the drift is real but already covered. Naming both addresses is
// what makes it actionable.
Check::warn(
NAME,
format!(
"deployed hook still points at {} (settings say {}) — launching rewrites it",
deployed.host, wanted.host
),
)
}
fn connects(host: &str, port: u16) -> bool { fn connects(host: &str, port: u16) -> bool {
match (host, port).to_socket_addrs() { match (host, port).to_socket_addrs() {
Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()), Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()),
@@ -289,13 +330,21 @@ mod tests {
#[test] #[test]
fn an_unconfigured_launcher_skips_rather_than_passes() { fn an_unconfigured_launcher_skips_rather_than_passes() {
// The distinction that matters: a fresh config must not display four // The distinction that matters: a fresh config must not display a column
// green ticks. "Not checked" is not "checked and fine". // of green ticks. "Not checked" is not "checked and fine".
//
// `ptrace_scope` is excluded because it is no longer configuration
// dependent: it reads this machine's Yama setting and reports a real
// verdict either way. `only_ptrace_scope_zero_lets_autopatch_work`
// covers it.
let mut c = cfg(); let mut c = cfg();
// `default()` points this at a conventional path whose existence varies // `default()` points this at a conventional path whose existence varies
// by machine. Pin it so the assertion is about the code, not this box. // by machine. Pin it so the assertion is about the code, not this box.
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into(); c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
let checks = run(&c); let checks: Vec<Check> = run(&c)
.into_iter()
.filter(|k| k.name != "ptrace_scope (autopatch)")
.collect();
assert!( assert!(
checks.iter().all(|k| k.state == State::Skipped), checks.iter().all(|k| k.state == State::Skipped),
"{checks:#?}" "{checks:#?}"
@@ -314,16 +363,6 @@ mod tests {
assert!(ptrace_verdict("1").detail.contains("Arm client")); 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] #[test]
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() { fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
let mut c = cfg(); let mut c = cfg();
@@ -356,15 +395,24 @@ mod tests {
/// A shadowed hostname must not be counted as a reason to expect failure. /// A shadowed hostname must not be counted as a reason to expect failure.
/// This is the exact case the first version got wrong. /// This is the exact case the first version got wrong.
///
/// Asserts the hostname check itself rather than counting states across the
/// whole run: `backend_reachable` opens real sockets, so an aggregate count
/// silently asserts that THIS machine has the OpenFUT ports open. That made
/// the test pass only on the server host and fail on the game machine, which
/// is precisely where someone building the launcher runs the suite.
#[test] #[test]
fn a_shadowed_hostname_is_a_warning_not_a_failure() { fn a_shadowed_hostname_is_a_warning_not_a_failure() {
let mut c = cfg(); let mut c = cfg();
c.openfut_server_host = "127.0.0.2".into(); c.openfut_server_host = "127.0.0.2".into();
c.ea_hostnames = vec!["localhost".into()]; c.ea_hostnames = vec!["localhost".into()];
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into(); let check = hostname_mapping(&c);
let checks = run(&c); assert_eq!(check.state, State::Warn, "{}", check.detail);
assert_eq!(failures(&checks), 0, "must not be reported as fatal"); assert!(
assert_eq!(warnings(&checks), 1); check.detail.contains("localhost"),
"the warning must name the shadowed host: {}",
check.detail
);
} }
#[test] #[test]
@@ -377,13 +425,6 @@ mod tests {
assert_eq!(hostname_mapping(&c).state, State::Pass); 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] #[test]
fn a_dead_backend_port_is_reported_as_a_failure() { fn a_dead_backend_port_is_reported_as_a_failure() {
let mut c = cfg(); let mut c = cfg();
@@ -395,4 +436,49 @@ mod tests {
assert_eq!(check.state, State::Fail, "{}", check.detail); assert_eq!(check.state, State::Fail, "{}", check.detail);
assert!(check.detail.contains("no answer on"), "{}", check.detail); assert!(check.detail.contains("no answer on"), "{}", check.detail);
} }
/// A temp game dir holding one `openfut.cfg` body.
fn game_dir_with_cfg(tag: &str, body: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("openfut-preflight-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(crate::setup::HOOK_CFG_FILE), body).unwrap();
dir
}
#[test]
fn a_stale_hook_config_is_reported_and_names_both_addresses() {
// The silent failure this check exists for: settings changed, the file
// the game reads did not.
let mut c = cfg();
c.openfut_server_host = "10.0.0.2".into();
let old = openfut_common::ServerConfig {
host: "10.0.0.1".into(),
ports: c.server_config().ports,
};
let dir = game_dir_with_cfg("stale", &old.to_cfg_string());
c.fifa_game_dir = dir.to_string_lossy().into_owned();
let check = hook_config(&c);
assert_eq!(check.state, State::Warn, "{}", check.detail);
assert!(check.detail.contains("10.0.0.1"), "{}", check.detail);
assert!(check.detail.contains("10.0.0.2"), "{}", check.detail);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn a_hook_config_matching_settings_passes() {
let mut c = cfg();
c.openfut_server_host = "10.0.0.2".into();
let dir = game_dir_with_cfg("fresh", &c.server_config().to_cfg_string());
c.fifa_game_dir = dir.to_string_lossy().into_owned();
assert_eq!(hook_config(&c).state, State::Pass);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn a_missing_hook_config_is_skipped_not_passed() {
let mut c = cfg();
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
assert_eq!(hook_config(&c).state, State::Skipped);
}
} }
+23 -9
View File
@@ -67,6 +67,9 @@ pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> {
// ── DLL hook deployment ─────────────────────────────────────────────────────── // ── DLL hook deployment ───────────────────────────────────────────────────────
/// The file the injected hook reads its server address from, in the game dir.
pub const HOOK_CFG_FILE: &str = "openfut.cfg";
/// Deploy openfut_hook.dll into the FIFA 23 game directory and write /// Deploy openfut_hook.dll into the FIFA 23 game directory and write
/// openfut.cfg with the structured server configuration the hook reads. /// openfut.cfg with the structured server configuration the hook reads.
/// `cfg_contents` must be the full `openfut.cfg` body (see /// `cfg_contents` must be the full `openfut.cfg` body (see
@@ -85,14 +88,14 @@ pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> a
} }
std::fs::create_dir_all(game_dir)?; std::fs::create_dir_all(game_dir)?;
std::fs::copy(dll_src, game_dir.join("version.dll"))?; std::fs::copy(dll_src, game_dir.join("version.dll"))?;
std::fs::write(game_dir.join("openfut.cfg"), cfg_contents)?; std::fs::write(game_dir.join(HOOK_CFG_FILE), cfg_contents)?;
Ok(()) Ok(())
} }
/// Update only openfut.cfg without redeploying the DLL. `cfg_contents` is the /// Update only openfut.cfg without redeploying the DLL. `cfg_contents` is the
/// full structured `openfut.cfg` body. /// full structured `openfut.cfg` body.
pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> { pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
let cfg = game_dir.join("openfut.cfg"); let cfg = game_dir.join(HOOK_CFG_FILE);
if !cfg.exists() { if !cfg.exists() {
anyhow::bail!("Hook DLL not deployed yet — deploy first."); anyhow::bail!("Hook DLL not deployed yet — deploy first.");
} }
@@ -100,6 +103,15 @@ pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result
Ok(()) Ok(())
} }
/// Read the `openfut.cfg` the hook will actually load, if one is deployed.
///
/// The launcher's own health and account requests are built from the in-memory
/// config, but the *game* only ever sees this file. Reading it back is the only
/// way to tell whether the two agree.
pub fn read_hook_config(game_dir: &Path) -> Option<String> {
std::fs::read_to_string(game_dir.join(HOOK_CFG_FILE)).ok()
}
/// Remove the deployed hook DLL from the FIFA game directory. /// Remove the deployed hook DLL from the FIFA game directory.
pub fn remove_hook_dll(game_dir: &Path) -> anyhow::Result<()> { pub fn remove_hook_dll(game_dir: &Path) -> anyhow::Result<()> {
let dest = game_dir.join("version.dll"); let dest = game_dir.join("version.dll");
@@ -127,13 +139,14 @@ pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %comman
pub fn launch_game( pub fn launch_game(
command: &str, command: &str,
workdir: &str, workdir: &str,
log_buf: std::sync::Arc<std::sync::Mutex<crate::logs::LogBuffer>>, log_buf: std::sync::Arc<parking_lot::Mutex<crate::logs::LogBuffer>>,
on_exit: impl FnOnce() + Send + 'static,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
if command.trim().is_empty() { if command.trim().is_empty() {
anyhow::bail!("No game launch command configured (set it in the Config tab)."); anyhow::bail!("No game launch command configured (set it in Settings).");
} }
let mut cmd = Command::new("sh"); let mut cmd = Command::new("sh");
@@ -145,7 +158,6 @@ pub fn launch_game(
log_buf log_buf
.lock() .lock()
.unwrap()
.push(format!("[launcher] launching game: {command}")); .push(format!("[launcher] launching game: {command}"));
let mut child = cmd.spawn()?; let mut child = cmd.spawn()?;
@@ -154,7 +166,7 @@ pub fn launch_game(
let buf = std::sync::Arc::clone(&log_buf); let buf = std::sync::Arc::clone(&log_buf);
std::thread::spawn(move || { std::thread::spawn(move || {
for line in BufReader::new(out).lines().map_while(Result::ok) { for line in BufReader::new(out).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line); buf.lock().push(line);
} }
}); });
} }
@@ -162,18 +174,20 @@ pub fn launch_game(
let buf = std::sync::Arc::clone(&log_buf); let buf = std::sync::Arc::clone(&log_buf);
std::thread::spawn(move || { std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) { for line in BufReader::new(err).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line); buf.lock().push(line);
} }
}); });
} }
// Reap the child in the background so a finished game doesn't linger as a // Reap the child in the background so a finished game doesn't linger as a
// zombie; we don't block the UI on it. // zombie; we don't block the UI on it. `on_exit` is how the launch state
// machine learns the game is gone — without it the UI would sit on
// "FIFA 17 Running" forever.
std::thread::spawn(move || { std::thread::spawn(move || {
let _ = child.wait(); let _ = child.wait();
log_buf log_buf
.lock() .lock()
.unwrap()
.push("[launcher] game process exited.".to_string()); .push("[launcher] game process exited.".to_string());
on_exit();
}); });
Ok(()) Ok(())
+1 -6
View File
@@ -131,12 +131,7 @@ pub fn status_pill(ui: &mut egui::Ui, label: &str, status: Status) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0; ui.spacing_mut().item_spacing.x = 6.0;
ui.label(egui::RichText::new(status.glyph()).color(color).size(11.0)); ui.label(egui::RichText::new(status.glyph()).color(color).size(11.0));
ui.label( ui.label(egui::RichText::new(label).color(color).size(12.0).strong());
egui::RichText::new(label)
.color(color)
.size(12.0)
.strong(),
);
}); });
}); });
} }