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.
This commit is contained in:
+76
-104
@@ -717,9 +717,6 @@ impl LauncherApp {
|
||||
if phase == launch::Phase::StartingServices {
|
||||
return (launch::Readiness::Busy, "Starting…".into());
|
||||
}
|
||||
if self.config.fifa17_tools_dir.trim().is_empty() {
|
||||
return (launch::Readiness::Attention, "Not configured".into());
|
||||
}
|
||||
let runtimes = self.observe_services();
|
||||
let ready = runtimes.iter().filter(|(_, r)| r.ready()).count();
|
||||
let blocked = runtimes
|
||||
@@ -764,8 +761,6 @@ impl LauncherApp {
|
||||
/// OpenFUT is actively being reverse engineered, so independent control of
|
||||
/// each moving part stays available — it is just no longer the front door.
|
||||
fn ui_advanced(&mut self, ui: &mut Ui) {
|
||||
let tools_configured = !self.config.fifa17_tools_dir.trim().is_empty();
|
||||
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Everything here happens automatically when you press Launch. These \
|
||||
@@ -789,87 +784,84 @@ impl LauncherApp {
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
|
||||
if !tools_configured {
|
||||
ui.colored_label(
|
||||
theme::WARN,
|
||||
"FIFA 17 tools dir not set — configure it in Settings.",
|
||||
);
|
||||
} else {
|
||||
let runtimes = self.observe_services();
|
||||
egui::Grid::new("advanced_services_grid")
|
||||
.num_columns(4)
|
||||
.spacing([12.0, 10.0])
|
||||
.min_col_width(90.0)
|
||||
.show(ui, |ui| {
|
||||
for (service, runtime) in runtimes {
|
||||
ui.label(RichText::new(service.label()).color(theme::TEXT).strong());
|
||||
if self.controller.services.lock().stopping(service) {
|
||||
theme::status_pill(ui, "Stopping", Status::Busy);
|
||||
} else if runtime.running && runtime.started_by_launcher {
|
||||
theme::status_pill(ui, "Running", Status::Ok);
|
||||
} else if runtime.running {
|
||||
theme::status_pill(ui, "Running (foreign)", Status::Warn);
|
||||
} else if runtime.detail.is_some() {
|
||||
theme::status_pill(ui, "Blocked", Status::Error);
|
||||
} else {
|
||||
theme::status_pill(ui, "Stopped", Status::Idle);
|
||||
}
|
||||
|
||||
// Only ever facts the launcher established.
|
||||
let mut facts = Vec::new();
|
||||
if let Some(pid) = runtime.pid {
|
||||
facts.push(format!("pid {pid}"));
|
||||
}
|
||||
if let Some(detail) = &runtime.detail {
|
||||
facts.push(detail.clone());
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(if facts.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
facts.join(" · ")
|
||||
})
|
||||
.color(theme::TEXT_FAINT)
|
||||
.small(),
|
||||
);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if runtime.running {
|
||||
if ui
|
||||
.add_enabled(
|
||||
runtime.started_by_launcher,
|
||||
egui::Button::new("Restart"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.advanced_stop_service(service);
|
||||
self.restart_queue.push(service);
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
runtime.started_by_launcher,
|
||||
egui::Button::new("Stop"),
|
||||
)
|
||||
.on_disabled_hover_text(
|
||||
"Started outside this launcher — stop it where it \
|
||||
was started.",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.advanced_stop_service(service);
|
||||
}
|
||||
} else if ui.button("Start").clicked() {
|
||||
self.advanced_start_service(service);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
// Rendered unconditionally: the companions are workspace binaries
|
||||
// resolved relative to this launcher, so there is no configuration that
|
||||
// could make this panel inapplicable. A binary that is genuinely absent
|
||||
// surfaces as that service's own spawn error, not as a hidden panel.
|
||||
let runtimes = self.observe_services();
|
||||
egui::Grid::new("advanced_services_grid")
|
||||
.num_columns(4)
|
||||
.spacing([12.0, 10.0])
|
||||
.min_col_width(90.0)
|
||||
.show(ui, |ui| {
|
||||
for (service, runtime) in runtimes {
|
||||
ui.label(RichText::new(service.label()).color(theme::TEXT).strong());
|
||||
if self.controller.services.lock().stopping(service) {
|
||||
theme::status_pill(ui, "Stopping", Status::Busy);
|
||||
} else if runtime.running && runtime.started_by_launcher {
|
||||
theme::status_pill(ui, "Running", Status::Ok);
|
||||
} else if runtime.running {
|
||||
theme::status_pill(ui, "Running (foreign)", Status::Warn);
|
||||
} else if runtime.detail.is_some() {
|
||||
theme::status_pill(ui, "Blocked", Status::Error);
|
||||
} else {
|
||||
theme::status_pill(ui, "Stopped", Status::Idle);
|
||||
}
|
||||
});
|
||||
|
||||
if let Some((ok, msg)) = &self.local_services_message {
|
||||
ui.add_space(6.0);
|
||||
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
|
||||
}
|
||||
// Only ever facts the launcher established.
|
||||
let mut facts = Vec::new();
|
||||
if let Some(pid) = runtime.pid {
|
||||
facts.push(format!("pid {pid}"));
|
||||
}
|
||||
if let Some(detail) = &runtime.detail {
|
||||
facts.push(detail.clone());
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(if facts.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
facts.join(" · ")
|
||||
})
|
||||
.color(theme::TEXT_FAINT)
|
||||
.small(),
|
||||
);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if runtime.running {
|
||||
if ui
|
||||
.add_enabled(
|
||||
runtime.started_by_launcher,
|
||||
egui::Button::new("Restart"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.advanced_stop_service(service);
|
||||
self.restart_queue.push(service);
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
runtime.started_by_launcher,
|
||||
egui::Button::new("Stop"),
|
||||
)
|
||||
.on_disabled_hover_text(
|
||||
"Started outside this launcher — stop it where it \
|
||||
was started.",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.advanced_stop_service(service);
|
||||
}
|
||||
} else if ui.button("Start").clicked() {
|
||||
self.advanced_start_service(service);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
|
||||
if let Some((ok, msg)) = &self.local_services_message {
|
||||
ui.add_space(6.0);
|
||||
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
|
||||
}
|
||||
|
||||
ui.add_space(14.0);
|
||||
@@ -1079,8 +1071,6 @@ impl LauncherApp {
|
||||
/// sequence uses, so the two can never disagree about what is running.
|
||||
fn advanced_start_service(&mut self, service: crate::local_services::Service) {
|
||||
let spec = crate::local_services::SpawnSpec {
|
||||
python: self.config.fifa17_python.clone(),
|
||||
tools_dir: self.config.fifa17_tools_dir.clone(),
|
||||
persona_id: self.config.fut_persona_id,
|
||||
persona_name: self.config.fut_persona_name.clone(),
|
||||
capability: match service {
|
||||
@@ -1467,24 +1457,6 @@ impl LauncherApp {
|
||||
.text_edit_singleline(&mut self.config.fifa_game_dir)
|
||||
.changed();
|
||||
ui.end_row();
|
||||
|
||||
ui.label(RichText::new("FIFA17 tools dir:").color(theme::TEXT_WEAK));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(&mut self.config.fifa17_tools_dir)
|
||||
.hint_text("fifa17-recon/tools (LSX + autopatch scripts)"),
|
||||
)
|
||||
.changed();
|
||||
ui.end_row();
|
||||
|
||||
ui.label(RichText::new("Python:").color(theme::TEXT_WEAK));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(&mut self.config.fifa17_python)
|
||||
.hint_text("python3"),
|
||||
)
|
||||
.changed();
|
||||
ui.end_row();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-76
@@ -168,26 +168,6 @@ pub struct LauncherConfig {
|
||||
/// Dead EA hostnames that must resolve to `openfut_server_host`.
|
||||
#[serde(default)]
|
||||
pub ea_hostnames: Vec<String>,
|
||||
|
||||
// ── FIFA 17 local companion services (client-side, run on THIS machine) ──
|
||||
// FIFA 17's FUT flow needs two pieces that are inherently local to the game
|
||||
// box and cannot move to the server: the LSX Origin emulator (the game dials
|
||||
// it on the hardcoded loopback 127.0.0.1:4216) and autopatch (patches
|
||||
// FIFA17.exe process memory for ProtoSSL cert-verify). The launcher manages
|
||||
// both as child processes. The heavy responders (Blaze/UTAS/roster/POW) run
|
||||
// in the server container; these two stay here.
|
||||
/// Directory holding the FIFA 17 Python responders (fifa17-recon `tools/`).
|
||||
/// Empty means the local-services feature is unconfigured and its controls
|
||||
/// stay disabled.
|
||||
#[serde(default)]
|
||||
pub fifa17_tools_dir: String,
|
||||
/// Python interpreter used to run the local companion services.
|
||||
#[serde(default = "default_python")]
|
||||
pub fifa17_python: String,
|
||||
}
|
||||
|
||||
fn default_python() -> String {
|
||||
"python3".to_string()
|
||||
}
|
||||
|
||||
fn default_https_port() -> u16 {
|
||||
@@ -271,11 +251,6 @@ impl Default for LauncherConfig {
|
||||
game_profile: GameProfile::default(),
|
||||
ea_redirect_probe_ip: String::new(),
|
||||
ea_hostnames: Vec::new(),
|
||||
fifa17_tools_dir: base
|
||||
.join("fifa17-recon/tools")
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
fifa17_python: default_python(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,19 +329,6 @@ impl LauncherConfig {
|
||||
self.server_config().validate().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Validate the client-local FIFA 17 service configuration. Filesystem
|
||||
/// existence is checked by the process launcher immediately before spawn;
|
||||
/// this ensures required user configuration is never silently invented.
|
||||
pub fn validate_local_services(&self) -> Result<(), String> {
|
||||
if self.fifa17_tools_dir.trim().is_empty() {
|
||||
return Err("No FIFA 17 tools dir configured. Set it in Settings.".into());
|
||||
}
|
||||
if self.fifa17_python.trim().is_empty() {
|
||||
return Err("No Python interpreter configured. Set it in Settings.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate every configuration value required by the one-button FIFA 17
|
||||
/// launch path. Runtime state such as hook deployment is checked by the UI.
|
||||
pub fn validate_launch_config(&self) -> Result<(), String> {
|
||||
@@ -384,7 +346,7 @@ impl LauncherConfig {
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
self.validate_local_services()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_account(&self) -> Result<(), String> {
|
||||
@@ -508,31 +470,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_services_require_tools_dir_and_python() {
|
||||
let mut c = LauncherConfig::default();
|
||||
c.fifa17_tools_dir.clear();
|
||||
assert!(c
|
||||
.validate_local_services()
|
||||
.unwrap_err()
|
||||
.contains("tools dir"));
|
||||
|
||||
c.fifa17_tools_dir = "/tmp/fifa17-tools".into();
|
||||
c.fifa17_python.clear();
|
||||
assert!(c.validate_local_services().unwrap_err().contains("Python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_services_accept_explicit_configuration() {
|
||||
let c = LauncherConfig {
|
||||
fifa17_tools_dir: "/tmp/fifa17-tools".into(),
|
||||
fifa17_python: "/usr/bin/python3".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
assert!(c.validate_local_services().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_config_requires_server_local_services_and_command() {
|
||||
fn launch_config_requires_server_account_and_command() {
|
||||
let mut c = LauncherConfig::default();
|
||||
assert!(c.validate_launch_config().is_err());
|
||||
|
||||
@@ -545,14 +483,6 @@ mod tests {
|
||||
.contains("launch command"));
|
||||
|
||||
c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into();
|
||||
c.fifa17_tools_dir.clear();
|
||||
assert!(c
|
||||
.validate_launch_config()
|
||||
.unwrap_err()
|
||||
.contains("tools dir"));
|
||||
|
||||
c.fifa17_tools_dir = "/home/alex/Documents/OpenFUT/fifa17-recon/tools".into();
|
||||
c.fifa17_python = "/usr/bin/python3".into();
|
||||
assert!(c.validate_launch_config().is_ok());
|
||||
}
|
||||
|
||||
@@ -580,8 +510,6 @@ mod tests {
|
||||
openfut_server_host: "10.10.0.120".into(),
|
||||
fut_persona_id: 1,
|
||||
fut_persona_name: "X".into(),
|
||||
fifa17_tools_dir: "/tmp/tools".into(),
|
||||
fifa17_python: "/usr/bin/python3".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
c.game_launch_command.clear();
|
||||
@@ -609,8 +537,6 @@ mod tests {
|
||||
openfut_server_host: "10.10.0.120".into(),
|
||||
fut_persona_id: 1,
|
||||
fut_persona_name: "X".into(),
|
||||
fifa17_tools_dir: "/tmp/tools".into(),
|
||||
fifa17_python: "/usr/bin/python3".into(),
|
||||
game_launch_command: "/home/u/launch.sh".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
|
||||
@@ -481,8 +481,6 @@ impl LaunchOps for RealOps {
|
||||
|
||||
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
|
||||
let spec = SpawnSpec {
|
||||
python: self.config.fifa17_python.clone(),
|
||||
tools_dir: self.config.fifa17_tools_dir.clone(),
|
||||
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
|
||||
|
||||
+103
-69
@@ -16,7 +16,7 @@
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
||||
path::Path,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{mpsc, Arc},
|
||||
time::{Duration, Instant},
|
||||
@@ -56,25 +56,48 @@ impl Service {
|
||||
}
|
||||
}
|
||||
|
||||
/// The responder script filename inside the tools dir.
|
||||
fn script(self) -> &'static str {
|
||||
/// The companion's executable name.
|
||||
///
|
||||
/// 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 {
|
||||
Service::Lsx => "lsx_responder_v2.py",
|
||||
Service::Autopatch => "autopatch.py",
|
||||
Service::Lsx => "openfut-lsx",
|
||||
Service::Autopatch => "openfut-autopatch",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()];
|
||||
/// Absolute path to a companion binary.
|
||||
///
|
||||
/// Prefers a sibling of the running launcher, which is what a workspace build and any
|
||||
/// sane install layout both produce, and falls back to the bare name so a
|
||||
/// 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 {
|
||||
// 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()]);
|
||||
}
|
||||
CommandParts {
|
||||
program: python.to_string(),
|
||||
program: resolve_binary(service).to_string_lossy().into_owned(),
|
||||
args,
|
||||
}
|
||||
}
|
||||
@@ -265,14 +288,17 @@ pub fn lsx_port_busy() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// PID of a process running `service`'s responder script that this launcher does
|
||||
/// 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 script = service.script();
|
||||
let binary = service.binary();
|
||||
let self_pid = std::process::id();
|
||||
let entries = std::fs::read_dir("/proc").ok()?;
|
||||
for entry in entries.flatten() {
|
||||
@@ -285,10 +311,13 @@ pub fn foreign_pid(service: Service, ours: Option<u32>) -> Option<u32> {
|
||||
let Ok(cmdline) = std::fs::read(entry.path().join("cmdline")) else {
|
||||
continue;
|
||||
};
|
||||
if cmdline
|
||||
.split(|b| *b == 0)
|
||||
.any(|arg| String::from_utf8_lossy(arg).ends_with(script))
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -420,8 +449,6 @@ impl ServiceSupervisor {
|
||||
}
|
||||
let child = spawn(
|
||||
service,
|
||||
&spec.python,
|
||||
&spec.tools_dir,
|
||||
spec.persona_id,
|
||||
&spec.persona_name,
|
||||
spec.capability,
|
||||
@@ -450,24 +477,22 @@ impl ServiceSupervisor {
|
||||
/// Everything [`spawn`] needs, bundled so the launch sequence can hand it over
|
||||
/// as one value per service.
|
||||
pub struct SpawnSpec {
|
||||
pub python: String,
|
||||
pub tools_dir: String,
|
||||
pub persona_id: u64,
|
||||
pub persona_name: String,
|
||||
pub capability: Option<CapabilityWiring>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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 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>,
|
||||
@@ -475,35 +500,28 @@ pub fn spawn(
|
||||
) -> 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();
|
||||
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);
|
||||
cmd.args(&parts.args);
|
||||
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())
|
||||
.env("FUT_PERSONA_NAME", persona_name);
|
||||
} 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")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
@@ -512,19 +530,24 @@ pub fn spawn(
|
||||
}
|
||||
// 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());
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
log.lock().push(format!(
|
||||
"[launcher] starting {label}: {} {}",
|
||||
python,
|
||||
script_path.display(),
|
||||
"[launcher] starting {label}: {}{}",
|
||||
parts.program,
|
||||
parts
|
||||
.args
|
||||
.iter()
|
||||
.fold(String::new(), |mut acc, a| {
|
||||
acc.push(' ');
|
||||
acc.push_str(a);
|
||||
acc
|
||||
}),
|
||||
));
|
||||
|
||||
let mut child = cmd
|
||||
.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() {
|
||||
let buf = Arc::clone(&log);
|
||||
@@ -606,27 +629,38 @@ 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"]);
|
||||
fn lsx_runs_its_own_binary_with_no_arguments() {
|
||||
let parts = command_parts(Service::Lsx);
|
||||
assert_eq!(
|
||||
Path::new(&parts.program).file_name().unwrap(),
|
||||
"openfut-lsx"
|
||||
);
|
||||
assert!(parts.args.is_empty(), "{:?}", parts.args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autopatch_runs_python_directly_with_launcher_ownership() {
|
||||
let parts = command_parts(
|
||||
Service::Autopatch,
|
||||
"/usr/bin/python3",
|
||||
Path::new("/tmp/tools"),
|
||||
fn autopatch_runs_its_own_binary_with_launcher_ownership() {
|
||||
let parts = command_parts(Service::Autopatch);
|
||||
assert_eq!(
|
||||
Path::new(&parts.program).file_name().unwrap(),
|
||||
"openfut-autopatch"
|
||||
);
|
||||
assert_eq!(parts.program, "/usr/bin/python3");
|
||||
// The launcher pid is how autopatch learns to exit with its owner.
|
||||
assert_eq!(
|
||||
parts.args,
|
||||
vec![
|
||||
"/tmp/tools/autopatch.py",
|
||||
"--launcher-pid",
|
||||
&std::process::id().to_string(),
|
||||
]
|
||||
vec!["--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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+17
-31
@@ -88,7 +88,7 @@ impl Check {
|
||||
/// Run every applicable check. Order is the order the game exercises them.
|
||||
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
||||
vec![
|
||||
ptrace_scope(cfg),
|
||||
ptrace_scope(),
|
||||
ea_redirect(cfg),
|
||||
hostname_mapping(cfg),
|
||||
backend_reachable(cfg),
|
||||
@@ -109,16 +109,12 @@ pub fn warnings(checks: &[Check]) -> usize {
|
||||
/// 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
|
||||
/// 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)";
|
||||
// `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) {
|
||||
Ok(v) => ptrace_verdict(&v),
|
||||
// Not every kernel has Yama. Absent means unenforced, which is what we want.
|
||||
@@ -336,12 +332,19 @@ mod tests {
|
||||
fn an_unconfigured_launcher_skips_rather_than_passes() {
|
||||
// The distinction that matters: a fresh config must not display a column
|
||||
// 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();
|
||||
// `default()` points these at conventional paths whose existence varies
|
||||
// by machine. Pin them so the assertion is about the code, not this box.
|
||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
||||
// `default()` points this at a conventional path whose existence varies
|
||||
// by machine. Pin it so the assertion is about the code, not this box.
|
||||
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!(
|
||||
checks.iter().all(|k| k.state == State::Skipped),
|
||||
"{checks:#?}"
|
||||
@@ -360,16 +363,6 @@ mod tests {
|
||||
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]
|
||||
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
|
||||
let mut c = cfg();
|
||||
@@ -432,13 +425,6 @@ mod tests {
|
||||
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]
|
||||
fn a_dead_backend_port_is_reported_as_a_failure() {
|
||||
let mut c = cfg();
|
||||
|
||||
Reference in New Issue
Block a user