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:
funman300
2026-08-18 05:30:35 +00:00
parent c5424158b9
commit 1cd4f18e92
5 changed files with 198 additions and 282 deletions
+76 -104
View File
@@ -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();
});
});