diff --git a/Cargo.toml b/Cargo.toml index e1e5e25..180017d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,3 +13,6 @@ serde_json = "1" dirs = "5" chrono = { version = "0.4", features = ["serde"] } openfut-common = { path = "openfut-common" } +# parking_lot over std::sync: every lock here is taken and used immediately, so +# the poisoning unwrap at each call site is pure noise (project rule). +parking_lot = "0.12" diff --git a/src/account_monitor.rs b/src/account_monitor.rs index 326be67..e8f3b08 100644 --- a/src/account_monitor.rs +++ b/src/account_monitor.rs @@ -8,10 +8,11 @@ //! target the UI re-points when the server config changes, and a shared state //! snapshot the UI renders each frame. +use parking_lot::Mutex; use std::{ sync::{ atomic::{AtomicBool, Ordering}, - Arc, Mutex, + Arc, }, thread, time::{Duration, Instant}, @@ -68,15 +69,15 @@ impl AccountMonitor { let t_running = Arc::clone(&running); thread::spawn(move || { while t_running.load(Ordering::Relaxed) { - let target = t_target.lock().unwrap().clone(); + let target = t_target.lock().clone(); match target { None => { // No server configured — reset to the idle prompt state. - *t_state.lock().unwrap() = AccountState::default(); + *t_state.lock() = AccountState::default(); } Some(config) => { let result = account_sync::sync(&config); - let mut state = t_state.lock().unwrap(); + let mut state = t_state.lock(); state.configured = true; state.last_checked = Some(Instant::now()); match result { @@ -107,11 +108,11 @@ impl AccountMonitor { /// Point the monitor at a new server/account. `None` (no server configured) /// puts it back into the idle prompt state. pub fn set_target(&self, target: Option) { - *self.target.lock().unwrap() = target; + *self.target.lock() = target; } pub fn snapshot(&self) -> AccountState { - self.state.lock().unwrap().clone() + self.state.lock().clone() } } diff --git a/src/account_sync.rs b/src/account_sync.rs index 4d367d1..eba4022 100644 --- a/src/account_sync.rs +++ b/src/account_sync.rs @@ -7,11 +7,18 @@ use std::time::Duration; const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync"; const TIMEOUT: Duration = Duration::from_secs(3); +/// The launcher's view of the account, sent on every sync. +/// +/// `persona_id`/`persona_name` are `Option` because omitting them is meaningful: +/// the server then answers with the persona *it* is configured for, which is how +/// first-run account creation learns an identity instead of inventing one. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct AccountSyncRequest<'a> { - persona_id: u64, - persona_name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + persona_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + persona_name: Option<&'a str>, level: u32, experience: u32, experience_max: u32, @@ -54,7 +61,64 @@ pub struct AccountSummary { pub fn sync(config: &LauncherConfig) -> Result { config.validate_server()?; config.validate_account()?; + let account = post( + config, + &AccountSyncRequest { + persona_id: Some(config.fut_persona_id), + persona_name: Some(config.fut_persona_name.trim()), + level: config.fut_account_level, + experience: config.fut_account_experience, + experience_max: config.fut_account_experience_max, + account_funds: config.fut_account_funds, + account_funds_cap: config.fut_account_funds_cap, + }, + )?; + // The server echoes the persona it selected. A different one means the two + // sides disagree about who is playing, which must never pass silently. + if account.persona_id != config.fut_persona_id { + return Err(format!( + "account server selected persona {} instead of {}", + account.persona_id, config.fut_persona_id + )); + } + Ok(account) +} +/// Ask the server which account it serves, for first-run account creation. +/// +/// Sending no persona makes the server fall back to the one it was started with +/// and answer with its real club and Core coin balance. That is the whole reason +/// the launcher never has to invent a persona id: the identity that matters is +/// the server's, and this is how it is claimed. +pub fn discover(config: &LauncherConfig) -> Result { + config.validate_server()?; + let account = post( + config, + &AccountSyncRequest { + persona_id: None, + persona_name: None, + level: config.fut_account_level.max(1), + experience: config.fut_account_experience, + experience_max: config.fut_account_experience_max.max(1), + account_funds: config.fut_account_funds, + account_funds_cap: config.fut_account_funds_cap, + }, + )?; + if account.persona_id == 0 { + return Err( + "account server returned no persona — is it configured with \ + a persona id?" + .to_string(), + ); + } + if account.persona_name.trim().is_empty() { + return Err("account server returned an empty persona name".to_string()); + } + Ok(account) +} + +/// One bounded POST to `/openfut/account/sync`, returning the account summary. +fn post(config: &LauncherConfig, body: &AccountSyncRequest<'_>) -> Result { let host = config.openfut_server_host.trim(); let port = config.openfut_account_sync_port; let address = (host, port) @@ -71,16 +135,8 @@ pub fn sync(config: &LauncherConfig) -> Result { .set_write_timeout(Some(TIMEOUT)) .map_err(|error| format!("cannot set account sync timeout: {error}"))?; - let payload = serde_json::to_vec(&AccountSyncRequest { - persona_id: config.fut_persona_id, - persona_name: config.fut_persona_name.trim(), - level: config.fut_account_level, - experience: config.fut_account_experience, - experience_max: config.fut_account_experience_max, - account_funds: config.fut_account_funds, - account_funds_cap: config.fut_account_funds_cap, - }) - .map_err(|error| format!("cannot encode account sync request: {error}"))?; + let payload = serde_json::to_vec(body) + .map_err(|error| format!("cannot encode account sync request: {error}"))?; let request = format!( "POST {ACCOUNT_SYNC_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", @@ -107,21 +163,15 @@ pub fn sync(config: &LauncherConfig) -> Result { .and_then(|line| line.split_whitespace().nth(1)) .and_then(|value| value.parse::().ok()) .ok_or_else(|| "account server returned a malformed status line".to_string())?; - let body = &response[separator + 4..]; + let response_body = &response[separator + 4..]; if !(200..300).contains(&status) { - let detail = String::from_utf8_lossy(body); + let detail = String::from_utf8_lossy(response_body); return Err(format!( "account server rejected sync (HTTP {status}): {detail}" )); } - let envelope: AccountSyncResult = serde_json::from_slice(body) + let envelope: AccountSyncResult = serde_json::from_slice(response_body) .map_err(|error| format!("account server returned invalid JSON: {error}"))?; - if envelope.account.persona_id != config.fut_persona_id { - return Err(format!( - "account server selected persona {} instead of {}", - envelope.account.persona_id, config.fut_persona_id - )); - } Ok(envelope.account) } @@ -185,4 +235,108 @@ mod tests { assert_eq!(selected.unopened_packs, 1); server.join().unwrap(); } + + /// Serve exactly one `/openfut/account/sync` POST, handing the decoded + /// request text to `inspect` and replying with `body`. + fn serve_once( + inspect: impl FnOnce(&str) + Send + 'static, + body: &'static str, + ) -> (u16, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + loop { + let mut chunk = [0; 1024]; + let count = socket.read(&mut chunk).unwrap(); + assert!(count > 0); + request.extend_from_slice(&chunk[..count]); + if let Some(separator) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..separator]); + let length = headers + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .unwrap() + .parse::() + .unwrap(); + if request.len() >= separator + 4 + length { + break; + } + } + } + inspect(&String::from_utf8_lossy(&request)); + write!( + socket, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + (port, handle) + } + + #[test] + fn discover_omits_the_persona_so_the_server_names_its_own() { + // The point of first-run discovery: the launcher must not send a guessed + // persona, because the server would echo the guess straight back. + let (port, server) = serve_once( + |request| { + assert!(!request.contains("personaId"), "{request}"); + assert!(!request.contains("personaName"), "{request}"); + }, + r#"{"status":"OK","account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC","level":1,"experience":0,"accountFunds":0,"coins":29876776,"unopenedPacks":0}}"#, + ); + let config = LauncherConfig { + openfut_server_host: "127.0.0.1".into(), + openfut_account_sync_port: port, + ..LauncherConfig::default() + }; + // Deliberately an unconfigured account: discovery must work before one + // exists, which is the whole reason it does not call `validate_account`. + assert_eq!(config.fut_persona_id, 0); + let found = discover(&config).unwrap(); + assert_eq!(found.persona_id, 33_068_179); + assert_eq!(found.persona_name, "CAGE"); + assert_eq!(found.club_name, "OpenFUT"); + assert_eq!(found.coins, 29_876_776); + server.join().unwrap(); + } + + #[test] + fn discover_rejects_a_server_that_names_no_persona() { + // A zero persona would otherwise be written into the config as a real + // account and fail much later, at launch, as a mismatch. + let (port, server) = serve_once( + |_| {}, + r#"{"status":"OK","account":{"personaId":0,"personaName":"","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#, + ); + let config = LauncherConfig { + openfut_server_host: "127.0.0.1".into(), + openfut_account_sync_port: port, + ..LauncherConfig::default() + }; + let error = discover(&config).unwrap_err(); + assert!(error.contains("no persona"), "{error}"); + server.join().unwrap(); + } + + #[test] + fn sync_refuses_a_server_that_selects_a_different_persona() { + let (port, server) = serve_once( + |_| {}, + r#"{"status":"OK","account":{"personaId":999,"personaName":"OTHER","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#, + ); + let config = LauncherConfig { + openfut_server_host: "127.0.0.1".into(), + openfut_account_sync_port: port, + fut_persona_id: 12345678, + fut_persona_name: "TEST_USER".into(), + ..LauncherConfig::default() + }; + let error = sync(&config).unwrap_err(); + assert!(error.contains("999"), "{error}"); + server.join().unwrap(); + } } diff --git a/src/app.rs b/src/app.rs index 75a04fa..ad749d4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,6 @@ -use std::sync::{Arc, Mutex}; +use std::sync::Arc; + +use parking_lot::Mutex; use egui::{Color32, RichText, ScrollArea, Ui, Vec2}; @@ -11,25 +13,36 @@ use crate::{ #[derive(Clone, Copy, PartialEq)] enum Tab { + /// Guided first-run flow: connect, claim an account, launch. + Welcome, Dashboard, Logs, Setup, - Config, + Settings, } impl Tab { - /// The tab shown on startup. Defaults to the Dashboard; an optional - /// `OPENFUT_LAUNCHER_TAB` env var (dashboard|logs|setup|config) overrides - /// it — handy for screenshotting a specific tab without clicking. - fn default_active() -> Tab { + /// The tab shown on startup. + /// + /// A fresh install opens on [`Tab::Welcome`], because the Dashboard's honest + /// rendering of an unconfigured launcher is a column of warnings pointing at + /// other tabs — accurate, and useless as a first impression. An optional + /// `OPENFUT_LAUNCHER_TAB` env var (welcome|dashboard|logs|setup|settings) + /// overrides it, for screenshotting a specific tab without clicking. + fn default_active(config: &LauncherConfig) -> Tab { match std::env::var("OPENFUT_LAUNCHER_TAB") .unwrap_or_default() .to_ascii_lowercase() .as_str() { + "welcome" => Tab::Welcome, + "dashboard" => Tab::Dashboard, "logs" => Tab::Logs, "setup" => Tab::Setup, - "config" => Tab::Config, + // `config` stays accepted: it is what every existing screenshot + // script and note passes. + "settings" | "config" => Tab::Settings, + _ if config.needs_onboarding() => Tab::Welcome, _ => Tab::Dashboard, } } @@ -59,6 +72,10 @@ pub struct LauncherApp { /// Result of the last "Test Connection" click. test_message: Option<(bool, String)>, + /// Result of the last "Create account" / "Refresh from server" click on the + /// 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, @@ -93,18 +110,23 @@ impl LauncherApp { let account = AccountMonitor::new(); account.set_target(config.account_target()); + // Decided before `config` moves into the struct: a fresh install opens + // on the guided flow, a configured one on the dashboard. + let active_tab = Tab::default_active(&config); + Self { config, config_dirty: false, health, account, game_logs: Arc::new(Mutex::new(LogBuffer::new())), - active_tab: Tab::default_active(), + active_tab, log_follow: true, hook_deployed, cert_path, setup_message: None, test_message: None, + account_message: None, lsx: crate::local_services::ManagedService::default(), autopatch: crate::local_services::ManagedService::default(), local_services_message: None, @@ -121,6 +143,307 @@ impl LauncherApp { self.account.set_target(self.config.account_target()); } + /// Write `openfut.cfg` from the CURRENT settings, so the injected hook sends + /// the game where the UI says it does. + /// + /// Everything else in this launcher — health pill, account card, preflight — + /// reads the in-memory config, but the game only ever sees this file. Until + /// this ran at launch time, changing the server in settings left FIFA talking + /// to the previous host while every panel showed the new one online. + fn write_hook_config(&self) -> Result<(), String> { + let contents = self.config.hook_cfg_contents()?; + setup::update_hook_config(std::path::Path::new(&self.config.fifa_game_dir), &contents) + .map_err(|e| { + format!( + "cannot write {} in {}: {e}", + setup::HOOK_CFG_FILE, + self.config.fifa_game_dir + ) + }) + } + + /// Claim the account the server is configured for, and adopt it locally. + /// + /// The launcher never invents a persona. It asks the server who it serves + /// (`account_sync::discover`) and stores the answer, so the identity the game + /// authenticates with is by construction the identity the server expects. + fn create_account(&mut self) { + match crate::account_sync::discover(&self.config) { + Ok(found) => { + self.config.fut_persona_id = found.persona_id; + self.config.fut_persona_name = found.persona_name.clone(); + self.config.save(); + self.config_dirty = false; + self.refresh_health_target(); + let club = if found.club_name.trim().is_empty() { + found.persona_name.clone() + } else { + found.club_name.clone() + }; + self.account_message = Some(( + true, + format!( + "Signed in as {} · {} · {} coins", + found.persona_name, + club, + thousands_i64(found.coins) + ), + )); + self.game_logs.lock().push(format!( + "[launcher] account claimed from server: {}/{}", + found.persona_id, found.persona_name + )); + } + Err(message) => self.account_message = Some((false, message)), + } + } + + /// The guided first-run flow: connect, claim an account, launch. + /// + /// Three steps in the order a new install must satisfy them, each showing its + /// own live state so the user is never asked to remember which tab held what. + fn ui_welcome(&mut self, ui: &mut Ui) { + let health = self.health.snapshot(); + let server_ok = self.config.validate_server().is_ok(); + let account_ok = self.config.account_configured(); + self.hook_deployed = + setup::hook_dll_deployed(std::path::Path::new(&self.config.fifa_game_dir)); + let launch_ready = self.config.validate_launch_config().is_ok() && self.hook_deployed; + + ui.heading("Welcome to OpenFUT"); + ui.add_space(4.0); + ui.label( + RichText::new( + "Three steps to get playing. Everything here can be changed later in \ + Settings.", + ) + .color(theme::TEXT_WEAK), + ); + ui.add_space(16.0); + + // ── Step 1 · the server ─────────────────────────────────────────────── + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + step_header(ui, 1, "Connect to your OpenFUT server", server_ok); + ui.label( + RichText::new( + "The IP or hostname of the machine running OpenFUT. FIFA's EA \ + traffic is redirected there.", + ) + .color(theme::TEXT_WEAK), + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label("Server:"); + if ui + .add( + egui::TextEdit::singleline(&mut self.config.openfut_server_host) + .hint_text("e.g. 10.10.0.120 or fut.mylan.home") + .desired_width(240.0), + ) + .changed() + { + self.config_dirty = true; + self.test_message = None; + self.account_message = None; + self.refresh_health_target(); + } + if ui + .add_enabled(self.config_dirty, egui::Button::new("Save")) + .clicked() + { + self.save_settings(); + } + if ui + .add_enabled(server_ok, egui::Button::new("Test")) + .clicked() + { + let outcome = netcheck::test_connection(&self.config.server_config()); + self.test_message = Some((outcome.ok, outcome.message)); + } + }); + ui.add_space(6.0); + match health.reachable { + Some(true) => status_text(ui, Status::Ok, &health.detail), + Some(false) => status_text(ui, Status::Error, &health.detail), + None if server_ok => status_text(ui, Status::Busy, "Checking…"), + None => status_text(ui, Status::Idle, "No server set yet"), + } + if let Some((ok, msg)) = &self.test_message { + ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg); + } + }); + + ui.add_space(14.0); + + // ── Step 2 · the account ────────────────────────────────────────────── + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + step_header(ui, 2, "Your account", account_ok); + ui.label( + RichText::new( + "Your club lives on the server. The launcher asks the server which \ + account it serves and signs you in to it — there is nothing to \ + make up and no password to choose.", + ) + .color(theme::TEXT_WEAK), + ); + ui.add_space(8.0); + + if account_ok { + egui::Grid::new("welcome_account_grid") + .num_columns(2) + .spacing([18.0, 8.0]) + .show(ui, |ui| { + ui.label(RichText::new("Signed in as").color(theme::TEXT_WEAK)); + ui.label(RichText::new(&self.config.fut_persona_name).strong()); + ui.end_row(); + ui.label(RichText::new("Persona ID").color(theme::TEXT_WEAK)); + ui.monospace(self.config.fut_persona_id.to_string()); + ui.end_row(); + }); + ui.add_space(8.0); + if ui + .add_enabled(server_ok, egui::Button::new("Refresh from server")) + .clicked() + { + self.create_account(); + } + } else { + // Gated on the address being usable, NOT on the health pill: that + // pill probes the HTTPS port, while claiming an account talks to + // the account port. Gating on the wrong port would disable this + // button on a server that answers it perfectly well, so let the + // request itself report the truth. + let why = (!server_ok).then_some("Set a server address first."); + if ui + .add_enabled( + why.is_none(), + egui::Button::new( + RichText::new("Create my account").color(theme::ON_ACCENT), + ) + .fill(theme::ACCENT) + .min_size(Vec2::new(180.0, 34.0)), + ) + .clicked() + { + self.create_account(); + } + if let Some(why) = why { + ui.add_space(6.0); + ui.colored_label(theme::TEXT_FAINT, why); + } + } + if let Some((ok, msg)) = &self.account_message { + ui.add_space(6.0); + ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg); + } + }); + + ui.add_space(14.0); + + // ── Step 3 · the game ───────────────────────────────────────────────── + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + step_header(ui, 3, "Connect FIFA to OpenFUT", launch_ready); + ui.label( + RichText::new( + "FIFA needs the network hook deployed into its game folder, and the \ + launcher needs to know how to start it.", + ) + .color(theme::TEXT_WEAK), + ); + ui.add_space(8.0); + egui::Grid::new("welcome_game_grid") + .num_columns(2) + .spacing([18.0, 8.0]) + .show(ui, |ui| { + ui.label(RichText::new("Network hook").color(theme::TEXT_WEAK)); + if self.hook_deployed { + status_text(ui, Status::Ok, "Deployed"); + } else { + status_text(ui, Status::Warn, "Not deployed"); + } + ui.end_row(); + ui.label(RichText::new("Game").color(theme::TEXT_WEAK)); + match self.config.validate_launch_config() { + Ok(()) => status_text(ui, Status::Ok, "Configured"), + Err(_) => status_text(ui, Status::Warn, "Not configured"), + } + ui.end_row(); + }); + ui.add_space(10.0); + ui.horizontal(|ui| { + if !self.hook_deployed && ui.button("Open Setup").clicked() { + self.active_tab = Tab::Setup; + } + if ui.button("Open Settings").clicked() { + self.active_tab = Tab::Settings; + } + }); + if let Err(message) = self.config.validate_launch_config() { + ui.add_space(6.0); + ui.colored_label(theme::TEXT_FAINT, message); + } + }); + + ui.add_space(18.0); + + // ── The payoff ──────────────────────────────────────────────────────── + let cta_w = ui.available_width().min(380.0); + if ui + .add_enabled( + launch_ready, + 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)), + ) + .clicked() + { + self.launch_game(); + } + ui.add_space(8.0); + if ui + .add( + egui::Button::new(RichText::new("Skip to dashboard").color(theme::TEXT_WEAK)) + .frame(false), + ) + .clicked() + { + self.active_tab = Tab::Dashboard; + } + } + + /// Persist the config and, when the hook is already deployed, push the new + /// server address into the file the game reads. Saving settings that the game + /// then ignores is the failure this exists to prevent. + fn save_settings(&mut self) { + self.config.save(); + self.config_dirty = false; + self.refresh_health_target(); + if self.hook_deployed { + match self.write_hook_config() { + Ok(()) => { + self.setup_message = Some(( + true, + format!( + "Saved — the hook now redirects FIFA to {}.", + self.config.openfut_server_host + ), + )) + } + Err(e) => self.setup_message = Some((false, e)), + } + } else { + self.setup_message = Some((true, "Saved.".to_string())); + } + } + // ── UI sections ─────────────────────────────────────────────────────────── fn ui_dashboard(&mut self, ui: &mut Ui) { @@ -129,10 +452,39 @@ impl LauncherApp { let account = self.account.snapshot(); // ── Your Club card ──────────────────────────────────────────────────── + let account_ok = self.config.account_configured(); + let mut create_clicked = false; theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); account_card(ui, &account); + // No account yet: the dashboard states the fix and offers it here, + // rather than naming another tab. + if !account_ok { + ui.add_space(10.0); + create_clicked = ui + .add_enabled( + server_ok, + egui::Button::new( + RichText::new("Create my account").color(theme::ON_ACCENT), + ) + .fill(theme::ACCENT), + ) + .clicked(); + if !server_ok { + ui.label( + RichText::new("Set a server address in Settings first.") + .color(theme::TEXT_FAINT) + .small(), + ); + } + if let Some((ok, msg)) = &self.account_message { + ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg); + } + } }); + if create_clicked { + self.create_account(); + } ui.add_space(14.0); @@ -179,7 +531,7 @@ impl LauncherApp { ui.add_space(8.0); ui.colored_label( theme::WARN, - "No OpenFUT server configured — set the host in the Setup tab.", + "No OpenFUT server configured — set the host in Settings.", ); } ui.add_space(8.0); @@ -225,7 +577,7 @@ impl LauncherApp { if hook_ready { status_text(ui, Status::Ok, "Deployed (version.dll)"); } else { - status_text(ui, Status::Warn, "Not deployed — see Setup tab"); + status_text(ui, Status::Warn, "Not deployed — see the Setup tab"); } ui.end_row(); @@ -238,7 +590,7 @@ impl LauncherApp { } else if can_launch { ui.monospace(&self.config.game_launch_command); } else { - status_text(ui, Status::Warn, "Not set — configure it in the Config tab"); + status_text(ui, Status::Warn, "Not set — configure it in Settings"); } ui.end_row(); }); @@ -319,7 +671,7 @@ impl LauncherApp { if !configured { ui.colored_label( theme::WARN, - "FIFA 17 tools dir not set — configure it in the Config tab.", + "FIFA 17 tools dir not set — configure it in Settings.", ); return; } @@ -365,7 +717,7 @@ impl LauncherApp { 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().unwrap() = Default::default(); + *self.fifa17_caps.lock() = Default::default(); } } else if ui.button("Start").clicked() { let _ = self.start_local_service(Service::Autopatch); @@ -411,7 +763,7 @@ impl LauncherApp { match crate::arm::arm(&self.config) { Ok(summary) => { { - let mut logs = self.game_logs.lock().unwrap(); + let mut logs = self.game_logs.lock(); logs.push("[launcher] client armed:".to_string()); for line in &summary { logs.push(format!("[launcher] - {line}")); @@ -427,7 +779,6 @@ impl LauncherApp { Err(e) => { self.game_logs .lock() - .unwrap() .push(format!("[launcher] arming failed: {e}")); self.arm_status = Some((false, format!("Arming failed: {e}"))); } @@ -484,11 +835,10 @@ impl LauncherApp { 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().unwrap() = Default::default(); + *self.fifa17_caps.lock() = Default::default(); if let Err(message) = self.config.validate_launch_config() { self.game_logs .lock() - .unwrap() .push(format!("[launcher] launch blocked: {message}")); self.local_services_message = Some((false, message)); self.active_tab = Tab::Logs; @@ -498,25 +848,41 @@ impl LauncherApp { let message = "Hook DLL is not deployed. Complete Setup before launching.".to_string(); self.game_logs .lock() - .unwrap() .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() - .unwrap() .push(format!("[launcher] account sync failed: {message}")); self.local_services_message = Some((false, message)); self.active_tab = Tab::Logs; return; } }; - self.game_logs.lock().unwrap().push(format!( + self.game_logs.lock().push(format!( "[launcher] account synchronized: {}/{} level={} XP={} account-funds={} FUT-coins={} unopened-packs={}", account.persona_id, account.persona_name, @@ -529,7 +895,6 @@ impl LauncherApp { if let Err(message) = self.ensure_local_services() { self.game_logs .lock() - .unwrap() .push(format!("[launcher] launch blocked: {message}")); self.local_services_message = Some((false, message)); self.active_tab = Tab::Logs; @@ -549,7 +914,6 @@ impl LauncherApp { if let Err(e) = result { self.game_logs .lock() - .unwrap() .push(format!("[launcher] launch failed: {e}")); } self.active_tab = Tab::Logs; @@ -617,7 +981,7 @@ impl LauncherApp { } fn ui_logs(&mut self, ui: &mut Ui) { - let line_count = self.game_logs.lock().unwrap().lines().count(); + let line_count = self.game_logs.lock().lines().count(); ui.horizontal(|ui| { ui.label(RichText::new("Console").text_style(theme::text_style(theme::SUBHEADING))); @@ -628,7 +992,7 @@ impl LauncherApp { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Clear").clicked() { - self.game_logs.lock().unwrap().clear(); + self.game_logs.lock().clear(); } ui.checkbox(&mut self.log_follow, "Follow"); }); @@ -647,7 +1011,7 @@ impl LauncherApp { .auto_shrink([false, false]) .stick_to_bottom(follow) .show(ui, |ui| { - let guard = self.game_logs.lock().unwrap(); + let guard = self.game_logs.lock(); if guard.lines().next().is_none() { ui.add_space(6.0); ui.label( @@ -686,105 +1050,71 @@ impl LauncherApp { ); ui.add_space(16.0); - // ── Server address ──────────────────────────────────────────────────── + // ── Server address (owned by Settings; shown here for context) ──────── + // These fields used to be editable here as well as in Settings, with + // different save semantics on each copy. One owner, one behaviour. theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); card_header(ui, "Step 1 · OpenFUT server address", None); ui.add_space(4.0); ui.label( - "The IP or hostname of your OpenFUT server. FIFA's intercepted EA \ - traffic is redirected here. No loopback default — an empty value \ - blocks setup.", + RichText::new( + "The server the hook redirects FIFA's EA traffic to. Edited in \ + Settings; shown here so the deployment below is unambiguous.", + ) + .color(theme::TEXT_WEAK), ); - ui.add_space(6.0); - + ui.add_space(8.0); + egui::Grid::new("setup_server_grid") + .num_columns(2) + .spacing([18.0, 8.0]) + .show(ui, |ui| { + ui.label(RichText::new("Server").color(theme::TEXT_WEAK)); + if self.config.openfut_server_host.trim().is_empty() { + status_text(ui, Status::Error, "not set"); + } else { + ui.monospace(&self.config.openfut_server_host); + } + ui.end_row(); + ui.label(RichText::new("Ports").color(theme::TEXT_WEAK)); + ui.monospace(format!( + "https {} · blaze-redir {} · blaze-main {}", + self.config.openfut_https_port, + self.config.openfut_blaze_redirector_port, + self.config.openfut_blaze_main_port, + )); + ui.end_row(); + }); + ui.add_space(10.0); ui.horizontal(|ui| { - ui.label("Server (IP or hostname):"); - let changed = ui - .add( - egui::TextEdit::singleline(&mut self.config.openfut_server_host) - .hint_text("e.g. 10.10.0.120 or fut.mylan.home") - .desired_width(220.0), + if ui.button("Edit in Settings").clicked() { + self.active_tab = Tab::Settings; + } + if ui + .add_enabled( + self.config.validate_server().is_ok(), + egui::Button::new("Test Connection"), ) - .changed(); - if changed { - self.config_dirty = true; - self.test_message = None; - self.refresh_health_target(); - } - }); - ui.horizontal(|ui| { - ui.label("Ports:"); - ui.label("HTTPS"); - let mut p = self.config.openfut_https_port.to_string(); - if ui - .add(egui::TextEdit::singleline(&mut p).desired_width(64.0)) - .changed() - { - if let Ok(v) = p.parse() { - self.config.openfut_https_port = v; - } - self.config_dirty = true; - self.refresh_health_target(); - } - ui.label("Blaze-redir"); - let mut r = self.config.openfut_blaze_redirector_port.to_string(); - if ui - .add(egui::TextEdit::singleline(&mut r).desired_width(64.0)) - .changed() - { - if let Ok(v) = r.parse() { - self.config.openfut_blaze_redirector_port = v; - } - self.config_dirty = true; - } - ui.label("Blaze-main"); - let mut m = self.config.openfut_blaze_main_port.to_string(); - if ui - .add(egui::TextEdit::singleline(&mut m).desired_width(64.0)) - .changed() - { - if let Ok(v) = m.parse() { - self.config.openfut_blaze_main_port = v; - } - self.config_dirty = true; - } - }); - - ui.add_space(6.0); - ui.horizontal(|ui| { - if ui.button("Test Connection").clicked() { - match self.config.validate_server() { - Ok(()) => { - let outcome = netcheck::test_connection(&self.config.server_config()); - self.test_message = Some((outcome.ok, outcome.message)); - } - Err(msg) => self.test_message = Some((false, msg)), - } - } - if ui - .add_enabled(self.hook_deployed, egui::Button::new("Save & Update hook")) .clicked() { - match self.config.hook_cfg_contents() { - Ok(cfg) => match setup::update_hook_config( - Path::new(&self.config.fifa_game_dir), - &cfg, - ) { - Ok(()) => { - self.config.save(); - self.config_dirty = false; - self.setup_message = Some(( - true, - format!( - "Config updated — hook will redirect to {}.", - self.config.openfut_server_host - ), - )); - } - Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))), - }, - Err(msg) => self.setup_message = Some((false, msg)), + let outcome = netcheck::test_connection(&self.config.server_config()); + self.test_message = Some((outcome.ok, outcome.message)); + } + if ui + .add_enabled(self.hook_deployed, egui::Button::new("Update hook now")) + .clicked() + { + match self.write_hook_config() { + Ok(()) => { + self.setup_message = Some(( + true, + format!( + "Hook updated — FIFA will connect to {}.", + self.config.openfut_server_host + ), + )) + } + Err(e) => self.setup_message = Some((false, e)), } } }); @@ -925,19 +1255,23 @@ impl LauncherApp { } } - fn ui_config(&mut self, ui: &mut Ui) { - ui.heading("Configuration"); + fn ui_settings(&mut self, ui: &mut Ui) { + ui.heading("Settings"); ui.add_space(4.0); ui.label( - RichText::new( - "Everything the launcher needs to reach your server and start the game.", - ) - .color(theme::TEXT_WEAK), + RichText::new("Everything the launcher needs to reach your server and start the game.") + .color(theme::TEXT_WEAK), ); ui.add_space(16.0); let mut changed = false; let mut server_changed = false; + // Whether the deployed hook disagrees with these settings. Enables Save + // even with nothing edited: the message below says "Save to update it", + // so the button has to actually be clickable. + let hook_drift = setup::read_hook_config(std::path::Path::new(&self.config.fifa_game_dir)) + .and_then(|body| openfut_common::ServerConfig::parse(&body).ok()) + .is_some_and(|deployed| deployed != self.config.server_config()); theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); @@ -996,14 +1330,27 @@ impl LauncherApp { theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); card_header(ui, "OpenFUT server", None); + ui.label( + RichText::new( + "Where this launcher — and the game — connect. This is the only \ + place the server address is edited.", + ) + .color(theme::TEXT_WEAK), + ); + ui.add_space(8.0); egui::Grid::new("config_server_grid") .num_columns(2) .spacing([14.0, 9.0]) .min_col_width(150.0) .show(ui, |ui| { ui.label(RichText::new("Server host:").color(theme::TEXT_WEAK)); - let r = ui.text_edit_singleline(&mut self.config.openfut_server_host); - if r.changed() { + if ui + .add( + egui::TextEdit::singleline(&mut self.config.openfut_server_host) + .hint_text("e.g. 10.10.0.120 or fut.mylan.home"), + ) + .changed() + { changed = true; server_changed = true; } @@ -1023,6 +1370,34 @@ impl LauncherApp { } ui.end_row(); + ui.label(RichText::new("Blaze redirector port:").color(theme::TEXT_WEAK)); + let mut p = self.config.openfut_blaze_redirector_port.to_string(); + if ui + .add(egui::TextEdit::singleline(&mut p).desired_width(90.0)) + .changed() + { + if let Ok(v) = p.parse() { + self.config.openfut_blaze_redirector_port = v; + } + changed = true; + server_changed = true; + } + ui.end_row(); + + ui.label(RichText::new("Blaze main port:").color(theme::TEXT_WEAK)); + let mut p = self.config.openfut_blaze_main_port.to_string(); + if ui + .add(egui::TextEdit::singleline(&mut p).desired_width(90.0)) + .changed() + { + if let Ok(v) = p.parse() { + self.config.openfut_blaze_main_port = v; + } + changed = true; + server_changed = true; + } + ui.end_row(); + ui.label(RichText::new("Account/UTAS port:").color(theme::TEXT_WEAK)); let mut p = self.config.openfut_account_sync_port.to_string(); if ui @@ -1033,9 +1408,47 @@ impl LauncherApp { self.config.openfut_account_sync_port = v; } changed = true; + server_changed = true; } ui.end_row(); }); + + ui.add_space(10.0); + if ui + .add_enabled( + self.config.validate_server().is_ok(), + egui::Button::new("Test Connection"), + ) + .clicked() + { + let outcome = netcheck::test_connection(&self.config.server_config()); + self.test_message = Some((outcome.ok, outcome.message)); + } + if let Some((ok, msg)) = &self.test_message { + ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg); + } + + // What the GAME will read, which is a different fact from what these + // fields say until the config is saved or the game is launched. + ui.add_space(8.0); + let deployed = + setup::read_hook_config(std::path::Path::new(&self.config.fifa_game_dir)) + .and_then(|body| openfut_common::ServerConfig::parse(&body).ok()); + match deployed { + Some(d) if d == self.config.server_config() => { + status_text(ui, Status::Ok, &format!("FIFA's hook points at {}", d.host)) + } + Some(d) => status_text( + ui, + Status::Warn, + &format!("FIFA's hook still points at {} — Save to update it", d.host), + ), + None => status_text( + ui, + Status::Idle, + "FIFA's hook is not deployed yet (Setup tab)", + ), + } }); ui.add_space(14.0); @@ -1138,15 +1551,15 @@ impl LauncherApp { ui.horizontal(|ui| { if ui .add_enabled( - self.config_dirty, + self.config_dirty || hook_drift, egui::Button::new(RichText::new("Save").color(theme::ON_ACCENT)) .fill(theme::ACCENT), ) .clicked() { - self.config.save(); - self.config_dirty = false; - self.refresh_health_target(); + // Not just `config.save()`: saving must also reach the file the + // game reads, or the two disagree until the next launch. + self.save_settings(); } if ui.button("Reset to defaults").clicked() { self.config = LauncherConfig::default(); @@ -1154,6 +1567,10 @@ impl LauncherApp { self.refresh_health_target(); } }); + if let Some((ok, msg)) = &self.setup_message { + ui.add_space(6.0); + ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg); + } } /// The branded top bar: wordmark badge, product name, and a live server @@ -1206,8 +1623,7 @@ impl LauncherApp { fn nav_item(&mut self, ui: &mut Ui, tab: Tab, glyph: &str, label: &str) { let active = self.active_tab == tab; let full_w = ui.available_width(); - let (rect, resp) = - ui.allocate_exact_size(egui::vec2(full_w, 40.0), egui::Sense::click()); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(full_w, 40.0), egui::Sense::click()); if resp.clicked() { self.active_tab = tab; } @@ -1227,8 +1643,16 @@ impl LauncherApp { ); painter.rect_filled(bar, egui::Rounding::same(2.0), theme::ACCENT); } - let text_color = if active { theme::TEXT } else { theme::TEXT_WEAK }; - let icon_color = if active { theme::ACCENT_HOVER } else { theme::TEXT_FAINT }; + let text_color = if active { + theme::TEXT + } else { + theme::TEXT_WEAK + }; + let icon_color = if active { + theme::ACCENT_HOVER + } else { + theme::TEXT_FAINT + }; let mid = rect.left_center(); painter.text( mid + egui::vec2(16.0, 0.0), @@ -1270,13 +1694,20 @@ impl eframe::App for LauncherApp { .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)), ) .show(ctx, |ui| { - self.nav_item(ui, Tab::Dashboard, "▤", "Dashboard"); + // The guided flow earns a rail slot only while it is unfinished — + // or while the user is standing on it, so clicking away is never + // a one-way door. + if self.config.needs_onboarding() || self.active_tab == Tab::Welcome { + self.nav_item(ui, Tab::Welcome, "★", "Get started"); + ui.add_space(4.0); + } + self.nav_item(ui, Tab::Dashboard, "🏠", "Dashboard"); ui.add_space(4.0); self.nav_item(ui, Tab::Logs, "≡", "Logs"); ui.add_space(4.0); self.nav_item(ui, Tab::Setup, "🔧", "Setup"); ui.add_space(4.0); - self.nav_item(ui, Tab::Config, "⚙", "Config"); + self.nav_item(ui, Tab::Settings, "⚙", "Settings"); // Version pinned to the bottom of the rail. ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| { @@ -1309,9 +1740,10 @@ impl eframe::App for LauncherApp { egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| match self.active_tab { + Tab::Welcome => self.ui_welcome(ui), Tab::Dashboard => self.ui_dashboard(ui), Tab::Setup => self.ui_setup(ui), - Tab::Config => self.ui_config(ui), + Tab::Settings => self.ui_settings(ui), Tab::Logs => {} }); } @@ -1355,11 +1787,47 @@ fn card_header(ui: &mut Ui, title: &str, pill: Option<(&str, Status)>) { fn status_text(ui: &mut Ui, status: Status, text: &str) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; - ui.label(RichText::new(status.glyph()).color(status.color()).size(11.0)); + ui.label( + RichText::new(status.glyph()) + .color(status.color()) + .size(11.0), + ); ui.label(RichText::new(text).color(status.color())); }); } +/// A numbered step title for the Welcome flow, with a tick once satisfied. The +/// number carries the ordering, so the copy never has to say "first"/"then". +fn step_header(ui: &mut Ui, number: u8, title: &str, done: bool) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + let (rect, _) = ui.allocate_exact_size(Vec2::new(24.0, 24.0), egui::Sense::hover()); + let (fill, fg) = if done { + (theme::SUCCESS, theme::ON_ACCENT) + } else { + (theme::ACCENT_WASH, theme::TEXT_WEAK) + }; + ui.painter().circle_filled(rect.center(), 12.0, fill); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + if done { + "✔".to_string() + } else { + number.to_string() + }, + egui::FontId::proportional(13.0), + fg, + ); + ui.label( + RichText::new(title) + .text_style(theme::text_style(theme::SUBHEADING)) + .color(theme::TEXT), + ); + }); + ui.add_space(6.0); +} + /// The dashboard's "Your Club" card. Renders the live account summary when one /// has been fetched, and calm, on-brand states otherwise: idle (no server), /// loading (fetch in flight), offline/unreachable, and a loud error only for a @@ -1435,7 +1903,11 @@ fn account_body(ui: &mut Ui, s: &crate::account_sync::AccountSummary) { // Level + XP progression. ui.horizontal(|ui| { - ui.label(RichText::new(format!("Level {}", s.level)).color(theme::TEXT).strong()); + ui.label( + RichText::new(format!("Level {}", s.level)) + .color(theme::TEXT) + .strong(), + ); if s.experience_max > 0 { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( diff --git a/src/arm.rs b/src/arm.rs index 7c70e91..9e0279e 100644 --- a/src/arm.rs +++ b/src/arm.rs @@ -106,14 +106,16 @@ pub(crate) fn arming_summary( pub fn arm(cfg: &LauncherConfig) -> anyhow::Result> { let server = cfg.openfut_server_host.trim(); if server.is_empty() { - anyhow::bail!("Set the OpenFUT server host in the Config tab before arming."); + anyhow::bail!("Set the OpenFUT server host in Settings before arming."); } let ea_ip = cfg.ea_redirect_probe_ip.trim(); if ea_ip.is_empty() { - anyhow::bail!("Set the EA redirector IP (Config tab) before arming."); + anyhow::bail!("Set the EA redirector IP (Settings) before arming."); } if cfg.ea_hostnames.is_empty() { - anyhow::bail!("Add at least one EA hostname (e.g. easw.easports.com) in the Config tab before arming."); + anyhow::bail!( + "Add at least one EA hostname (e.g. easw.easports.com) in Settings before arming." + ); } let redirector_port = cfg.openfut_blaze_redirector_port; let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?; diff --git a/src/config.rs b/src/config.rs index c991929..49dbdb8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -359,10 +359,10 @@ impl LauncherConfig { /// this ensures required user configuration is never silently invented. pub fn validate_local_services(&self) -> Result<(), String> { if self.fifa17_tools_dir.trim().is_empty() { - return Err("No FIFA 17 tools dir configured. Set it in the Config tab.".into()); + return Err("No FIFA 17 tools dir configured. Set it in Settings.".into()); } if self.fifa17_python.trim().is_empty() { - return Err("No Python interpreter configured. Set it in the Config tab.".into()); + return Err("No Python interpreter configured. Set it in Settings.".into()); } Ok(()) } @@ -380,7 +380,7 @@ impl LauncherConfig { } else if self.game_launch_command.trim().is_empty() { return Err( "No game configured. Fill in the game profile, or set a launch command, \ - in the Config tab." + in Settings." .into(), ); } @@ -389,10 +389,10 @@ impl LauncherConfig { pub fn validate_account(&self) -> Result<(), String> { if self.fut_persona_id == 0 { - return Err("No EA persona ID configured. Set the account in the Config tab.".into()); + return Err("No account yet. Create one from the Get started tab.".into()); } if self.fut_persona_name.trim().is_empty() { - return Err("No EA persona name configured. Set the account in the Config tab.".into()); + return Err("Account has no persona name. Recreate it from Get started.".into()); } if self.fut_account_level == 0 { return Err("EA account level must be at least 1.".into()); @@ -408,6 +408,21 @@ impl LauncherConfig { Ok(()) } + /// Whether an account has been claimed from the server (see + /// [`crate::account_sync::discover`]). Distinct from + /// [`Self::validate_account`], which also polices the derived EASFC values: + /// this answers only "does this install know who is playing?". + pub fn account_configured(&self) -> bool { + self.fut_persona_id != 0 && !self.fut_persona_name.trim().is_empty() + } + + /// Whether the launcher should open on the guided first-run flow instead of + /// the dashboard. Keyed on the two things a new user cannot be expected to + /// guess: where the server is, and who they are. + pub fn needs_onboarding(&self) -> bool { + self.validate_server().is_err() || !self.account_configured() + } + /// The exact `openfut.cfg` bytes to write for the hook, or an error if the /// server isn't validly configured (never emits a loopback fallback). /// @@ -696,12 +711,38 @@ mod tests { #[test] fn launch_requires_a_valid_ea_account() { let mut c = LauncherConfig::default(); - assert!(c.validate_account().unwrap_err().contains("persona ID")); + // A fresh install has no account, and must say so rather than launching + // FIFA as persona 0. + assert!(!c.account_configured()); + assert!(c.validate_account().is_err()); c.fut_persona_id = 12345678; + assert!( + !c.account_configured(), + "an id without a name is not an account" + ); assert!(c.validate_account().unwrap_err().contains("persona name")); c.fut_persona_name = "TEST_USER".into(); + assert!(c.account_configured()); assert!(c.validate_account().is_ok()); c.fut_account_experience = 1001; assert!(c.validate_account().unwrap_err().contains("XP")); } + + #[test] + fn onboarding_is_needed_until_both_server_and_account_are_known() { + // Drives which tab the launcher opens on, so the two halves must both + // count: a server with no account is still a dead end for a new user. + let mut c = LauncherConfig::default(); + assert!(c.needs_onboarding()); + c.openfut_server_host = "10.10.0.120".into(); + assert!( + c.needs_onboarding(), + "a server alone cannot launch anything" + ); + c.fut_persona_id = 33_068_179; + c.fut_persona_name = "CAGE".into(); + assert!(!c.needs_onboarding()); + c.openfut_server_host.clear(); + assert!(c.needs_onboarding(), "losing the server reopens the flow"); + } } diff --git a/src/game_launch.rs b/src/game_launch.rs index ba87c91..09852f2 100644 --- a/src/game_launch.rs +++ b/src/game_launch.rs @@ -23,10 +23,11 @@ //! `game_launch_command` remains as an escape hatch: an unconfigured profile //! falls back to it, so an existing working setup cannot be broken by upgrading. +use parking_lot::Mutex; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::time::{Duration, Instant}; use crate::config::GameProfile; @@ -35,7 +36,7 @@ use crate::logs::LogBuffer; type Log = Arc>; fn say(log: &Log, msg: impl Into) { - log.lock().unwrap().push(msg.into()); + log.lock().push(msg.into()); } /// Prepare the prefix, satisfy the licence precondition, and start the game. @@ -226,7 +227,7 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) { let buf = Arc::clone(&log); std::thread::spawn(move || { for line in BufReader::new(out).lines().map_while(Result::ok) { - buf.lock().unwrap().push(line); + buf.lock().push(line); } }); } @@ -234,13 +235,13 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) { let buf = Arc::clone(&log); std::thread::spawn(move || { for line in BufReader::new(err).lines().map_while(Result::ok) { - buf.lock().unwrap().push(line); + buf.lock().push(line); } }); } std::thread::spawn(move || { let _ = child.wait(); - log.lock().unwrap().push(exit_msg.to_string()); + log.lock().push(exit_msg.to_string()); }); } diff --git a/src/health.rs b/src/health.rs index 8220e01..5145be8 100644 --- a/src/health.rs +++ b/src/health.rs @@ -6,11 +6,12 @@ //! stops, or assumes anything about how the server is hosted; it only asks //! "can the FIFA client reach it right now?". +use parking_lot::Mutex; use std::{ net::{TcpStream, ToSocketAddrs}, sync::{ atomic::{AtomicBool, Ordering}, - Arc, Mutex, + Arc, }, thread, time::{Duration, Instant}, @@ -57,14 +58,14 @@ impl HealthMonitor { let t_running = Arc::clone(&running); thread::spawn(move || { while t_running.load(Ordering::Relaxed) { - let target = t_target.lock().unwrap().clone(); + let target = t_target.lock().clone(); match target { None => { - *t_state.lock().unwrap() = HealthState::default(); + *t_state.lock() = HealthState::default(); } Some((host, port)) => { let snapshot = probe(&host, port); - *t_state.lock().unwrap() = snapshot; + *t_state.lock() = snapshot; } } thread::sleep(POLL_INTERVAL); @@ -81,11 +82,11 @@ impl HealthMonitor { /// Point the monitor at a new server address (host + bridge port). Passing /// None (e.g. no server configured) puts it back into the idle state. pub fn set_target(&self, target: Option<(String, u16)>) { - *self.target.lock().unwrap() = target; + *self.target.lock() = target; } pub fn snapshot(&self) -> HealthState { - self.state.lock().unwrap().clone() + self.state.lock().clone() } } diff --git a/src/local_services.rs b/src/local_services.rs index 22f755c..f63d992 100644 --- a/src/local_services.rs +++ b/src/local_services.rs @@ -13,11 +13,12 @@ //! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous //! Polkit prompt delaying cert patching until after FIFA's first TLS attempt. +use parking_lot::Mutex; use std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener}, path::Path, process::{Child, Command, Stdio}, - sync::{mpsc, Arc, Mutex}, + sync::{mpsc, Arc}, time::{Duration, Instant}, }; @@ -138,22 +139,19 @@ impl ManagedService { if let Some(result) = self.stopping.as_ref() { match result.try_recv() { Ok(Ok(())) => { - log.lock() - .unwrap() - .push(format!("[launcher] {label} stopped.")); + log.lock().push(format!("[launcher] {label} stopped.")); self.stopping = None; return false; } Ok(Err(error)) => { log.lock() - .unwrap() .push(format!("[launcher] failed to stop {label}: {error}")); self.stopping = None; return false; } Err(mpsc::TryRecvError::Empty) => return true, Err(mpsc::TryRecvError::Disconnected) => { - log.lock().unwrap().push(format!( + log.lock().push(format!( "[launcher] {label} stop worker exited unexpectedly." )); self.stopping = None; @@ -168,7 +166,6 @@ impl ManagedService { Ok(None) => true, Ok(Some(status)) => { log.lock() - .unwrap() .push(format!("[launcher] {label} exited ({status}).")); self.child = None; false @@ -189,9 +186,7 @@ impl ManagedService { } if let Some(mut child) = self.child.take() { let label = service.label(); - log.lock() - .unwrap() - .push(format!("[launcher] stopping {label}…")); + log.lock().push(format!("[launcher] stopping {label}…")); self.stopping = Some(dispatch_stop_work(move || { child @@ -245,7 +240,7 @@ pub fn spawn( let dir = Path::new(tools_dir); if !dir.is_dir() { anyhow::bail!( - "FIFA 17 tools dir not found: {} (set it in the Config tab)", + "FIFA 17 tools dir not found: {} (set it in Settings)", dir.display() ); } @@ -283,7 +278,7 @@ pub fn spawn( .stdout(Stdio::piped()) .stderr(Stdio::piped()); - log.lock().unwrap().push(format!( + log.lock().push(format!( "[launcher] starting {label}: {} {}", python, script_path.display(), @@ -304,7 +299,7 @@ pub fn spawn( let mut registered = false; for line in BufReader::new(out).lines().map_while(Result::ok) { // Every raw line is still mirrored into the log, as before. - buf.lock().unwrap().push(format!("[{lbl}] {line}")); + buf.lock().push(format!("[{lbl}] {line}")); let Some(wiring) = cap_wiring.as_ref() else { continue; @@ -317,9 +312,9 @@ pub fn spawn( }; registered = true; let fifa_pid = parse_fifa_pid(&line).unwrap_or(0); - wiring.sink.lock().unwrap().empty_mypacks_resolver = Some(version); + wiring.sink.lock().empty_mypacks_resolver = Some(version); { - let mut log = buf.lock().unwrap(); + let mut log = buf.lock(); log.push(format!( "[fifa17] resolver capability verified for FIFA pid {fifa_pid}" )); @@ -336,11 +331,9 @@ pub fn spawn( ) { Ok(()) => buf .lock() - .unwrap() .push("[fifa17] capability registered with backend".to_string()), Err(error) => buf .lock() - .unwrap() .push(format!("[fifa17] capability registration failed: {error}")), } } @@ -351,7 +344,7 @@ pub fn spawn( let lbl = label.to_string(); std::thread::spawn(move || { for line in BufReader::new(err).lines().map_while(Result::ok) { - buf.lock().unwrap().push(format!("[{lbl}] {line}")); + buf.lock().push(format!("[{lbl}] {line}")); } }); } @@ -364,7 +357,6 @@ pub fn spawn( return Err(error); } log.lock() - .unwrap() .push("[launcher] LSX ready on 127.0.0.1:4216".to_string()); } diff --git a/src/main.rs b/src/main.rs index 02ba4c7..d2fe21b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ -mod account_sync; mod account_monitor; +mod account_sync; mod app; mod arm; mod config; diff --git a/src/preflight.rs b/src/preflight.rs index 08d6956..06a0c61 100644 --- a/src/preflight.rs +++ b/src/preflight.rs @@ -92,6 +92,7 @@ pub fn run(cfg: &LauncherConfig) -> Vec { ea_redirect(cfg), hostname_mapping(cfg), backend_reachable(cfg), + hook_config(cfg), ] } @@ -261,6 +262,50 @@ fn backend_reachable(cfg: &LauncherConfig) -> Check { } } +/// The deployed `openfut.cfg` is the only server address the *game* can see. +/// +/// Every panel in this launcher reads the in-memory config, so a settings change +/// that never reached the file produces the worst possible failure: the UI shows +/// the new server online while FIFA connects to the old one. Compare the two. +fn hook_config(cfg: &LauncherConfig) -> Check { + const NAME: &str = "Hook server address"; + let game_dir = cfg.fifa_game_dir.trim(); + if game_dir.is_empty() { + return Check::skip(NAME, "no FIFA game dir configured"); + } + let Some(body) = crate::setup::read_hook_config(std::path::Path::new(game_dir)) else { + return Check::skip( + NAME, + format!("no {} deployed yet", crate::setup::HOOK_CFG_FILE), + ); + }; + let deployed = match openfut_common::ServerConfig::parse(&body) { + Ok(parsed) => parsed, + // Unparseable means the hook cannot read it either, and nothing else in + // the stack recovers from that — so this one is a genuine failure. + Err(e) => { + return Check::fail( + NAME, + format!("{} is unreadable: {e}", crate::setup::HOOK_CFG_FILE), + ) + } + }; + let wanted = cfg.server_config(); + if deployed == wanted { + return Check::pass(NAME, format!("hook redirects to {}", wanted.host)); + } + // Warn, not fail: the launch path rewrites this file before starting the + // game, so the drift is real but already covered. Naming both addresses is + // what makes it actionable. + Check::warn( + NAME, + format!( + "deployed hook still points at {} (settings say {}) — launching rewrites it", + deployed.host, wanted.host + ), + ) +} + fn connects(host: &str, port: u16) -> bool { match (host, port).to_socket_addrs() { Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()), @@ -289,12 +334,13 @@ mod tests { #[test] fn an_unconfigured_launcher_skips_rather_than_passes() { - // The distinction that matters: a fresh config must not display four - // green ticks. "Not checked" is not "checked and fine". + // The distinction that matters: a fresh config must not display a column + // of green ticks. "Not checked" is not "checked and fine". let mut c = cfg(); - // `default()` points this at a conventional path whose existence varies - // by machine. Pin it so the assertion is about the code, not this box. + // `default()` points these at conventional paths whose existence varies + // by machine. Pin them so the assertion is about the code, not this box. c.fifa17_tools_dir = "/nonexistent/openfut-tools".into(); + c.fifa_game_dir = "/nonexistent/fifa-game-dir".into(); let checks = run(&c); assert!( checks.iter().all(|k| k.state == State::Skipped), @@ -395,4 +441,49 @@ mod tests { assert_eq!(check.state, State::Fail, "{}", check.detail); assert!(check.detail.contains("no answer on"), "{}", check.detail); } + + /// A temp game dir holding one `openfut.cfg` body. + fn game_dir_with_cfg(tag: &str, body: &str) -> std::path::PathBuf { + let dir = + std::env::temp_dir().join(format!("openfut-preflight-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(crate::setup::HOOK_CFG_FILE), body).unwrap(); + dir + } + + #[test] + fn a_stale_hook_config_is_reported_and_names_both_addresses() { + // The silent failure this check exists for: settings changed, the file + // the game reads did not. + let mut c = cfg(); + c.openfut_server_host = "10.0.0.2".into(); + let old = openfut_common::ServerConfig { + host: "10.0.0.1".into(), + ports: c.server_config().ports, + }; + let dir = game_dir_with_cfg("stale", &old.to_cfg_string()); + c.fifa_game_dir = dir.to_string_lossy().into_owned(); + let check = hook_config(&c); + assert_eq!(check.state, State::Warn, "{}", check.detail); + assert!(check.detail.contains("10.0.0.1"), "{}", check.detail); + assert!(check.detail.contains("10.0.0.2"), "{}", check.detail); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn a_hook_config_matching_settings_passes() { + let mut c = cfg(); + c.openfut_server_host = "10.0.0.2".into(); + let dir = game_dir_with_cfg("fresh", &c.server_config().to_cfg_string()); + c.fifa_game_dir = dir.to_string_lossy().into_owned(); + assert_eq!(hook_config(&c).state, State::Pass); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn a_missing_hook_config_is_skipped_not_passed() { + let mut c = cfg(); + c.fifa_game_dir = "/nonexistent/fifa-game-dir".into(); + assert_eq!(hook_config(&c).state, State::Skipped); + } } diff --git a/src/setup.rs b/src/setup.rs index 0cb7ae0..256f1d1 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -67,6 +67,9 @@ pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> { // ── DLL hook deployment ─────────────────────────────────────────────────────── +/// The file the injected hook reads its server address from, in the game dir. +pub const HOOK_CFG_FILE: &str = "openfut.cfg"; + /// Deploy openfut_hook.dll into the FIFA 23 game directory and write /// openfut.cfg with the structured server configuration the hook reads. /// `cfg_contents` must be the full `openfut.cfg` body (see @@ -85,14 +88,14 @@ pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> a } std::fs::create_dir_all(game_dir)?; std::fs::copy(dll_src, game_dir.join("version.dll"))?; - std::fs::write(game_dir.join("openfut.cfg"), cfg_contents)?; + std::fs::write(game_dir.join(HOOK_CFG_FILE), cfg_contents)?; Ok(()) } /// Update only openfut.cfg without redeploying the DLL. `cfg_contents` is the /// full structured `openfut.cfg` body. pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> { - let cfg = game_dir.join("openfut.cfg"); + let cfg = game_dir.join(HOOK_CFG_FILE); if !cfg.exists() { anyhow::bail!("Hook DLL not deployed yet — deploy first."); } @@ -100,6 +103,15 @@ pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result Ok(()) } +/// Read the `openfut.cfg` the hook will actually load, if one is deployed. +/// +/// The launcher's own health and account requests are built from the in-memory +/// config, but the *game* only ever sees this file. Reading it back is the only +/// way to tell whether the two agree. +pub fn read_hook_config(game_dir: &Path) -> Option { + std::fs::read_to_string(game_dir.join(HOOK_CFG_FILE)).ok() +} + /// Remove the deployed hook DLL from the FIFA game directory. pub fn remove_hook_dll(game_dir: &Path) -> anyhow::Result<()> { let dest = game_dir.join("version.dll"); @@ -127,13 +139,13 @@ pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %comman pub fn launch_game( command: &str, workdir: &str, - log_buf: std::sync::Arc>, + log_buf: std::sync::Arc>, ) -> anyhow::Result<()> { use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; if command.trim().is_empty() { - anyhow::bail!("No game launch command configured (set it in the Config tab)."); + anyhow::bail!("No game launch command configured (set it in Settings)."); } let mut cmd = Command::new("sh"); @@ -145,7 +157,6 @@ pub fn launch_game( log_buf .lock() - .unwrap() .push(format!("[launcher] launching game: {command}")); let mut child = cmd.spawn()?; @@ -154,7 +165,7 @@ pub fn launch_game( let buf = std::sync::Arc::clone(&log_buf); std::thread::spawn(move || { for line in BufReader::new(out).lines().map_while(Result::ok) { - buf.lock().unwrap().push(line); + buf.lock().push(line); } }); } @@ -162,7 +173,7 @@ pub fn launch_game( let buf = std::sync::Arc::clone(&log_buf); std::thread::spawn(move || { for line in BufReader::new(err).lines().map_while(Result::ok) { - buf.lock().unwrap().push(line); + buf.lock().push(line); } }); } @@ -172,7 +183,6 @@ pub fn launch_game( let _ = child.wait(); log_buf .lock() - .unwrap() .push("[launcher] game process exited.".to_string()); }); diff --git a/src/theme.rs b/src/theme.rs index a5aa916..0cc7fc2 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -131,12 +131,7 @@ pub fn status_pill(ui: &mut egui::Ui, label: &str, status: Status) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; ui.label(egui::RichText::new(status.glyph()).color(color).size(11.0)); - ui.label( - egui::RichText::new(label) - .color(color) - .size(12.0) - .strong(), - ); + ui.label(egui::RichText::new(label).color(color).size(12.0).strong()); }); }); }