From 3174fe4c1f8528eac6460821c2a7049efb25ec03 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 17 Aug 2026 22:44:21 +0000 Subject: [PATCH] launcher: one Launch button, driven by an explicit launch state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app.rs | 1058 +++++++++++++++++++++++++---------------- src/game_launch.rs | 26 +- src/launch.rs | 942 ++++++++++++++++++++++++++++++++++++ src/local_services.rs | 327 ++++++++++++- src/main.rs | 1 + src/preflight.rs | 2 +- src/setup.rs | 6 +- 7 files changed, 1934 insertions(+), 428 deletions(-) create mode 100644 src/launch.rs diff --git a/src/app.rs b/src/app.rs index ad749d4..b86a6e6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,7 +8,7 @@ use crate::theme::{self, Status}; use crate::{ account_monitor::AccountMonitor, config::LauncherConfig, game_launch, health::HealthMonitor, - logs::LogBuffer, netcheck, preflight, setup, + launch, logs::LogBuffer, netcheck, preflight, setup, }; #[derive(Clone, Copy, PartialEq)] @@ -76,23 +76,26 @@ pub struct LauncherApp { /// Welcome flow. account_message: Option<(bool, String)>, - // FIFA 17 local companion services (client-side daemons). - lsx: crate::local_services::ManagedService, - autopatch: crate::local_services::ManagedService, + // FIFA 17 local companion services + the launch sequence that drives them. + // The controller owns the services so the launch sequence and the Advanced + // panel act on the same objects; a second copy of "is it running" is how a UI + // ends up claiming Ready over a dead process. + controller: launch::Controller, local_services_message: Option<(bool, String)>, + /// Services the user asked to restart, waiting for their stop to finish. + restart_queue: Vec, + /// Whether the Advanced / Diagnostics section is expanded. + advanced_open: bool, + /// Whether the failure card is showing implementation detail. + show_failure_details: bool, - /// Last pre-launch check results. `None` until run — deliberately not run - /// automatically on every frame: the checks open sockets, and a 2s probe - /// on the UI thread would stall the window. - preflight: Option>, - - /// Result of the last "Arm client" click, shown inline beneath the button so - /// the outcome appears where the user acted — not on another tab. + /// Result of the last manual "Prepare client" click, shown inline where the + /// user acted. arm_status: Option<(bool, String)>, /// Capabilities verified for the *current* FIFA process (shared with the - /// autopatch stdout reader). Reset to unknown at each launch so a new FIFA - /// process never inherits a previous launch's capability. + /// autopatch stdout reader). Reset at each launch so a new FIFA process never + /// inherits a previous launch's capability. fifa17_caps: Arc>, } @@ -114,12 +117,22 @@ impl LauncherApp { // on the guided flow, a configured one on the dashboard. let active_tab = Tab::default_active(&config); + let game_logs = Arc::new(Mutex::new(LogBuffer::new())); + let controller = launch::Controller::new(Arc::clone(&game_logs)); + let fifa17_caps: Arc> = + Arc::new(Mutex::new(Default::default())); + + // Look at the world once at startup, on a worker thread. Without this the + // status rows would honestly say "Not checked yet" until the user pressed + // something — accurate, and a poor answer to "am I ready?". + controller.refresh(&config, &game_logs, &fifa17_caps); + Self { config, config_dirty: false, health, account, - game_logs: Arc::new(Mutex::new(LogBuffer::new())), + game_logs, active_tab, log_follow: true, hook_deployed, @@ -127,12 +140,13 @@ impl LauncherApp { setup_message: None, test_message: None, account_message: None, - lsx: crate::local_services::ManagedService::default(), - autopatch: crate::local_services::ManagedService::default(), + controller, local_services_message: None, - preflight: None, + restart_queue: Vec::new(), + advanced_open: false, + show_failure_details: false, arm_status: None, - fifa17_caps: Arc::new(Mutex::new(Default::default())), + fifa17_caps, } } @@ -488,495 +502,644 @@ impl LauncherApp { ui.add_space(14.0); - // ── Server status card ──────────────────────────────────────────────── - theme::card().show(ui, |ui| { - ui.set_width(ui.available_width()); - let (status, pill) = match health.reachable { - None => (Status::Unknown, "Unknown"), - Some(true) => (Status::Ok, "Online"), - Some(false) => (Status::Error, "Unreachable"), - }; - card_header(ui, "Server status", Some((pill, status))); - - egui::Grid::new("health_grid") - .num_columns(2) - .spacing([18.0, 8.0]) - .show(ui, |ui| { - ui.label(RichText::new("Address").color(theme::TEXT_WEAK)); - ui.monospace(if self.config.openfut_server_host.is_empty() { - "—".to_string() - } else { - format!( - "{}:{}", - self.config.openfut_server_host, self.config.openfut_https_port - ) - }); - ui.end_row(); - - ui.label(RichText::new("Detail").color(theme::TEXT_WEAK)); - ui.label(RichText::new(&health.detail).color(theme::TEXT)); - ui.end_row(); - - if let Some(t) = health.last_checked { - ui.label(RichText::new("Checked").color(theme::TEXT_WEAK)); - ui.label( - RichText::new(format!("{}s ago", t.elapsed().as_secs())) - .color(theme::TEXT_FAINT), - ); - ui.end_row(); - } - }); - - if !server_ok { - ui.add_space(8.0); - ui.colored_label( - theme::WARN, - "No OpenFUT server configured — set the host in Settings.", - ); - } - ui.add_space(8.0); - ui.label( - RichText::new( - "The server runs elsewhere (e.g. Docker on the server host). This \ - launcher monitors it read-only — it does not start or stop it.", - ) - .color(theme::TEXT_FAINT) - .small(), - ); - }); - - ui.add_space(14.0); - - // ── Local services card ─────────────────────────────────────────────── - theme::card().show(ui, |ui| { - ui.set_width(ui.available_width()); - self.ui_local_services(ui); - }); - - ui.add_space(14.0); - - // ── Game & launch card ──────────────────────────────────────────────── + // ── The launch surface ──────────────────────────────────────────────── + // One card, one button. The individual moving parts — LSX, autopatch, + // client preparation, the pre-launch checklist — are OpenFUT's internal + // launch order, not a user's job; they live under Advanced. + let snapshot = self.controller.snapshot(); + let phase = snapshot.phase; + let server = self.server_readiness(&health); + let integration = launch::client_integration(&snapshot); + let (services, services_label) = self.services_readiness(phase); + let (hook, hook_label) = self.hook_readiness(); + let overall = launch::overall(phase, server, integration, services, hook); let launch_config = self.config.validate_launch_config(); - let hook_ready = self.hook_deployed; - let can_launch = launch_config.is_ok() && hook_ready; theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); - let (gstatus, glabel) = if can_launch { - (Status::Ok, "Ready") - } else { - (Status::Warn, "Setup needed") - }; - card_header(ui, "Game & launch", Some((glabel, gstatus))); + card_header(ui, "FIFA 17", Some(readiness_pill(overall, phase))); - egui::Grid::new("game_status_grid") + egui::Grid::new("launch_status_grid") .num_columns(2) - .spacing([18.0, 8.0]) + .spacing([18.0, 9.0]) + .min_col_width(150.0) .show(ui, |ui| { - ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK)); - if hook_ready { - status_text(ui, Status::Ok, "Deployed (version.dll)"); + ui.label(RichText::new("OpenFUT server").color(theme::TEXT_WEAK)); + let server_label = if self.config.openfut_server_host.trim().is_empty() { + "Not configured".to_string() } else { - status_text(ui, Status::Warn, "Not deployed — see the Setup tab"); - } + match health.reachable { + Some(true) => { + format!("Connected · {}", self.config.openfut_server_host) + } + Some(false) => { + format!("Unreachable · {}", self.config.openfut_server_host) + } + None => "Connecting…".to_string(), + } + }; + status_text(ui, readiness_status(server), &server_label); ui.end_row(); - ui.label(RichText::new("Launch command").color(theme::TEXT_WEAK)); - if self.config.game_profile.configured() { - ui.monospace(format!( - "{} {}", - self.config.game_profile.runner, self.config.game_profile.executable - )); - } else if can_launch { - ui.monospace(&self.config.game_launch_command); - } else { - status_text(ui, Status::Warn, "Not set — configure it in Settings"); - } + ui.label(RichText::new("Client integration").color(theme::TEXT_WEAK)); + status_text( + ui, + readiness_status(integration), + match integration { + launch::Readiness::Ready => "Ready", + launch::Readiness::Busy => "Preparing…", + launch::Readiness::Attention => "Needs attention", + // Never green for "we have not looked". + launch::Readiness::Unknown => "Not checked yet", + }, + ); + ui.end_row(); + + ui.label(RichText::new("Local services").color(theme::TEXT_WEAK)); + status_text(ui, readiness_status(services), &services_label); + ui.end_row(); + + ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK)); + status_text(ui, readiness_status(hook), &hook_label); ui.end_row(); }); - ui.add_space(12.0); - ui.separator(); - ui.add_space(12.0); - self.preflight_ui(ui); - ui.add_space(14.0); + ui.add_space(16.0); + // ── The one button ─────────────────────────────────────────────── + let (label, enabled) = match phase { + launch::Phase::Idle => ("▶ Launch FIFA 17", launch_config.is_ok()), + launch::Phase::Checking => ("◌ Checking…", false), + launch::Phase::PreparingClient => ("◌ Preparing FIFA 17…", false), + launch::Phase::StartingServices => ("◌ Starting local services…", false), + launch::Phase::Validating => ("◌ Validating…", false), + launch::Phase::Launching => ("◌ Starting FIFA 17…", false), + launch::Phase::Running => ("● FIFA 17 Running", false), + launch::Phase::Failed => ("Retry Launch", launch_config.is_ok()), + }; let cta_w = ui.available_width().min(380.0); if ui .add_enabled( - can_launch, - egui::Button::new( - RichText::new("▶ Start Services & Launch Game") - .size(15.0) - .color(theme::ON_ACCENT), - ) - .fill(theme::ACCENT) - .min_size(Vec2::new(cta_w, 46.0)) - .rounding(egui::Rounding::same(10.0)), + enabled, + egui::Button::new(RichText::new(label).size(15.0).color(theme::ON_ACCENT)) + .fill(if phase == launch::Phase::Failed { + theme::WARN + } else { + theme::ACCENT + }) + .min_size(Vec2::new(cta_w, 46.0)) + .rounding(egui::Rounding::same(10.0)), ) .clicked() { self.launch_game(); } + // A configuration gap is the one thing Launch cannot repair, so it is + // the one thing that disables the button. if let Err(message) = &launch_config { ui.add_space(6.0); ui.colored_label(theme::WARN, message); } - if !server_ok { - ui.add_space(6.0); - ui.colored_label( - theme::WARN, - "Tip: the game can launch, but without a reachable server FUT features \ - won't connect.", - ); + + if phase == launch::Phase::Failed { + ui.add_space(12.0); + self.ui_launch_failure(ui, &snapshot); } + + ui.add_space(10.0); + let mut open = self.advanced_open; + let header = egui::CollapsingHeader::new( + RichText::new("Advanced / Diagnostics").color(theme::TEXT_WEAK), + ) + .open(Some(open)) + .show(ui, |ui| self.ui_advanced(ui)); + if header.header_response.clicked() { + open = !open; + } + self.advanced_open = open; }); } - /// Dashboard section for the two client-side FIFA 17 daemons. - fn ui_local_services(&mut self, ui: &mut Ui) { - use crate::local_services::Service; + /// The failure card: the high-level reason first, then which steps passed and + /// which did not. Implementation output stays behind "View details". + fn ui_launch_failure(&mut self, ui: &mut Ui, snapshot: &launch::LaunchState) { + ui.colored_label( + theme::ERROR, + RichText::new("Unable to launch FIFA 17").strong(), + ); + if let Some(reason) = &snapshot.failure { + ui.colored_label(theme::ERROR, reason); + } + ui.add_space(8.0); + for (step, outcome) in &snapshot.steps { + let (mark, color) = match outcome { + // The bundled font has no ✓/✕, so use the dot the rest of the UI + // already uses; colour carries the verdict. + launch::Outcome::Done(_) => (Status::Ok.glyph(), theme::SUCCESS), + launch::Outcome::Skipped(_) => (Status::Ok.glyph(), theme::TEXT_WEAK), + launch::Outcome::Failed(_) => (Status::Error.glyph(), theme::ERROR), + }; + ui.horizontal(|ui| { + ui.colored_label(color, mark); + ui.colored_label(color, step.label()); + ui.label( + RichText::new(outcome.detail()) + .color(theme::TEXT_FAINT) + .small(), + ); + }); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Retry").clicked() { + self.launch_game(); + } + let details_label = if self.show_failure_details { + "Hide details" + } else { + "View details" + }; + if ui.button(details_label).clicked() { + self.show_failure_details = !self.show_failure_details; + } + }); + if self.show_failure_details { + ui.add_space(8.0); + if let Some(checks) = &snapshot.checks { + check_list(ui, checks); + } + let runtimes = self.observe_services(); + for (service, runtime) in runtimes { + let mut facts = vec![if runtime.running { + "running".to_string() + } else { + "stopped".to_string() + }]; + if let Some(pid) = runtime.pid { + facts.push(format!("pid {pid}")); + } + if runtime.running && !runtime.started_by_launcher { + facts.push("started outside this launcher".into()); + } + if let Some(detail) = &runtime.detail { + facts.push(detail.clone()); + } + ui.label( + RichText::new(format!("{}: {}", service.label(), facts.join(" · "))) + .color(theme::TEXT_WEAK) + .monospace() + .small(), + ); + } + ui.add_space(6.0); + if ui.button("Open full logs").clicked() { + self.active_tab = Tab::Logs; + } + } + } - let configured = !self.config.fifa17_tools_dir.trim().is_empty(); - let lsx_running = self.lsx.running(&self.game_logs, Service::Lsx.label()); - let ap_running = self - .autopatch - .running(&self.game_logs, Service::Autopatch.label()); - let lsx_stopping = self.lsx.stopping(); - let ap_stopping = self.autopatch.stopping(); + /// Server readiness from the background health poll. The server is remote by + /// design, so this is the only dependency the launcher cannot repair. + fn server_readiness(&self, health: &crate::health::HealthState) -> launch::Readiness { + if self.config.validate_server().is_err() { + return launch::Readiness::Attention; + } + match health.reachable { + Some(true) => launch::Readiness::Ready, + Some(false) => launch::Readiness::Attention, + None => launch::Readiness::Unknown, + } + } - let (sum_status, sum_label) = if !configured { - (Status::Warn, "Not configured") - } else if lsx_running && ap_running { - (Status::Ok, "Both running") - } else if lsx_running || ap_running { - (Status::Warn, "Partial") - } else { - (Status::Idle, "Stopped") - }; - card_header(ui, "Local services", Some((sum_label, sum_status))); + /// Local-service readiness, observed — not remembered from a button press. + fn services_readiness(&self, phase: launch::Phase) -> (launch::Readiness, String) { + 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 + .iter() + .any(|(_, r)| !r.running && r.detail.is_some()); + match (ready, blocked) { + (_, true) => (launch::Readiness::Attention, "Blocked".into()), + (2, _) => (launch::Readiness::Ready, "Ready".into()), + (0, _) => ( + // Not a problem: Launch starts them. Stating "Stopped" is honest + // and does not demand an action. + launch::Readiness::Unknown, + "Stopped — Launch starts them".into(), + ), + (_, _) => (launch::Readiness::Unknown, "Partly running".into()), + } + } + + /// Hook readiness: the DLL is deployed and the address it will send FIFA to + /// matches the current settings. + fn hook_readiness(&self) -> (launch::Readiness, String) { + if !self.hook_deployed { + return (launch::Readiness::Attention, "Not deployed".into()); + } + match setup::read_hook_config(std::path::Path::new(&self.config.fifa_game_dir)) + .and_then(|body| openfut_common::ServerConfig::parse(&body).ok()) + { + Some(d) if d == self.config.server_config() => { + (launch::Readiness::Ready, format!("Deployed → {}", d.host)) + } + // Launch rewrites it, so this is not something to demand action for. + Some(d) => ( + launch::Readiness::Unknown, + format!("Deployed → {} · Launch updates it", d.host), + ), + None => (launch::Readiness::Attention, "No openfut.cfg".into()), + } + } + + /// Advanced / Diagnostics: every manual control the normal flow automates. + /// + /// 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( - "LSX (Origin emulator, loopback 4216) and autopatch (ProtoSSL cert-verify) \ - run on THIS machine — the game needs them locally. The Blaze/UTAS/roster/POW \ - responders run in the server container. Start these before launching.", + "Everything here happens automatically when you press Launch. These \ + controls exist for development and debugging.", ) .color(theme::TEXT_FAINT) .small(), ); - ui.add_space(12.0); + ui.add_space(10.0); - if !configured { + // ── Local services ─────────────────────────────────────────────────── + ui.label(RichText::new("Local services").color(theme::TEXT).strong()); + ui.label( + RichText::new( + "LSX (Origin emulator, loopback 4216) and autopatch (ProtoSSL cert-verify) \ + must run on THIS machine. The Blaze/UTAS/roster responders run on the \ + server.", + ) + .color(theme::TEXT_FAINT) + .small(), + ); + ui.add_space(6.0); + + if !tools_configured { ui.colored_label( theme::WARN, "FIFA 17 tools dir not set — configure it in Settings.", ); - return; + } 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(); + } + }); + + 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); + } } - egui::Grid::new("local_services_grid") - .num_columns(3) - .spacing([14.0, 10.0]) - .min_col_width(96.0) + ui.add_space(14.0); + + // ── Client integration ─────────────────────────────────────────────── + ui.label( + RichText::new("Client integration") + .color(theme::TEXT) + .strong(), + ); + egui::Grid::new("advanced_client_grid") + .num_columns(2) + .spacing([14.0, 8.0]) + .min_col_width(120.0) .show(ui, |ui| { - // LSX row - ui.label(RichText::new("LSX").color(theme::TEXT).strong()); - if lsx_stopping { - theme::status_pill(ui, "Stopping", Status::Busy); - } else if lsx_running { - theme::status_pill(ui, "Running", Status::Ok); + ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK)); + if self.hook_deployed { + status_text(ui, Status::Ok, "version.dll deployed"); } else { - theme::status_pill(ui, "Stopped", Status::Idle); - } - if lsx_stopping { - ui.add_enabled(false, egui::Button::new("Stopping…")); - } else if lsx_running { - if ui.button("Stop").clicked() { - self.lsx.stop(&self.game_logs, Service::Lsx); - } - } else if ui.button("Start").clicked() { - let _ = self.start_local_service(Service::Lsx); + status_text(ui, Status::Warn, "not deployed — see the Setup tab"); } ui.end_row(); - // autopatch row - ui.label(RichText::new("autopatch").color(theme::TEXT).strong()); - if ap_stopping { - theme::status_pill(ui, "Stopping", Status::Busy); - } else if ap_running { - theme::status_pill(ui, "Running", Status::Ok); - } else { - theme::status_pill(ui, "Stopped", Status::Idle); - } - if ap_stopping { - ui.add_enabled(false, egui::Button::new("Stopping…")); - } else if ap_running { - if ui.button("Stop").clicked() { - self.autopatch.stop(&self.game_logs, Service::Autopatch); - // The verified capability belongs to the FIFA process - // autopatch was serving; drop it when autopatch stops. - *self.fifa17_caps.lock() = Default::default(); + ui.label(RichText::new("Hook target").color(theme::TEXT_WEAK)); + match setup::read_hook_config(std::path::Path::new(&self.config.fifa_game_dir)) + .and_then(|body| openfut_common::ServerConfig::parse(&body).ok()) + { + Some(d) if d == self.config.server_config() => { + status_text(ui, Status::Ok, &d.host) } - } else if ui.button("Start").clicked() { - let _ = self.start_local_service(Service::Autopatch); + Some(d) => status_text( + ui, + Status::Warn, + &format!("{} — differs from Settings", d.host), + ), + None => status_text(ui, Status::Idle, "no openfut.cfg deployed"), } ui.end_row(); }); - - ui.add_space(10.0); - if ui.button("Start both local services").clicked() { - if !lsx_running { - let _ = self.start_local_service(Service::Lsx); - } - if !ap_running { - let _ = self.start_local_service(Service::Autopatch); - } + ui.add_space(8.0); + if ui + .button("Prepare client") + .on_hover_text( + "Sets ptrace_scope, the EA-redirector DNAT and /etc/hosts in one elevated \ + step (asks for your password once). Launch does this automatically when \ + it is needed.", + ) + .clicked() + { + self.advanced_prepare_client(); } - - if let Some((ok, msg)) = &self.local_services_message { - ui.add_space(6.0); + if let Some((ok, msg)) = &self.arm_status { ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg); } - } - /// The pre-launch checklist. - /// - /// Advisory by design: a failing check colours the row red and explains the - /// fix, but never disables Launch. Every one of these checks can itself be - /// wrong, and a wrong check that locks the user out of their own game is a - /// worse failure than the one it is guarding against. - fn preflight_ui(&mut self, ui: &mut Ui) { + ui.add_space(14.0); + + // ── Diagnostics ────────────────────────────────────────────────────── + ui.label(RichText::new("Diagnostics").color(theme::TEXT).strong()); + let health = self.health.snapshot(); + ui.label( + RichText::new(format!( + "Server probe: {}{}", + health.detail, + match health.last_checked { + Some(t) => format!(" ({}s ago)", t.elapsed().as_secs()), + None => String::new(), + } + )) + .color(theme::TEXT_FAINT) + .small(), + ); + ui.add_space(6.0); ui.horizontal(|ui| { if ui.button("Run pre-launch checks").clicked() { - self.preflight = Some(preflight::run(&self.config)); + self.refresh_launch_state(); + } + if ui.button("View logs").clicked() { + self.active_tab = Tab::Logs; } if ui - .button("Arm client") + .add_enabled( + self.config.game_profile.configured() + || !self.config.game_launch_command.trim().is_empty(), + egui::Button::new("Launch game only"), + ) .on_hover_text( - "Sets ptrace_scope, the EA-redirector DNAT, and /etc/hosts in one step \ - (asks for your password once). Replaces client_arm.sh.", + "Starts FIFA without preparing anything. For debugging a launch that \ + the sequence refuses.", ) .clicked() { - match crate::arm::arm(&self.config) { - Ok(summary) => { - { - let mut logs = self.game_logs.lock(); - logs.push("[launcher] client armed:".to_string()); - for line in &summary { - logs.push(format!("[launcher] - {line}")); - } - } - self.arm_status = Some(( - true, - format!("Client armed — {} change(s) applied.", summary.len()), - )); - // Re-run the checklist so the result shows immediately. - self.preflight = Some(preflight::run(&self.config)); - } - Err(e) => { - self.game_logs - .lock() - .push(format!("[launcher] arming failed: {e}")); - self.arm_status = Some((false, format!("Arming failed: {e}"))); - } - } - } - if let Some(checks) = &self.preflight { - let bad = preflight::failures(checks); - let warn = preflight::warnings(checks); - // States what was found, never what will happen. An earlier - // version predicted "the game will probably fail" on a warning - // and the game then reached the FUT hub — a checklist that - // overstates its own findings gets ignored. - let (color, text) = match (bad, warn) { - (0, 0) => (theme::SUCCESS, "no problems found".to_string()), - (0, w) => ( - theme::WARN, - format!("{w} warning(s) — worth fixing, usually not fatal"), - ), - (b, 0) => ( - theme::ERROR, - format!("{b} problem(s) — expect the game to fail"), - ), - (b, w) => (theme::ERROR, format!("{b} problem(s), {w} warning(s)")), - }; - ui.colored_label(color, text); + self.launch_game_only(); } }); - if let Some((ok, msg)) = &self.arm_status { - let color = if *ok { theme::SUCCESS } else { theme::ERROR }; - ui.colored_label(color, msg); - } - - let Some(checks) = &self.preflight else { - return; - }; - ui.add_space(4.0); - for c in checks { - let (mark, color) = match c.state { - preflight::State::Pass => ("OK ", theme::SUCCESS), - preflight::State::Warn => ("WARN", theme::WARN), - preflight::State::Fail => ("FAIL", theme::ERROR), - // Grey, never green: "not checked" must not read as "fine". - preflight::State::Skipped => ("-- ", theme::TEXT_FAINT), - }; - ui.horizontal(|ui| { - ui.colored_label(color, RichText::new(mark).monospace()); - ui.colored_label(color, &c.name); - ui.label(RichText::new(&c.detail).color(theme::TEXT_WEAK)); - }); + let snapshot = self.controller.snapshot(); + if let Some(checks) = &snapshot.checks { + ui.add_space(6.0); + check_list(ui, checks); + if let Some(age) = snapshot.checks_age { + ui.label( + RichText::new(format!("checked {}s ago", age.elapsed().as_secs())) + .color(theme::TEXT_FAINT) + .small(), + ); + } + } else { + ui.add_space(6.0); + ui.label( + RichText::new("Checks have not run yet.") + .color(theme::TEXT_FAINT) + .small(), + ); } } - fn launch_game(&mut self) { - // A new FIFA process starts UNKNOWN: never inherit a prior launch's - // verified capability. The autopatch stdout reader re-populates this. - *self.fifa17_caps.lock() = Default::default(); - if let Err(message) = self.config.validate_launch_config() { - self.game_logs - .lock() - .push(format!("[launcher] launch blocked: {message}")); - self.local_services_message = Some((false, message)); - self.active_tab = Tab::Logs; - return; - } - if !self.hook_deployed { - let message = "Hook DLL is not deployed. Complete Setup before launching.".to_string(); - self.game_logs - .lock() - .push(format!("[launcher] launch blocked: {message}")); - self.local_services_message = Some((false, message)); - self.active_tab = Tab::Logs; - return; - } - // Fail-closed: launching FIFA at an unknown server is worse than not - // launching. This is the only moment the file the game reads is - // guaranteed to agree with the settings the user is looking at. - if let Err(message) = self.write_hook_config() { - self.game_logs - .lock() - .push(format!("[launcher] launch blocked: {message}")); - self.local_services_message = Some((false, message)); - self.active_tab = Tab::Logs; - return; - } - self.game_logs.lock().push(format!( - "[launcher] hook config written: server={} https={} blaze-redir={} blaze-main={}", - self.config.openfut_server_host, - self.config.openfut_https_port, - self.config.openfut_blaze_redirector_port, - self.config.openfut_blaze_main_port, - )); - let account = match crate::account_sync::sync(&self.config) { - Ok(account) => account, - Err(message) => { - self.game_logs - .lock() - .push(format!("[launcher] account sync failed: {message}")); - self.local_services_message = Some((false, message)); - self.active_tab = Tab::Logs; - return; - } - }; - self.game_logs.lock().push(format!( - "[launcher] account synchronized: {}/{} level={} XP={} account-funds={} FUT-coins={} unopened-packs={}", - account.persona_id, - account.persona_name, - account.level, - account.experience, - account.account_funds, - account.coins, - account.unopened_packs, - )); - if let Err(message) = self.ensure_local_services() { - self.game_logs - .lock() - .push(format!("[launcher] launch blocked: {message}")); - self.local_services_message = Some((false, message)); - self.active_tab = Tab::Logs; - return; - } + /// Observe both services once, for rendering. + fn observe_services( + &self, + ) -> Vec<( + crate::local_services::Service, + crate::local_services::ServiceRuntime, + )> { + use crate::local_services::Service; + let mut supervisor = self.controller.services.lock(); + [Service::Lsx, Service::Autopatch] + .into_iter() + .map(|s| { + let runtime = supervisor.observe(s); + (s, runtime) + }) + .collect() + } - // Prefer the native profile; fall back to the user's shell command. - // The fallback is why an existing setup keeps working after upgrading, - // and why a profile that misbehaves is recoverable without a rebuild. + /// Finish any Advanced "Restart" the user asked for. Stopping is asynchronous + /// (it kills and reaps off the UI thread), so the start half has to wait for + /// the process to actually be gone rather than racing its own kill. + fn drive_restart_queue(&mut self) { + if self.restart_queue.is_empty() { + return; + } + let pending = std::mem::take(&mut self.restart_queue); + for service in pending { + let gone = { + let mut supervisor = self.controller.services.lock(); + !supervisor.stopping(service) && !supervisor.observe(service).running + }; + if gone { + self.advanced_start_service(service); + } else { + self.restart_queue.push(service); + } + } + } + + /// Start FIFA with no preparation at all. Debug escape hatch: the launch + /// sequence refusing to start the game is usually right, and when it is + /// wrong a developer still needs to get into the client. + fn launch_game_only(&mut self) { let result = if self.config.game_profile.configured() { - game_launch::launch(&self.config.game_profile, &self.game_logs) + game_launch::launch(&self.config.game_profile, &self.game_logs, || {}) } else { - let cmd = self.config.game_launch_command.clone(); - let workdir = self.config.game_launch_workdir.clone(); - setup::launch_game(&cmd, &workdir, Arc::clone(&self.game_logs)) + setup::launch_game( + &self.config.game_launch_command, + &self.config.game_launch_workdir, + Arc::clone(&self.game_logs), + || {}, + ) }; - if let Err(e) = result { - self.game_logs - .lock() - .push(format!("[launcher] launch failed: {e}")); + match result { + Ok(()) => { + self.local_services_message = + Some((true, "FIFA started with no preparation.".into())) + } + Err(e) => self.local_services_message = Some((false, format!("launch failed: {e}"))), } self.active_tab = Tab::Logs; } - /// Start one companion service, recording a user-facing result message. - fn ensure_local_services(&mut self) -> Result<(), String> { - use crate::local_services::Service; - - if !self.lsx.running(&self.game_logs, Service::Lsx.label()) { - self.start_local_service(Service::Lsx)?; + /// The one thing the normal user does. Everything the launch needs — + /// connectivity, the hook file, client preparation, LSX, autopatch, final + /// validation — is decided and performed by [`launch::Controller`], which + /// reuses whatever is already healthy. + fn launch_game(&mut self) { + if let Err(message) = self.config.validate_launch_config() { + // A configuration gap is not something a launch sequence can repair. + self.game_logs + .lock() + .push(format!("[launcher] launch blocked: {message}")); + self.local_services_message = Some((false, message)); + return; } - if !self - .autopatch - .running(&self.game_logs, Service::Autopatch.label()) - { - self.start_local_service(Service::Autopatch)?; - } - Ok(()) + self.show_failure_details = false; + self.controller + .launch(&self.config, &self.game_logs, &self.fifa17_caps); } - fn start_local_service(&mut self, which: crate::local_services::Service) -> Result<(), String> { - use crate::local_services::spawn; - let py = self.config.fifa17_python.clone(); - let dir = self.config.fifa17_tools_dir.clone(); - let persona_id = self.config.fut_persona_id; - let persona_name = self.config.fut_persona_name.clone(); - let logs = Arc::clone(&self.game_logs); - // Only autopatch advertises the verified resolver guard, so only it - // receives the shared capability sink; LSX passes None. - let capability = match which { - crate::local_services::Service::Autopatch => { - Some(crate::local_services::CapabilityWiring { - server_host: self.config.openfut_server_host.clone(), - account_sync_port: self.config.openfut_account_sync_port, - sink: Arc::clone(&self.fifa17_caps), - }) - } - crate::local_services::Service::Lsx => None, + /// Re-observe the world without touching it (startup, and after settings + /// change). Runs on a worker thread; the checks open sockets. + fn refresh_launch_state(&self) { + self.controller + .refresh(&self.config, &self.game_logs, &self.fifa17_caps); + } + + /// Start one service by hand, from Advanced. Same supervisor the launch + /// 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 { + crate::local_services::Service::Autopatch => { + Some(crate::local_services::CapabilityWiring { + server_host: self.config.openfut_server_host.clone(), + account_sync_port: self.config.openfut_account_sync_port, + sink: Arc::clone(&self.fifa17_caps), + }) + } + crate::local_services::Service::Lsx => None, + }, }; - let slot = match which { - crate::local_services::Service::Lsx => &mut self.lsx, - crate::local_services::Service::Autopatch => &mut self.autopatch, - }; - match spawn( - which, - &py, - &dir, - persona_id, - &persona_name, - capability, - logs, - ) { - Ok(child) => { - *slot = crate::local_services::ManagedService::from_child(child); - self.local_services_message = Some((true, format!("{} started.", which.label()))); - Ok(()) + let result = self + .controller + .services + .lock() + .ensure_running(service, spec); + self.local_services_message = Some(match result { + Ok(crate::local_services::Ensured::Started) => { + (true, format!("{} started.", service.label())) } - Err(e) => { - let message = format!("{}: {e}", which.label()); - self.local_services_message = Some((false, message.clone())); - Err(message) + Ok(crate::local_services::Ensured::Reused) => { + (true, format!("{} was already running.", service.label())) } + Err(e) => (false, e), + }); + } + + /// Stop one service by hand. A service this launcher did not start is + /// reported, never killed. + fn advanced_stop_service(&mut self, service: crate::local_services::Service) { + if service == crate::local_services::Service::Autopatch { + // The verified capability belongs to the FIFA process autopatch was + // serving; drop it when autopatch goes away. + *self.fifa17_caps.lock() = Default::default(); + } + if let Err(e) = self.controller.services.lock().stop(service) { + self.local_services_message = Some((false, e)); + } + } + + /// Client preparation, by hand, from Advanced. Internally this is `arm`; the + /// normal flow performs it automatically and never names it. + fn advanced_prepare_client(&mut self) { + match crate::arm::arm(&self.config) { + Ok(changes) => { + for change in &changes { + self.game_logs + .lock() + .push(format!("[launcher] prepared: {change}")); + } + self.arm_status = Some(( + true, + format!("Client prepared — {} change(s) applied.", changes.len()), + )); + self.refresh_launch_state(); + } + Err(e) => self.arm_status = Some((false, format!("Client preparation failed: {e}"))), } } @@ -1674,6 +1837,7 @@ impl LauncherApp { impl eframe::App for LauncherApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { ctx.request_repaint_after(std::time::Duration::from_millis(500)); + self.drive_restart_queue(); egui::TopBottomPanel::top("header") .frame( @@ -1751,6 +1915,62 @@ impl eframe::App for LauncherApp { } } +/// Status pill for the launch card's headline. +fn readiness_pill(readiness: launch::Readiness, phase: launch::Phase) -> (&'static str, Status) { + if phase == launch::Phase::Running { + return ("Running", Status::Ok); + } + match readiness { + launch::Readiness::Ready => ("Ready", Status::Ok), + launch::Readiness::Busy => ("Working", Status::Busy), + launch::Readiness::Attention => ("Needs attention", Status::Warn), + // "Unknown" is deliberately not "Ready": the launcher has not looked, or + // the thing it saw is something Launch will fix on the way. + launch::Readiness::Unknown => ("Not ready", Status::Unknown), + } +} + +fn readiness_status(readiness: launch::Readiness) -> Status { + match readiness { + launch::Readiness::Ready => Status::Ok, + launch::Readiness::Busy => Status::Busy, + launch::Readiness::Attention => Status::Warn, + launch::Readiness::Unknown => Status::Idle, + } +} + +/// The pre-launch checklist rows, for Advanced and the failure details. +fn check_list(ui: &mut Ui, checks: &[preflight::Check]) { + // States what was found, never what will happen. An earlier version predicted + // "the game will probably fail" on a warning and the game then reached the + // FUT hub — a checklist that overstates its findings gets ignored. + let (bad, warn) = (preflight::failures(checks), preflight::warnings(checks)); + let (color, summary) = match (bad, warn) { + (0, 0) => (theme::SUCCESS, "no problems found".to_string()), + (0, w) => ( + theme::WARN, + format!("{w} warning(s) — worth fixing, usually not fatal"), + ), + (b, 0) => (theme::ERROR, format!("{b} problem(s) found")), + (b, w) => (theme::ERROR, format!("{b} problem(s), {w} warning(s)")), + }; + ui.colored_label(color, summary); + for c in checks { + let (mark, color) = match c.state { + preflight::State::Pass => ("OK ", theme::SUCCESS), + preflight::State::Warn => ("WARN", theme::WARN), + preflight::State::Fail => ("FAIL", theme::ERROR), + // Grey, never green: "not checked" must not read as "fine". + preflight::State::Skipped => ("-- ", theme::TEXT_FAINT), + }; + ui.horizontal(|ui| { + ui.colored_label(color, RichText::new(mark).monospace()); + ui.colored_label(color, &c.name); + ui.label(RichText::new(&c.detail).color(theme::TEXT_WEAK)); + }); + } +} + fn log_line_color(line: &str) -> Color32 { let lower = line.to_lowercase(); if lower.contains("error") || lower.contains("panic") { diff --git a/src/game_launch.rs b/src/game_launch.rs index 09852f2..7a6d752 100644 --- a/src/game_launch.rs +++ b/src/game_launch.rs @@ -42,8 +42,13 @@ fn say(log: &Log, msg: impl Into) { /// Prepare the prefix, satisfy the licence precondition, and start the game. /// /// Returns once the game process has been spawned; its output continues to -/// stream into `log` on background threads. -pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { +/// stream into `log` on background threads. `on_exit` fires when the process +/// 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)?; let game_dir = PathBuf::from(&profile.game_dir); @@ -79,7 +84,12 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { let child = cmd .spawn() .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(()) } @@ -222,7 +232,12 @@ fn non_empty_file(path: &Path) -> bool { } /// 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() { let buf = Arc::clone(&log); std::thread::spawn(move || { @@ -242,6 +257,7 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) { std::thread::spawn(move || { let _ = child.wait(); log.lock().push(exit_msg.to_string()); + on_exit(); }); } @@ -444,7 +460,7 @@ mod tests { game_dir: "/definitely/not/here".into(), ..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}"); } } diff --git a/src/launch.rs b/src/launch.rs new file mode 100644 index 0000000..9649ad9 --- /dev/null +++ b/src/launch.rs @@ -0,0 +1,942 @@ +//! 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//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, + /// 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>, + pub checks_age: Option, +} + +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; + /// 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; + /// Which of the arming-repairable checks are currently failing. + fn run_checks(&mut self) -> Vec; + /// Elevated client preparation (`arm`). Returns what it changed. + fn prepare_client(&mut self) -> Result, String>; + fn ensure_service(&mut self, service: Service) -> Result; + 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>) -> 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 = 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 = 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>) { + 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 { + 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>, + logs: Arc>, + caps: Arc>, + state: Arc>, +} + +impl RealOps { + fn say(&self, message: impl Into) { + self.logs.lock().push(message.into()); + } +} + +impl LaunchOps for RealOps { + fn connect_server(&mut self) -> Result { + 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 { + 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 { + preflight::run(&self.config) + } + + fn prepare_client(&mut self) -> Result, 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 { + 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 + // 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>, + pub services: Arc>, +} + +impl Controller { + pub fn new(logs: Arc>) -> 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>, + caps: &Arc>, + ) -> 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>, + caps: &Arc>, + ) { + { + 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>, + caps: &Arc>, + ) { + { + 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>, + checks: Vec, + checks_after_prepare: Option>, + prepare_result: Option, String>>, + service_result: Vec<(Service, Result)>, + game_result: Option>, + // Observed calls + prepared: usize, + started: Vec, + 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 { + if self.server_up { + Ok("connected".into()) + } else { + Err("not reachable — is the OpenFUT server running?".into()) + } + } + fn ensure_client_files(&mut self) -> Result { + self.client_files + .clone() + .unwrap_or_else(|| Err("no client-files result configured".into())) + } + fn run_checks(&mut self) -> Vec { + 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, String> { + self.prepared += 1; + self.prepare_result + .clone() + .unwrap_or_else(|| Err("no prepare configured".into())) + } + fn ensure_service(&mut self, service: Service) -> Result { + 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> { + 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); + } +} diff --git a/src/local_services.rs b/src/local_services.rs index f63d992..89945b4 100644 --- a/src/local_services.rs +++ b/src/local_services.rs @@ -29,6 +29,10 @@ use crate::fifa17_capability::{ }; 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)] struct CommandParts { program: String, @@ -36,7 +40,7 @@ struct CommandParts { } /// 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 { /// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged. Lsx, @@ -179,6 +183,11 @@ impl ManagedService { self.stopping.is_some() } + /// PID of the child this launcher owns, if it owns one. + pub fn pid(&self) -> Option { + self.child.as_ref().map(Child::id) + } + /// Begin stopping the service without waiting on the egui UI thread. pub fn stop(&mut self, log: &Arc>, service: Service) { if self.stopping.is_some() { @@ -219,6 +228,235 @@ pub struct CapabilityWiring { pub sink: Arc>, } +/// What is actually true about one companion service right now. +/// +/// 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, + /// Observed supporting detail for the Advanced panel. Only ever facts the + /// launcher actually established. + pub detail: Option, +} + +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 responder script 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. +pub fn foreign_pid(service: Service, ours: Option) -> Option { + let script = service.script(); + 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::() 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| String::from_utf8_lossy(arg).ends_with(script)) + { + 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>, +} + +/// 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>) -> 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 { + 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.python, + &spec.tools_dir, + 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 python: String, + pub tools_dir: String, + pub persona_id: u64, + pub persona_name: String, + pub capability: Option, +} + /// 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. @@ -350,7 +588,7 @@ pub fn spawn( } 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)) { let _ = child.kill(); let _ = child.wait(); @@ -418,4 +656,89 @@ mod tests { .expect_err("exited child must not be reported 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()) + ); + } } diff --git a/src/main.rs b/src/main.rs index d2fe21b..9a0754a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod config; mod fifa17_capability; mod game_launch; mod health; +mod launch; mod local_services; mod logs; mod netcheck; diff --git a/src/preflight.rs b/src/preflight.rs index 947a38a..d9dccda 100644 --- a/src/preflight.rs +++ b/src/preflight.rs @@ -239,7 +239,7 @@ fn hostname_mapping(cfg: &LauncherConfig) -> Check { } /// 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"; let host = cfg.openfut_server_host.trim(); if host.is_empty() { diff --git a/src/setup.rs b/src/setup.rs index 256f1d1..0fba7cb 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -140,6 +140,7 @@ pub fn launch_game( command: &str, workdir: &str, log_buf: std::sync::Arc>, + on_exit: impl FnOnce() + Send + 'static, ) -> anyhow::Result<()> { use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; @@ -178,12 +179,15 @@ pub fn launch_game( }); } // 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 || { let _ = child.wait(); log_buf .lock() .push("[launcher] game process exited.".to_string()); + on_exit(); }); Ok(())