use std::sync::Arc; use parking_lot::Mutex; use egui::{Color32, RichText, ScrollArea, Ui, Vec2}; use crate::theme::{self, Status}; use crate::{ account_monitor::AccountMonitor, config::LauncherConfig, game_launch, health::HealthMonitor, logs::LogBuffer, netcheck, preflight, setup, }; #[derive(Clone, Copy, PartialEq)] enum Tab { /// Guided first-run flow: connect, claim an account, launch. Welcome, Dashboard, Logs, Setup, Settings, } impl 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` stays accepted: it is what every existing screenshot // script and note passes. "settings" | "config" => Tab::Settings, _ if config.needs_onboarding() => Tab::Welcome, _ => Tab::Dashboard, } } } /// The launcher is a *client-side* tool: the OpenFUT servers run elsewhere /// (e.g. Docker on the server host). It monitors server health read-only, /// prepares the FIFA integration (hook DLL + cert), and launches the game. pub struct LauncherApp { config: LauncherConfig, config_dirty: bool, /// Read-only health monitor for the configured (remote) server. health: HealthMonitor, /// Read-only background poller for the account summary ("Your Club" card). account: AccountMonitor, /// Game process output + launcher messages. game_logs: Arc>, active_tab: Tab, log_follow: bool, // Setup state hook_deployed: bool, cert_path: Option, setup_message: Option<(bool, String)>, /// 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, local_services_message: Option<(bool, String)>, /// Last pre-launch check results. `None` until run — deliberately not run /// automatically on every frame: the checks open sockets, and a 2s probe /// on the UI thread would stall the window. preflight: Option>, /// Result of the last "Arm client" click, shown inline beneath the button so /// the outcome appears where the user acted — not on another tab. arm_status: Option<(bool, String)>, /// Capabilities verified for the *current* FIFA process (shared with the /// autopatch stdout reader). Reset to unknown at each launch so a new FIFA /// process never inherits a previous launch's capability. fifa17_caps: Arc>, } impl LauncherApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { crate::theme::install(&cc.egui_ctx); let config = LauncherConfig::load(); let cert_path = setup::find_bridge_cert(&config.bridge_captures_dir); let hook_deployed = setup::hook_dll_deployed(std::path::Path::new(&config.fifa_game_dir)); let health = HealthMonitor::new(); health.set_target(config.health_target()); 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, 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, preflight: None, arm_status: None, fifa17_caps: Arc::new(Mutex::new(Default::default())), } } /// Re-point the background monitors whenever the server address may have /// changed. Both the health probe and the account poller track the config. fn refresh_health_target(&self) { self.health.set_target(self.config.health_target()); 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) { let server_ok = self.config.validate_server().is_ok(); let health = self.health.snapshot(); 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); // ── Server status card ──────────────────────────────────────────────── theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); let (status, pill) = match health.reachable { None => (Status::Unknown, "Unknown"), Some(true) => (Status::Ok, "Online"), Some(false) => (Status::Error, "Unreachable"), }; card_header(ui, "Server status", Some((pill, status))); egui::Grid::new("health_grid") .num_columns(2) .spacing([18.0, 8.0]) .show(ui, |ui| { ui.label(RichText::new("Address").color(theme::TEXT_WEAK)); ui.monospace(if self.config.openfut_server_host.is_empty() { "—".to_string() } else { format!( "{}:{}", self.config.openfut_server_host, self.config.openfut_https_port ) }); ui.end_row(); ui.label(RichText::new("Detail").color(theme::TEXT_WEAK)); ui.label(RichText::new(&health.detail).color(theme::TEXT)); ui.end_row(); if let Some(t) = health.last_checked { ui.label(RichText::new("Checked").color(theme::TEXT_WEAK)); ui.label( RichText::new(format!("{}s ago", t.elapsed().as_secs())) .color(theme::TEXT_FAINT), ); ui.end_row(); } }); if !server_ok { ui.add_space(8.0); ui.colored_label( theme::WARN, "No OpenFUT server configured — set the host in Settings.", ); } ui.add_space(8.0); ui.label( RichText::new( "The server runs elsewhere (e.g. Docker on the server host). This \ launcher monitors it read-only — it does not start or stop it.", ) .color(theme::TEXT_FAINT) .small(), ); }); ui.add_space(14.0); // ── Local services card ─────────────────────────────────────────────── theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); self.ui_local_services(ui); }); ui.add_space(14.0); // ── Game & launch card ──────────────────────────────────────────────── let launch_config = self.config.validate_launch_config(); let hook_ready = self.hook_deployed; let can_launch = launch_config.is_ok() && hook_ready; theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); let (gstatus, glabel) = if can_launch { (Status::Ok, "Ready") } else { (Status::Warn, "Setup needed") }; card_header(ui, "Game & launch", Some((glabel, gstatus))); egui::Grid::new("game_status_grid") .num_columns(2) .spacing([18.0, 8.0]) .show(ui, |ui| { ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK)); if hook_ready { status_text(ui, Status::Ok, "Deployed (version.dll)"); } else { status_text(ui, Status::Warn, "Not deployed — see the Setup tab"); } ui.end_row(); ui.label(RichText::new("Launch command").color(theme::TEXT_WEAK)); if self.config.game_profile.configured() { ui.monospace(format!( "{} {}", self.config.game_profile.runner, self.config.game_profile.executable )); } else if can_launch { ui.monospace(&self.config.game_launch_command); } else { status_text(ui, Status::Warn, "Not set — configure it in Settings"); } ui.end_row(); }); ui.add_space(12.0); ui.separator(); ui.add_space(12.0); self.preflight_ui(ui); ui.add_space(14.0); let cta_w = ui.available_width().min(380.0); if ui .add_enabled( can_launch, egui::Button::new( RichText::new("▶ Start Services & Launch Game") .size(15.0) .color(theme::ON_ACCENT), ) .fill(theme::ACCENT) .min_size(Vec2::new(cta_w, 46.0)) .rounding(egui::Rounding::same(10.0)), ) .clicked() { self.launch_game(); } if let Err(message) = &launch_config { ui.add_space(6.0); ui.colored_label(theme::WARN, message); } if !server_ok { ui.add_space(6.0); ui.colored_label( theme::WARN, "Tip: the game can launch, but without a reachable server FUT features \ won't connect.", ); } }); } /// Dashboard section for the two client-side FIFA 17 daemons. fn ui_local_services(&mut self, ui: &mut Ui) { use crate::local_services::Service; let configured = !self.config.fifa17_tools_dir.trim().is_empty(); let lsx_running = self.lsx.running(&self.game_logs, Service::Lsx.label()); let ap_running = self .autopatch .running(&self.game_logs, Service::Autopatch.label()); let lsx_stopping = self.lsx.stopping(); let ap_stopping = self.autopatch.stopping(); let (sum_status, sum_label) = if !configured { (Status::Warn, "Not configured") } else if lsx_running && ap_running { (Status::Ok, "Both running") } else if lsx_running || ap_running { (Status::Warn, "Partial") } else { (Status::Idle, "Stopped") }; card_header(ui, "Local services", Some((sum_label, sum_status))); ui.label( RichText::new( "LSX (Origin emulator, loopback 4216) and autopatch (ProtoSSL cert-verify) \ run on THIS machine — the game needs them locally. The Blaze/UTAS/roster/POW \ responders run in the server container. Start these before launching.", ) .color(theme::TEXT_FAINT) .small(), ); ui.add_space(12.0); if !configured { ui.colored_label( theme::WARN, "FIFA 17 tools dir not set — configure it in Settings.", ); return; } egui::Grid::new("local_services_grid") .num_columns(3) .spacing([14.0, 10.0]) .min_col_width(96.0) .show(ui, |ui| { // LSX row ui.label(RichText::new("LSX").color(theme::TEXT).strong()); if lsx_stopping { theme::status_pill(ui, "Stopping", Status::Busy); } else if lsx_running { theme::status_pill(ui, "Running", Status::Ok); } else { theme::status_pill(ui, "Stopped", Status::Idle); } if lsx_stopping { ui.add_enabled(false, egui::Button::new("Stopping…")); } else if lsx_running { if ui.button("Stop").clicked() { self.lsx.stop(&self.game_logs, Service::Lsx); } } else if ui.button("Start").clicked() { let _ = self.start_local_service(Service::Lsx); } ui.end_row(); // autopatch row ui.label(RichText::new("autopatch").color(theme::TEXT).strong()); if ap_stopping { theme::status_pill(ui, "Stopping", Status::Busy); } else if ap_running { theme::status_pill(ui, "Running", Status::Ok); } else { theme::status_pill(ui, "Stopped", Status::Idle); } if ap_stopping { ui.add_enabled(false, egui::Button::new("Stopping…")); } else if ap_running { if ui.button("Stop").clicked() { self.autopatch.stop(&self.game_logs, Service::Autopatch); // The verified capability belongs to the FIFA process // autopatch was serving; drop it when autopatch stops. *self.fifa17_caps.lock() = Default::default(); } } else if ui.button("Start").clicked() { let _ = self.start_local_service(Service::Autopatch); } ui.end_row(); }); ui.add_space(10.0); if ui.button("Start both local services").clicked() { if !lsx_running { let _ = self.start_local_service(Service::Lsx); } if !ap_running { let _ = self.start_local_service(Service::Autopatch); } } if let Some((ok, msg)) = &self.local_services_message { ui.add_space(6.0); ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg); } } /// The pre-launch checklist. /// /// Advisory by design: a failing check colours the row red and explains the /// fix, but never disables Launch. Every one of these checks can itself be /// wrong, and a wrong check that locks the user out of their own game is a /// worse failure than the one it is guarding against. fn preflight_ui(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { if ui.button("Run pre-launch checks").clicked() { self.preflight = Some(preflight::run(&self.config)); } if ui .button("Arm client") .on_hover_text( "Sets ptrace_scope, the EA-redirector DNAT, and /etc/hosts in one step \ (asks for your password once). Replaces client_arm.sh.", ) .clicked() { match crate::arm::arm(&self.config) { Ok(summary) => { { let mut logs = self.game_logs.lock(); logs.push("[launcher] client armed:".to_string()); for line in &summary { logs.push(format!("[launcher] - {line}")); } } self.arm_status = Some(( true, format!("Client armed — {} change(s) applied.", summary.len()), )); // Re-run the checklist so the result shows immediately. self.preflight = Some(preflight::run(&self.config)); } Err(e) => { self.game_logs .lock() .push(format!("[launcher] arming failed: {e}")); self.arm_status = Some((false, format!("Arming failed: {e}"))); } } } if let Some(checks) = &self.preflight { let bad = preflight::failures(checks); let warn = preflight::warnings(checks); // States what was found, never what will happen. An earlier // version predicted "the game will probably fail" on a warning // and the game then reached the FUT hub — a checklist that // overstates its own findings gets ignored. let (color, text) = match (bad, warn) { (0, 0) => (theme::SUCCESS, "no problems found".to_string()), (0, w) => ( theme::WARN, format!("{w} warning(s) — worth fixing, usually not fatal"), ), (b, 0) => ( theme::ERROR, format!("{b} problem(s) — expect the game to fail"), ), (b, w) => (theme::ERROR, format!("{b} problem(s), {w} warning(s)")), }; ui.colored_label(color, text); } }); if let Some((ok, msg)) = &self.arm_status { let color = if *ok { theme::SUCCESS } else { theme::ERROR }; ui.colored_label(color, msg); } let Some(checks) = &self.preflight else { return; }; ui.add_space(4.0); for c in checks { let (mark, color) = match c.state { preflight::State::Pass => ("OK ", theme::SUCCESS), preflight::State::Warn => ("WARN", theme::WARN), preflight::State::Fail => ("FAIL", theme::ERROR), // Grey, never green: "not checked" must not read as "fine". preflight::State::Skipped => ("-- ", theme::TEXT_FAINT), }; ui.horizontal(|ui| { ui.colored_label(color, RichText::new(mark).monospace()); ui.colored_label(color, &c.name); ui.label(RichText::new(&c.detail).color(theme::TEXT_WEAK)); }); } } fn launch_game(&mut self) { // A new FIFA process starts UNKNOWN: never inherit a prior launch's // verified capability. The autopatch stdout reader re-populates this. *self.fifa17_caps.lock() = Default::default(); if let Err(message) = self.config.validate_launch_config() { self.game_logs .lock() .push(format!("[launcher] launch blocked: {message}")); self.local_services_message = Some((false, message)); self.active_tab = Tab::Logs; return; } if !self.hook_deployed { let message = "Hook DLL is not deployed. Complete Setup before launching.".to_string(); self.game_logs .lock() .push(format!("[launcher] launch blocked: {message}")); self.local_services_message = Some((false, message)); self.active_tab = Tab::Logs; return; } // Fail-closed: launching FIFA at an unknown server is worse than not // launching. This is the only moment the file the game reads is // guaranteed to agree with the settings the user is looking at. if let Err(message) = self.write_hook_config() { self.game_logs .lock() .push(format!("[launcher] launch blocked: {message}")); self.local_services_message = Some((false, message)); self.active_tab = Tab::Logs; return; } self.game_logs.lock().push(format!( "[launcher] hook config written: server={} https={} blaze-redir={} blaze-main={}", self.config.openfut_server_host, self.config.openfut_https_port, self.config.openfut_blaze_redirector_port, self.config.openfut_blaze_main_port, )); let account = match crate::account_sync::sync(&self.config) { Ok(account) => account, Err(message) => { self.game_logs .lock() .push(format!("[launcher] account sync failed: {message}")); self.local_services_message = Some((false, message)); self.active_tab = Tab::Logs; return; } }; self.game_logs.lock().push(format!( "[launcher] account synchronized: {}/{} level={} XP={} account-funds={} FUT-coins={} unopened-packs={}", account.persona_id, account.persona_name, account.level, account.experience, account.account_funds, account.coins, account.unopened_packs, )); if let Err(message) = self.ensure_local_services() { self.game_logs .lock() .push(format!("[launcher] launch blocked: {message}")); self.local_services_message = Some((false, message)); self.active_tab = Tab::Logs; return; } // Prefer the native profile; fall back to the user's shell command. // The fallback is why an existing setup keeps working after upgrading, // and why a profile that misbehaves is recoverable without a rebuild. let result = if self.config.game_profile.configured() { game_launch::launch(&self.config.game_profile, &self.game_logs) } else { let cmd = self.config.game_launch_command.clone(); let workdir = self.config.game_launch_workdir.clone(); setup::launch_game(&cmd, &workdir, Arc::clone(&self.game_logs)) }; if let Err(e) = result { self.game_logs .lock() .push(format!("[launcher] launch failed: {e}")); } self.active_tab = Tab::Logs; } /// Start one companion service, recording a user-facing result message. fn ensure_local_services(&mut self) -> Result<(), String> { use crate::local_services::Service; if !self.lsx.running(&self.game_logs, Service::Lsx.label()) { self.start_local_service(Service::Lsx)?; } if !self .autopatch .running(&self.game_logs, Service::Autopatch.label()) { self.start_local_service(Service::Autopatch)?; } Ok(()) } fn start_local_service(&mut self, which: crate::local_services::Service) -> Result<(), String> { use crate::local_services::spawn; let py = self.config.fifa17_python.clone(); let dir = self.config.fifa17_tools_dir.clone(); let persona_id = self.config.fut_persona_id; let persona_name = self.config.fut_persona_name.clone(); let logs = Arc::clone(&self.game_logs); // Only autopatch advertises the verified resolver guard, so only it // receives the shared capability sink; LSX passes None. let capability = match which { crate::local_services::Service::Autopatch => { Some(crate::local_services::CapabilityWiring { server_host: self.config.openfut_server_host.clone(), account_sync_port: self.config.openfut_account_sync_port, sink: Arc::clone(&self.fifa17_caps), }) } crate::local_services::Service::Lsx => None, }; let slot = match which { crate::local_services::Service::Lsx => &mut self.lsx, crate::local_services::Service::Autopatch => &mut self.autopatch, }; match spawn( which, &py, &dir, persona_id, &persona_name, capability, logs, ) { Ok(child) => { *slot = crate::local_services::ManagedService::from_child(child); self.local_services_message = Some((true, format!("{} started.", which.label()))); Ok(()) } Err(e) => { let message = format!("{}: {e}", which.label()); self.local_services_message = Some((false, message.clone())); Err(message) } } } fn ui_logs(&mut self, ui: &mut Ui) { let line_count = self.game_logs.lock().lines().count(); ui.horizontal(|ui| { ui.label(RichText::new("Console").text_style(theme::text_style(theme::SUBHEADING))); ui.label( RichText::new(format!("{line_count} lines")) .color(theme::TEXT_FAINT) .small(), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Clear").clicked() { self.game_logs.lock().clear(); } ui.checkbox(&mut self.log_follow, "Follow"); }); }); ui.add_space(8.0); let follow = self.log_follow; egui::Frame::none() .fill(theme::INSET) .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)) .rounding(egui::Rounding::same(10.0)) .inner_margin(egui::Margin::same(12.0)) .show(ui, |ui| { ui.set_width(ui.available_width()); ScrollArea::vertical() .auto_shrink([false, false]) .stick_to_bottom(follow) .show(ui, |ui| { let guard = self.game_logs.lock(); if guard.lines().next().is_none() { ui.add_space(6.0); ui.label( RichText::new( "No output yet. Launch the game or start a service to \ see logs here.", ) .color(theme::TEXT_FAINT), ); return; } for line in guard.lines() { let color = log_line_color(line); ui.add( egui::Label::new( RichText::new(line).monospace().color(color).size(12.5), ) .wrap(), ); } }); }); } fn ui_setup(&mut self, ui: &mut Ui) { use std::path::Path; ui.heading("FIFA integration setup"); ui.add_space(4.0); ui.label( RichText::new( "These steps route the game's EA traffic to your OpenFUT server via DLL \ injection (no hosts file changes needed).", ) .color(theme::TEXT_WEAK), ); ui.add_space(16.0); // ── 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( 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(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| { 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"), ) .clicked() { 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)), } } }); if let Some((ok, msg)) = &self.test_message { let color = if *ok { theme::SUCCESS } else { theme::ERROR }; ui.colored_label(color, msg); } }); ui.add_space(14.0); // ── Hook DLL deployment ─────────────────────────────────────────────── theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); card_header(ui, "Step 2 · Deploy network hook DLL", None); ui.add_space(4.0); ui.label( "Copies openfut_hook.dll into the game folder as version.dll. When \ Proton loads the game it intercepts network calls and redirects EA \ hostnames to your OpenFUT server — no hosts file changes required.", ); ui.add_space(6.0); let game_dir = Path::new(&self.config.fifa_game_dir); self.hook_deployed = setup::hook_dll_deployed(game_dir); let dll_src = Path::new(&self.config.hook_dll_path); let dll_built = dll_src.exists(); if !dll_built { ui.colored_label( theme::WARN, "⚠ Hook DLL not built yet. Build in openfut-hook/:", ); ui.monospace("cargo build --release --target x86_64-pc-windows-gnu"); ui.add_space(4.0); } ui.horizontal(|ui| { if self.hook_deployed { status_text(ui, Status::Ok, "Deployed (version.dll)"); ui.add_space(8.0); if ui.button("Remove").clicked() { match setup::remove_hook_dll(game_dir) { Ok(()) => { self.setup_message = Some((true, "Hook DLL removed.".into())); self.hook_deployed = false; } Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))), } } } else { status_text(ui, Status::Error, "Not deployed"); ui.add_space(8.0); if ui .add_enabled(dll_built, egui::Button::new("Deploy")) .clicked() { match self.config.hook_cfg_contents() { Ok(cfg) => match setup::deploy_hook_dll(dll_src, game_dir, &cfg) { Ok(()) => { self.setup_message = Some((true, "Hook DLL deployed as version.dll.".into())); self.hook_deployed = true; } Err(e) => { self.setup_message = Some((false, format!("Failed: {e}"))) } }, Err(msg) => self.setup_message = Some((false, msg)), } } } }); ui.add_space(8.0); ui.label( RichText::new( "For Steam, paste this into the game's Launch Options; for a custom \ launch script, export it before running the game:", ) .weak() .small(), ); let launch_opt = setup::STEAM_LAUNCH_OPTIONS; ui.horizontal(|ui| { ui.monospace(launch_opt); if ui.small_button("Copy").clicked() { ui.output_mut(|o| o.copied_text = launch_opt.to_string()); } }); }); ui.add_space(14.0); // ── Cert install ────────────────────────────────────────────────────── theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); card_header(ui, "Step 3 · Install TLS certificate", None); ui.add_space(4.0); ui.label( "Installs the bridge's self-signed cert into the Wine/Proton cert store \ so the game accepts HTTPS connections to the bridge. The cert file must \ be reachable at the configured captures path (copy it from the server).", ); ui.add_space(6.0); self.cert_path = setup::find_bridge_cert(&self.config.bridge_captures_dir); match &self.cert_path.clone() { None => { ui.colored_label( theme::WARN, "⚠ Cert not found at the captures path. Copy bridge_cert.pem \ from the server into that directory.", ); } Some(cert) => { ui.label(format!("Cert: {}", cert.display())); ui.add_space(4.0); if ui.button("Install cert (Wine cert store)").clicked() { match setup::install_cert(cert) { Ok(()) => { self.setup_message = Some((true, "Certificate installed.".into())) } Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))), } } } } }); ui.add_space(14.0); if let Some((ok, msg)) = &self.setup_message { let color = if *ok { theme::SUCCESS } else { theme::ERROR }; ui.colored_label(color, msg); } } 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), ); 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()); card_header(ui, "Game", None); egui::Grid::new("config_game_grid") .num_columns(2) .spacing([14.0, 9.0]) .min_col_width(150.0) .show(ui, |ui| { ui.label(RichText::new("Launch command:").color(theme::TEXT_WEAK)); changed |= ui .add( egui::TextEdit::singleline(&mut self.config.game_launch_command) .hint_text("e.g. ~/Desktop/launch-fifa17.sh"), ) .changed(); ui.end_row(); ui.label(RichText::new("Launch workdir:").color(theme::TEXT_WEAK)); changed |= ui .add( egui::TextEdit::singleline(&mut self.config.game_launch_workdir) .hint_text("optional, e.g. /mnt/games/FIFA 17"), ) .changed(); ui.end_row(); ui.label(RichText::new("FIFA game dir:").color(theme::TEXT_WEAK)); changed |= ui .text_edit_singleline(&mut self.config.fifa_game_dir) .changed(); ui.end_row(); ui.label(RichText::new("FIFA17 tools dir:").color(theme::TEXT_WEAK)); changed |= ui .add( egui::TextEdit::singleline(&mut self.config.fifa17_tools_dir) .hint_text("fifa17-recon/tools (LSX + autopatch scripts)"), ) .changed(); ui.end_row(); ui.label(RichText::new("Python:").color(theme::TEXT_WEAK)); changed |= ui .add( egui::TextEdit::singleline(&mut self.config.fifa17_python) .hint_text("python3"), ) .changed(); ui.end_row(); }); }); ui.add_space(14.0); 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)); 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; } ui.end_row(); ui.label(RichText::new("HTTPS port:").color(theme::TEXT_WEAK)); let mut p = self.config.openfut_https_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_https_port = v; } changed = true; server_changed = true; } 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 .add(egui::TextEdit::singleline(&mut p).desired_width(90.0)) .changed() { if let Ok(v) = p.parse() { 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); theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); card_header(ui, "EA / Origin account", None); egui::Grid::new("config_account_grid") .num_columns(2) .spacing([14.0, 9.0]) .min_col_width(150.0) .show(ui, |ui| { ui.label(RichText::new("Persona ID:").color(theme::TEXT_WEAK)); changed |= ui .add(egui::DragValue::new(&mut self.config.fut_persona_id).speed(1)) .changed(); ui.end_row(); ui.label(RichText::new("Persona name:").color(theme::TEXT_WEAK)); changed |= ui .add( egui::TextEdit::singleline(&mut self.config.fut_persona_name) .hint_text("EA/Origin display name"), ) .changed(); ui.end_row(); ui.label(RichText::new("Account-bar level:").color(theme::TEXT_WEAK)); changed |= ui .add( egui::DragValue::new(&mut self.config.fut_account_level) .range(1..=u32::MAX), ) .changed(); ui.end_row(); ui.label(RichText::new("Account-bar XP:").color(theme::TEXT_WEAK)); ui.horizontal(|ui| { changed |= ui .add(egui::DragValue::new( &mut self.config.fut_account_experience, )) .changed(); ui.label(RichText::new("/").color(theme::TEXT_FAINT)); changed |= ui .add(egui::DragValue::new( &mut self.config.fut_account_experience_max, )) .changed(); }); ui.end_row(); ui.label(RichText::new("Account-bar funds:").color(theme::TEXT_WEAK)); ui.horizontal(|ui| { changed |= ui .add(egui::DragValue::new(&mut self.config.fut_account_funds)) .changed(); ui.label(RichText::new("/ cap").color(theme::TEXT_FAINT)); changed |= ui .add(egui::DragValue::new(&mut self.config.fut_account_funds_cap)) .changed(); }); ui.end_row(); ui.label(RichText::new("Captures dir:").color(theme::TEXT_WEAK)); changed |= ui .text_edit_singleline(&mut self.config.bridge_captures_dir) .changed(); ui.end_row(); }); }); ui.add_space(14.0); theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); card_header(ui, "Hook DLL", None); egui::Grid::new("config_hook_grid") .num_columns(2) .spacing([14.0, 9.0]) .min_col_width(150.0) .show(ui, |ui| { ui.label(RichText::new("Hook DLL path:").color(theme::TEXT_WEAK)); changed |= ui .text_edit_singleline(&mut self.config.hook_dll_path) .changed(); ui.end_row(); }); }); if changed { self.config_dirty = true; } if server_changed { self.refresh_health_target(); } ui.add_space(12.0); ui.horizontal(|ui| { if ui .add_enabled( self.config_dirty || hook_drift, egui::Button::new(RichText::new("Save").color(theme::ON_ACCENT)) .fill(theme::ACCENT), ) .clicked() { // 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(); self.config_dirty = true; 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 /// status pill on the right. fn ui_header(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { // Wordmark badge — an accent tile with the OpenFUT monogram. egui::Frame::none() .fill(theme::ACCENT) .rounding(egui::Rounding::same(9.0)) .inner_margin(egui::Margin::symmetric(11.0, 6.0)) .show(ui, |ui| { ui.label( RichText::new("OF") .text_style(theme::text_style(theme::HERO)) .size(22.0) .color(theme::ON_ACCENT), ); }); ui.add_space(12.0); ui.vertical(|ui| { ui.add_space(1.0); ui.label( RichText::new("OpenFUT") .text_style(theme::text_style(theme::HERO)) .size(24.0) .color(theme::TEXT), ); ui.label( RichText::new("FIFA 17 Ultimate Team · Launcher") .color(theme::TEXT_FAINT) .small(), ); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let health = self.health.snapshot(); let (status, label) = match health.reachable { None => (Status::Unknown, "Server unknown"), Some(true) => (Status::Ok, "Server online"), Some(false) => (Status::Error, "Server offline"), }; theme::status_pill(ui, label, status); }); }); } /// One row in the left navigation rail. Draws its own background/active /// accent so the rail reads as a branded nav, not a plain button column. 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()); if resp.clicked() { self.active_tab = tab; } let bg = if active { theme::ACCENT_WASH } else if resp.hovered() { theme::SURFACE_HOVER } else { Color32::TRANSPARENT }; let painter = ui.painter(); painter.rect_filled(rect, egui::Rounding::same(8.0), bg); if active { let bar = egui::Rect::from_min_size( rect.min + egui::vec2(0.0, 8.0), egui::vec2(3.0, rect.height() - 16.0), ); 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 mid = rect.left_center(); painter.text( mid + egui::vec2(16.0, 0.0), egui::Align2::LEFT_CENTER, glyph, egui::FontId::proportional(15.0), icon_color, ); painter.text( mid + egui::vec2(42.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(14.5), text_color, ); } } impl eframe::App for LauncherApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { ctx.request_repaint_after(std::time::Duration::from_millis(500)); egui::TopBottomPanel::top("header") .frame( egui::Frame::none() .fill(theme::BG_DEEP) .inner_margin(egui::Margin::symmetric(22.0, 14.0)) .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)), ) .show(ctx, |ui| self.ui_header(ui)); egui::SidePanel::left("nav") .resizable(false) .exact_width(198.0) .frame( egui::Frame::none() .fill(theme::BG_DEEP) .inner_margin(egui::Margin::symmetric(12.0, 16.0)) .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)), ) .show(ctx, |ui| { // 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::Settings, "⚙", "Settings"); // Version pinned to the bottom of the rail. ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| { ui.add_space(2.0); ui.label( RichText::new(concat!("v", env!("CARGO_PKG_VERSION"))) .color(theme::TEXT_FAINT) .small(), ); ui.label( RichText::new("Client-side tool") .color(theme::TEXT_FAINT) .small(), ); }); }); egui::CentralPanel::default() .frame( egui::Frame::none() .fill(theme::BG) .inner_margin(egui::Margin::symmetric(24.0, 20.0)), ) .show(ctx, |ui| { // Logs owns a full-height console with its own scroller; every // other tab scrolls its stacked cards. if self.active_tab == Tab::Logs { self.ui_logs(ui); } else { 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::Settings => self.ui_settings(ui), Tab::Logs => {} }); } }); } } fn log_line_color(line: &str) -> Color32 { let lower = line.to_lowercase(); if lower.contains("error") || lower.contains("panic") { theme::ERROR } else if lower.contains("warn") { theme::WARN } else if lower.contains("info") { theme::INFO } else { theme::TEXT } } /// A card title row: bold subheading on the left, optional status pill pushed to /// the right edge. fn card_header(ui: &mut Ui, title: &str, pill: Option<(&str, Status)>) { ui.horizontal(|ui| { ui.label( RichText::new(title) .text_style(theme::text_style(theme::SUBHEADING)) .color(theme::TEXT), ); if let Some((label, status)) = pill { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { theme::status_pill(ui, label, status); }); } }); ui.add_space(12.0); } /// Inline status: a coloured glyph followed by same-coloured text. Used inside /// grids where a full pill would be too heavy. 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(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 /// genuinely broken response. Never draws an empty/populated-looking shell. fn account_card(ui: &mut Ui, account: &crate::account_monitor::AccountState) { let pill = if account.summary.is_some() { ("Online", Status::Ok) } else if !account.configured || account.unreachable() { ("Offline", Status::Idle) } else if account.error.is_some() { ("Error", Status::Error) } else { ("Fetching…", Status::Busy) }; card_header(ui, "Your Club", Some(pill)); match &account.summary { Some(summary) => account_body(ui, summary), None => { let (msg, color) = if !account.configured || account.unreachable() { ( "Connect to your OpenFUT server to see your club.".to_string(), theme::TEXT_FAINT, ) } else if let Some(err) = &account.error { (format!("Account unavailable — {err}"), theme::ERROR) } else { ("Fetching account…".to_string(), theme::TEXT_WEAK) }; ui.label(RichText::new(msg).color(color)); } } } /// The populated body: club identity, the hero coin balance, an XP progress /// bar, and a compact stat row. fn account_body(ui: &mut Ui, s: &crate::account_sync::AccountSummary) { // Club identity: name + abbreviation badge, with the manager beneath. ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; let name = if s.club_name.trim().is_empty() { s.persona_name.as_str() } else { s.club_name.as_str() }; ui.label( RichText::new(name) .text_style(theme::text_style(theme::SUBHEADING)) .color(theme::TEXT), ); if !s.club_abbr.trim().is_empty() { club_badge(ui, &s.club_abbr); } }); ui.label( RichText::new(format!("Manager · {}", s.persona_name)) .color(theme::TEXT_FAINT) .small(), ); ui.add_space(16.0); // Hero: the coin balance is the single number that matters most. ui.label(RichText::new("COINS").color(theme::TEXT_WEAK).small()); ui.label( RichText::new(thousands_i64(s.coins)) .color(theme::ACCENT) .size(34.0) .strong(), ); ui.add_space(16.0); // Level + XP progression. ui.horizontal(|ui| { 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( RichText::new(format!( "{} / {} XP", thousands_u32(s.experience), thousands_u32(s.experience_max) )) .color(theme::TEXT_WEAK) .small(), ); }); } }); if s.experience_max > 0 { ui.add_space(4.0); let frac = (s.experience as f32 / s.experience_max as f32).clamp(0.0, 1.0); ui.add( egui::ProgressBar::new(frac) .desired_height(6.0) .fill(theme::ACCENT) .rounding(egui::Rounding::same(3.0)), ); } ui.add_space(14.0); // Compact stat row for the remaining figures. egui::Grid::new("account_stats_grid") .num_columns(2) .spacing([18.0, 10.0]) .show(ui, |ui| { ui.label(RichText::new("Unopened packs").color(theme::TEXT_WEAK)); if s.unopened_packs > 0 { ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { theme::status_pill(ui, &s.unopened_packs.to_string(), Status::Warn); }); } else { ui.label(RichText::new("0").color(theme::TEXT)); } ui.end_row(); ui.label(RichText::new("Account funds").color(theme::TEXT_WEAK)); let funds = if s.account_funds_cap > 0 { format!( "{} / {}", thousands_u32(s.account_funds), thousands_u32(s.account_funds_cap) ) } else { thousands_u32(s.account_funds) }; ui.label(RichText::new(funds).color(theme::TEXT)); ui.end_row(); }); } /// A small accent-washed badge for the club abbreviation (e.g. "OFC"). fn club_badge(ui: &mut Ui, abbr: &str) { egui::Frame::none() .fill(theme::ACCENT_WASH) .rounding(egui::Rounding::same(6.0)) .inner_margin(egui::Margin::symmetric(8.0, 2.0)) .show(ui, |ui| { ui.label( RichText::new(abbr.trim().to_uppercase()) .color(theme::ACCENT_HOVER) .strong() .size(12.0), ); }); } /// Group a run of ASCII digits into thousands with commas ("29876776" → /// "29,876,776"). fn group_thousands(digits: &str) -> String { let bytes = digits.as_bytes(); let len = bytes.len(); let mut out = String::with_capacity(len + len / 3); for (i, b) in bytes.iter().enumerate() { if i > 0 && (len - i) % 3 == 0 { out.push(','); } out.push(*b as char); } out } fn thousands_i64(n: i64) -> String { let s = group_thousands(&n.unsigned_abs().to_string()); if n < 0 { format!("-{s}") } else { s } } fn thousands_u32(n: u32) -> String { group_thousands(&n.to_string()) }