From 357501f549b99f15e01b86934c2c33b20d30f5a9 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 17 Aug 2026 21:46:43 +0000 Subject: [PATCH] launcher: guided first-run flow, server-owned settings, hook-config reconcile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release-readiness pass on the launcher, driven by the end state "open it, create an account, launch the game". Fixes a silent correctness bug. `openfut.cfg` in the game dir is the only server address the *game* can see, but it was written only by Setup's deploy and its "Save & Update hook" button. Changing the server anywhere else left FIFA connecting to the previous host while every panel in the launcher showed the new one online. Now: - `write_hook_config` reconciles the file from the live config, and runs fail-closed before every launch, so the file and the UI cannot disagree at the moment it matters; - saving Settings pushes the address into the hook immediately; - a `hook_config` preflight check reads the file back and warns, naming both addresses, instead of leaving the drift invisible; - Settings shows the same fact inline, and Save is enabled by drift alone — a message saying "Save to update it" beside a disabled button is a dead end. Account creation is now server-authoritative. `account_sync::discover` POSTs `/openfut/account/sync` with the persona fields *omitted*, which makes the host answer with the persona it was started with, its club, and the Core coin balance. The launcher adopts that answer, so it never invents an identity and the persona the game authenticates with is by construction the one the server expects. Claiming is gated on the address being valid, NOT on the health pill: that pill probes the HTTPS port while this talks to the account port, so gating on it disabled the button on servers that answer it perfectly well. UX consolidation: - new Welcome ("Get started") tab: three numbered steps — connect, claim an account, connect FIFA — each showing live state, ending in the launch CTA; a fresh install opens on it and it leaves the nav rail once satisfied; - Config renamed Settings, and made the single owner of the server address: Setup's duplicate editors (same fields, different save semantics) are now a read-only summary with actions; - the dashboard offers account creation in place instead of naming a tab, and the stale "set the host in the Setup tab" pointers are corrected. Locks move to parking_lot per project rule (already the convention in openfut-utas-host and openfut-identity); 47 poisoning unwraps go away. Verified: 58 tests pass, fmt clean, clippy clean apart from one pre-existing lint. Driven through the real UI under Xvfb as a fresh install — typed a server, clicked Create my account, and the config on disk came back with persona 33068179/CAGE claimed from the live host; clicking Save rewrote a stale `openfut.cfg` from host=10.10.0.99 to host=127.0.0.1. --- Cargo.toml | 3 + src/account_monitor.rs | 13 +- src/account_sync.rs | 196 +++++++++-- src/app.rs | 742 +++++++++++++++++++++++++++++++++-------- src/arm.rs | 8 +- src/config.rs | 53 ++- src/game_launch.rs | 11 +- src/health.rs | 13 +- src/local_services.rs | 30 +- src/main.rs | 2 +- src/preflight.rs | 99 +++++- src/setup.rs | 26 +- src/theme.rs | 7 +- 13 files changed, 983 insertions(+), 220 deletions(-) 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()); }); }); }