357501f549
Release-readiness pass on the launcher, driven by the end state "open it,
create an account, launch the game".
Fixes a silent correctness bug. `openfut.cfg` in the game dir is the only
server address the *game* can see, but it was written only by Setup's deploy
and its "Save & Update hook" button. Changing the server anywhere else left
FIFA connecting to the previous host while every panel in the launcher showed
the new one online. Now:
- `write_hook_config` reconciles the file from the live config, and runs
fail-closed before every launch, so the file and the UI cannot disagree at
the moment it matters;
- saving Settings pushes the address into the hook immediately;
- a `hook_config` preflight check reads the file back and warns, naming both
addresses, instead of leaving the drift invisible;
- Settings shows the same fact inline, and Save is enabled by drift alone —
a message saying "Save to update it" beside a disabled button is a dead end.
Account creation is now server-authoritative. `account_sync::discover` POSTs
`/openfut/account/sync` with the persona fields *omitted*, which makes the host
answer with the persona it was started with, its club, and the Core coin
balance. The launcher adopts that answer, so it never invents an identity and
the persona the game authenticates with is by construction the one the server
expects. Claiming is gated on the address being valid, NOT on the health pill:
that pill probes the HTTPS port while this talks to the account port, so gating
on it disabled the button on servers that answer it perfectly well.
UX consolidation:
- new Welcome ("Get started") tab: three numbered steps — connect, claim an
account, connect FIFA — each showing live state, ending in the launch CTA;
a fresh install opens on it and it leaves the nav rail once satisfied;
- Config renamed Settings, and made the single owner of the server address:
Setup's duplicate editors (same fields, different save semantics) are now a
read-only summary with actions;
- the dashboard offers account creation in place instead of naming a tab, and
the stale "set the host in the Setup tab" pointers are corrected.
Locks move to parking_lot per project rule (already the convention in
openfut-utas-host and openfut-identity); 47 poisoning unwraps go away.
Verified: 58 tests pass, fmt clean, clippy clean apart from one pre-existing
lint. Driven through the real UI under Xvfb as a fresh install — typed a server,
clicked Create my account, and the config on disk came back with persona
33068179/CAGE claimed from the live host; clicking Save rewrote a stale
`openfut.cfg` from host=10.10.0.99 to host=127.0.0.1.
422 lines
14 KiB
Rust
422 lines
14 KiB
Rust
//! FIFA 17 local companion services — LSX (Origin emulator) + autopatch
|
|
//! (ProtoSSL cert-verify memory patcher). Both are inherently local to the game
|
|
//! machine and are managed by the launcher as child processes, mirroring the way
|
|
//! `setup::launch_game` spawns and log-streams the game.
|
|
//!
|
|
//! WHY THESE TWO ARE LOCAL (and the rest is not): the heavy FUT responders
|
|
//! (Blaze / UTAS / roster / POW) run in the server container. LSX must stay here
|
|
//! because the game dials it on the hardcoded loopback `127.0.0.1:4216`;
|
|
//! autopatch must stay here because it writes `/proc/<FIFA17.exe>/mem`.
|
|
//!
|
|
//! Lifecycle: each service is a long-running daemon. We keep the `Child` handle
|
|
//! so the UI can show running/stopped and stop them. Both run as the launcher
|
|
//! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous
|
|
//! Polkit prompt delaying cert patching until after FIFA's first TLS attempt.
|
|
|
|
use parking_lot::Mutex;
|
|
use std::{
|
|
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
|
path::Path,
|
|
process::{Child, Command, Stdio},
|
|
sync::{mpsc, Arc},
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use std::os::unix::process::CommandExt;
|
|
|
|
use crate::fifa17_capability::{
|
|
parse_capability_line, parse_fifa_pid, register, Fifa17ClientCapabilities,
|
|
};
|
|
use crate::logs::LogBuffer;
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct CommandParts {
|
|
program: String,
|
|
args: Vec<String>,
|
|
}
|
|
|
|
/// Which companion service. The `str` values are used in log prefixes.
|
|
#[derive(Copy, Clone, PartialEq, Eq)]
|
|
pub enum Service {
|
|
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
|
|
Lsx,
|
|
/// autopatch — patches FIFA17.exe process memory after host ptrace arming.
|
|
Autopatch,
|
|
}
|
|
|
|
impl Service {
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Service::Lsx => "LSX",
|
|
Service::Autopatch => "autopatch",
|
|
}
|
|
}
|
|
|
|
/// The responder script filename inside the tools dir.
|
|
fn script(self) -> &'static str {
|
|
match self {
|
|
Service::Lsx => "lsx_responder_v2.py",
|
|
Service::Autopatch => "autopatch.py",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts {
|
|
let mut args = vec![tools_dir
|
|
.join(service.script())
|
|
.to_string_lossy()
|
|
.into_owned()];
|
|
if service == Service::Autopatch {
|
|
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
|
|
}
|
|
CommandParts {
|
|
program: python.to_string(),
|
|
args,
|
|
}
|
|
}
|
|
|
|
fn dispatch_stop_work<F>(work: F) -> mpsc::Receiver<anyhow::Result<()>>
|
|
where
|
|
F: FnOnce() -> anyhow::Result<()> + Send + 'static,
|
|
{
|
|
let (send, receive) = mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let _ = send.send(work());
|
|
});
|
|
receive
|
|
}
|
|
|
|
fn wait_for_listener_ready(
|
|
child: &mut Child,
|
|
address: SocketAddr,
|
|
timeout: Duration,
|
|
) -> anyhow::Result<()> {
|
|
let deadline = Instant::now() + timeout;
|
|
// Let immediate startup/bind errors surface before accepting an occupied
|
|
// port as evidence that this child became ready.
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
loop {
|
|
if let Some(status) = child
|
|
.try_wait()
|
|
.map_err(|error| anyhow::anyhow!("could not inspect LSX startup: {error}"))?
|
|
{
|
|
anyhow::bail!("LSX exited before becoming ready ({status}); port 4216 may be in use");
|
|
}
|
|
match TcpListener::bind(address) {
|
|
Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => return Ok(()),
|
|
Err(error) => anyhow::bail!("could not probe LSX listener {address}: {error}"),
|
|
Ok(listener) => drop(listener),
|
|
}
|
|
if Instant::now() >= deadline {
|
|
anyhow::bail!(
|
|
"LSX did not bind {address} within {} ms",
|
|
timeout.as_millis()
|
|
);
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
}
|
|
|
|
/// A managed companion service process.
|
|
#[derive(Default)]
|
|
pub struct ManagedService {
|
|
child: Option<Child>,
|
|
stopping: Option<mpsc::Receiver<anyhow::Result<()>>>,
|
|
}
|
|
|
|
impl ManagedService {
|
|
/// Wrap an already-spawned child.
|
|
pub fn from_child(child: Child) -> Self {
|
|
Self {
|
|
child: Some(child),
|
|
stopping: None,
|
|
}
|
|
}
|
|
|
|
/// True while the child is spawned and has not yet exited. Reaps the exit
|
|
/// status if it has, so the UI reflects a service that died on its own.
|
|
pub fn running(&mut self, log: &Arc<Mutex<LogBuffer>>, label: &str) -> bool {
|
|
if let Some(result) = self.stopping.as_ref() {
|
|
match result.try_recv() {
|
|
Ok(Ok(())) => {
|
|
log.lock().push(format!("[launcher] {label} stopped."));
|
|
self.stopping = None;
|
|
return false;
|
|
}
|
|
Ok(Err(error)) => {
|
|
log.lock()
|
|
.push(format!("[launcher] failed to stop {label}: {error}"));
|
|
self.stopping = None;
|
|
return false;
|
|
}
|
|
Err(mpsc::TryRecvError::Empty) => return true,
|
|
Err(mpsc::TryRecvError::Disconnected) => {
|
|
log.lock().push(format!(
|
|
"[launcher] {label} stop worker exited unexpectedly."
|
|
));
|
|
self.stopping = None;
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
match self.child.as_mut() {
|
|
None => false,
|
|
Some(c) => match c.try_wait() {
|
|
Ok(None) => true,
|
|
Ok(Some(status)) => {
|
|
log.lock()
|
|
.push(format!("[launcher] {label} exited ({status})."));
|
|
self.child = None;
|
|
false
|
|
}
|
|
Err(_) => true,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn stopping(&self) -> bool {
|
|
self.stopping.is_some()
|
|
}
|
|
|
|
/// Begin stopping the service without waiting on the egui UI thread.
|
|
pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) {
|
|
if self.stopping.is_some() {
|
|
return;
|
|
}
|
|
if let Some(mut child) = self.child.take() {
|
|
let label = service.label();
|
|
log.lock().push(format!("[launcher] stopping {label}…"));
|
|
|
|
self.stopping = Some(dispatch_stop_work(move || {
|
|
child
|
|
.kill()
|
|
.map_err(|error| anyhow::anyhow!("kill failed: {error}"))?;
|
|
|
|
child
|
|
.wait()
|
|
.map_err(|error| anyhow::anyhow!("reap failed: {error}"))?;
|
|
Ok(())
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for ManagedService {
|
|
fn drop(&mut self) {
|
|
if let Some(mut c) = self.child.take() {
|
|
let _ = c.kill();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Backend-registration wiring handed to the autopatch stdout reader so a
|
|
/// verified resolver-guard line can advertise the per-FIFA-process capability to
|
|
/// the backend. `Some(..)` for autopatch; `None` for LSX.
|
|
pub struct CapabilityWiring {
|
|
pub server_host: String,
|
|
pub account_sync_port: u16,
|
|
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
|
|
}
|
|
|
|
/// Spawn a companion service. `python` is the interpreter, `tools_dir` the
|
|
/// directory holding the responder scripts. Streams stdout+stderr into `log`.
|
|
/// Returns an error (without spawning) if the tools dir or script is missing.
|
|
///
|
|
/// `capability` is the backend-registration wiring + shared per-FIFA-process
|
|
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the
|
|
/// verified resolver guard) and `None` for LSX.
|
|
pub fn spawn(
|
|
service: Service,
|
|
python: &str,
|
|
tools_dir: &str,
|
|
persona_id: u64,
|
|
persona_name: &str,
|
|
capability: Option<CapabilityWiring>,
|
|
log: Arc<Mutex<LogBuffer>>,
|
|
) -> anyhow::Result<Child> {
|
|
use std::io::{BufRead, BufReader};
|
|
|
|
let dir = Path::new(tools_dir);
|
|
if !dir.is_dir() {
|
|
anyhow::bail!(
|
|
"FIFA 17 tools dir not found: {} (set it in Settings)",
|
|
dir.display()
|
|
);
|
|
}
|
|
let script_path = dir.join(service.script());
|
|
if !script_path.exists() {
|
|
anyhow::bail!(
|
|
"{} not found in tools dir: {}",
|
|
service.script(),
|
|
script_path.display()
|
|
);
|
|
}
|
|
|
|
let label = service.label();
|
|
|
|
// Both services use the configured interpreter and absolute script path;
|
|
// neither invents a Python installation path. Autopatch receives launcher
|
|
// ownership and a per-user runtime log so stale root-owned /tmp files cannot
|
|
// block startup.
|
|
let parts = command_parts(service, python, dir);
|
|
let mut cmd = Command::new(&parts.program);
|
|
cmd.args(&parts.args);
|
|
if service == Service::Lsx {
|
|
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
|
|
.env("FUT_PERSONA_NAME", persona_name);
|
|
} else if service == Service::Autopatch {
|
|
let log_path = std::env::var_os("XDG_RUNTIME_DIR")
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_else(std::env::temp_dir)
|
|
.join("openfut-autopatch.log");
|
|
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
|
|
}
|
|
// Put each companion in its own process group for lifecycle isolation.
|
|
cmd.process_group(0);
|
|
cmd.current_dir(dir)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
log.lock().push(format!(
|
|
"[launcher] starting {label}: {} {}",
|
|
python,
|
|
script_path.display(),
|
|
));
|
|
|
|
let mut child = cmd
|
|
.spawn()
|
|
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.script()))?;
|
|
|
|
if let Some(out) = child.stdout.take() {
|
|
let buf = Arc::clone(&log);
|
|
let lbl = label.to_string();
|
|
// Only autopatch carries capability wiring; LSX passes `None`.
|
|
let cap_wiring = capability;
|
|
let cap_persona = persona_id;
|
|
std::thread::spawn(move || {
|
|
// Fires the backend registration at most once per FIFA process.
|
|
let mut registered = false;
|
|
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
|
// Every raw line is still mirrored into the log, as before.
|
|
buf.lock().push(format!("[{lbl}] {line}"));
|
|
|
|
let Some(wiring) = cap_wiring.as_ref() else {
|
|
continue;
|
|
};
|
|
if registered {
|
|
continue;
|
|
}
|
|
let Some(version) = parse_capability_line(&line) else {
|
|
continue;
|
|
};
|
|
registered = true;
|
|
let fifa_pid = parse_fifa_pid(&line).unwrap_or(0);
|
|
wiring.sink.lock().empty_mypacks_resolver = Some(version);
|
|
{
|
|
let mut log = buf.lock();
|
|
log.push(format!(
|
|
"[fifa17] resolver capability verified for FIFA pid {fifa_pid}"
|
|
));
|
|
log.push(format!(
|
|
"[fifa17] registering capability for session (persona {cap_persona})"
|
|
));
|
|
}
|
|
match register(
|
|
&wiring.server_host,
|
|
wiring.account_sync_port,
|
|
cap_persona,
|
|
fifa_pid,
|
|
version,
|
|
) {
|
|
Ok(()) => buf
|
|
.lock()
|
|
.push("[fifa17] capability registered with backend".to_string()),
|
|
Err(error) => buf
|
|
.lock()
|
|
.push(format!("[fifa17] capability registration failed: {error}")),
|
|
}
|
|
}
|
|
});
|
|
}
|
|
if let Some(err) = child.stderr.take() {
|
|
let buf = Arc::clone(&log);
|
|
let lbl = label.to_string();
|
|
std::thread::spawn(move || {
|
|
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
|
buf.lock().push(format!("[{lbl}] {line}"));
|
|
}
|
|
});
|
|
}
|
|
|
|
if service == Service::Lsx {
|
|
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
|
|
if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Err(error);
|
|
}
|
|
log.lock()
|
|
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
|
|
}
|
|
|
|
Ok(child)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn lsx_runs_python_directly() {
|
|
let parts = command_parts(Service::Lsx, "/usr/bin/python3", Path::new("/tmp/tools"));
|
|
assert_eq!(parts.program, "/usr/bin/python3");
|
|
assert_eq!(parts.args, vec!["/tmp/tools/lsx_responder_v2.py"]);
|
|
}
|
|
|
|
#[test]
|
|
fn autopatch_runs_python_directly_with_launcher_ownership() {
|
|
let parts = command_parts(
|
|
Service::Autopatch,
|
|
"/usr/bin/python3",
|
|
Path::new("/tmp/tools"),
|
|
);
|
|
assert_eq!(parts.program, "/usr/bin/python3");
|
|
assert_eq!(
|
|
parts.args,
|
|
vec![
|
|
"/tmp/tools/autopatch.py",
|
|
"--launcher-pid",
|
|
&std::process::id().to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stop_work_is_dispatched_without_blocking_the_caller() {
|
|
use std::time::{Duration, Instant};
|
|
|
|
let started = Instant::now();
|
|
let done = dispatch_stop_work(|| {
|
|
std::thread::sleep(Duration::from_millis(250));
|
|
Ok(())
|
|
});
|
|
|
|
assert!(started.elapsed() < Duration::from_millis(100));
|
|
assert!(done.try_recv().is_err());
|
|
assert!(done.recv_timeout(Duration::from_secs(1)).unwrap().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn readiness_rejects_an_lsx_child_that_exits_before_binding() {
|
|
let mut child = Command::new("sh")
|
|
.args(["-c", "exit 7"])
|
|
.spawn()
|
|
.expect("spawn short-lived child");
|
|
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0));
|
|
let error = wait_for_listener_ready(&mut child, address, Duration::from_secs(1))
|
|
.expect_err("exited child must not be reported ready");
|
|
assert!(error.to_string().contains("exited before becoming ready"));
|
|
}
|
|
}
|