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, launch, 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 + the launch sequence that drives them. // The controller owns the services so the launch sequence and the Advanced // panel act on the same objects; a second copy of "is it running" is how a UI // ends up claiming Ready over a dead process. controller: launch::Controller, local_services_message: Option<(bool, String)>, /// Services the user asked to restart, waiting for their stop to finish. restart_queue: Vec, /// Whether the Advanced / Diagnostics section is expanded. advanced_open: bool, /// Whether the failure card is showing implementation detail. show_failure_details: bool, /// Result of the last manual "Prepare client" click, shown inline where the /// user acted. arm_status: Option<(bool, String)>, /// Capabilities verified for the *current* FIFA process (shared with the /// autopatch stdout reader). Reset 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); let game_logs = Arc::new(Mutex::new(LogBuffer::new())); let controller = launch::Controller::new(Arc::clone(&game_logs)); let fifa17_caps: Arc> = Arc::new(Mutex::new(Default::default())); // Look at the world once at startup, on a worker thread. Without this the // status rows would honestly say "Not checked yet" until the user pressed // something — accurate, and a poor answer to "am I ready?". controller.refresh(&config, &game_logs, &fifa17_caps); Self { config, config_dirty: false, health, account, game_logs, active_tab, log_follow: true, hook_deployed, cert_path, setup_message: None, test_message: None, account_message: None, controller, local_services_message: None, restart_queue: Vec::new(), advanced_open: false, show_failure_details: false, arm_status: None, fifa17_caps, } } /// 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); // ── The launch surface ──────────────────────────────────────────────── // One card, one button. The individual moving parts — LSX, autopatch, // client preparation, the pre-launch checklist — are OpenFUT's internal // launch order, not a user's job; they live under Advanced. let snapshot = self.controller.snapshot(); let phase = snapshot.phase; let server = self.server_readiness(&health); let integration = launch::client_integration(&snapshot); let (services, services_label) = self.services_readiness(phase); let (hook, hook_label) = self.hook_readiness(); let overall = launch::overall(phase, server, integration, services, hook); let launch_config = self.config.validate_launch_config(); theme::card().show(ui, |ui| { ui.set_width(ui.available_width()); card_header(ui, "FIFA 17", Some(readiness_pill(overall, phase))); egui::Grid::new("launch_status_grid") .num_columns(2) .spacing([18.0, 9.0]) .min_col_width(150.0) .show(ui, |ui| { ui.label(RichText::new("OpenFUT server").color(theme::TEXT_WEAK)); let server_label = if self.config.openfut_server_host.trim().is_empty() { "Not configured".to_string() } else { match health.reachable { Some(true) => { format!("Connected · {}", self.config.openfut_server_host) } Some(false) => { format!("Unreachable · {}", self.config.openfut_server_host) } None => "Connecting…".to_string(), } }; status_text(ui, readiness_status(server), &server_label); ui.end_row(); ui.label(RichText::new("Game files").color(theme::TEXT_WEAK)); status_text( ui, readiness_status(integration), match integration { launch::Readiness::Ready => "Ready", launch::Readiness::Busy => "Preparing…", launch::Readiness::Attention => "Needs attention", // Never green for "we have not looked". launch::Readiness::Unknown => "Not checked yet", }, ); ui.end_row(); ui.label(RichText::new("Background helpers").color(theme::TEXT_WEAK)); status_text(ui, readiness_status(services), &services_label); ui.end_row(); ui.label(RichText::new("Game patch").color(theme::TEXT_WEAK)); status_text(ui, readiness_status(hook), &hook_label); ui.end_row(); }); ui.add_space(16.0); // ── The one button ─────────────────────────────────────────────── let (label, enabled) = match phase { launch::Phase::Idle => ("▶ Launch FIFA 17", launch_config.is_ok()), launch::Phase::Checking => ("◌ Checking…", false), launch::Phase::PreparingClient => ("◌ Preparing FIFA 17…", false), launch::Phase::StartingServices => ("◌ Starting local services…", false), launch::Phase::Validating => ("◌ Validating…", false), launch::Phase::Launching => ("◌ Starting FIFA 17…", false), launch::Phase::Running => ("● FIFA 17 Running", false), launch::Phase::Failed => ("Retry Launch", launch_config.is_ok()), }; let cta_w = ui.available_width().min(380.0); if ui .add_enabled( enabled, egui::Button::new(RichText::new(label).size(15.0).color(theme::ON_ACCENT)) .fill(if phase == launch::Phase::Failed { theme::WARN } else { theme::ACCENT }) .min_size(Vec2::new(cta_w, 46.0)) .rounding(egui::Rounding::same(10.0)), ) .clicked() { self.launch_game(); } // A configuration gap is the one thing Launch cannot repair, so it is // the one thing that disables the button. if let Err(message) = &launch_config { ui.add_space(6.0); ui.colored_label(theme::WARN, message); } if phase == launch::Phase::Failed { ui.add_space(12.0); self.ui_launch_failure(ui, &snapshot); } ui.add_space(10.0); let mut open = self.advanced_open; let header = egui::CollapsingHeader::new( RichText::new("Advanced / Diagnostics").color(theme::TEXT_WEAK), ) .open(Some(open)) .show(ui, |ui| self.ui_advanced(ui)); if header.header_response.clicked() { open = !open; } self.advanced_open = open; }); } /// The failure card: the high-level reason first, then which steps passed and /// which did not. Implementation output stays behind "View details". fn ui_launch_failure(&mut self, ui: &mut Ui, snapshot: &launch::LaunchState) { ui.colored_label( theme::ERROR, RichText::new("Unable to launch FIFA 17").strong(), ); if let Some(reason) = &snapshot.failure { ui.colored_label(theme::ERROR, reason); } ui.add_space(8.0); for (step, outcome) in &snapshot.steps { let (mark, color) = match outcome { // The bundled font has no ✓/✕, so use the dot the rest of the UI // already uses; colour carries the verdict. launch::Outcome::Done(_) => (Status::Ok.glyph(), theme::SUCCESS), launch::Outcome::Skipped(_) => (Status::Ok.glyph(), theme::TEXT_WEAK), launch::Outcome::Failed(_) => (Status::Error.glyph(), theme::ERROR), }; ui.horizontal(|ui| { ui.colored_label(color, mark); ui.colored_label(color, step.label()); ui.label( RichText::new(outcome.detail()) .color(theme::TEXT_FAINT) .small(), ); }); } ui.add_space(8.0); ui.horizontal(|ui| { if ui.button("Retry").clicked() { self.launch_game(); } let details_label = if self.show_failure_details { "Hide details" } else { "View details" }; if ui.button(details_label).clicked() { self.show_failure_details = !self.show_failure_details; } }); if self.show_failure_details { ui.add_space(8.0); if let Some(checks) = &snapshot.checks { check_list(ui, checks); } let runtimes = self.observe_services(); for (service, runtime) in runtimes { let mut facts = vec![if runtime.running { "running".to_string() } else { "stopped".to_string() }]; if let Some(pid) = runtime.pid { facts.push(format!("pid {pid}")); } if runtime.running && !runtime.started_by_launcher { facts.push("started outside this launcher".into()); } if let Some(detail) = &runtime.detail { facts.push(detail.clone()); } ui.label( RichText::new(format!("{}: {}", service.label(), facts.join(" · "))) .color(theme::TEXT_WEAK) .monospace() .small(), ); } ui.add_space(6.0); if ui.button("Open full logs").clicked() { self.active_tab = Tab::Logs; } } } /// Server readiness from the background health poll. The server is remote by /// design, so this is the only dependency the launcher cannot repair. fn server_readiness(&self, health: &crate::health::HealthState) -> launch::Readiness { if self.config.validate_server().is_err() { return launch::Readiness::Attention; } match health.reachable { Some(true) => launch::Readiness::Ready, Some(false) => launch::Readiness::Attention, None => launch::Readiness::Unknown, } } /// Local-service readiness, observed — not remembered from a button press. fn services_readiness(&self, phase: launch::Phase) -> (launch::Readiness, String) { if phase == launch::Phase::StartingServices { return (launch::Readiness::Busy, "Starting…".into()); } let runtimes = self.observe_services(); let ready = runtimes.iter().filter(|(_, r)| r.ready()).count(); let blocked = runtimes .iter() .any(|(_, r)| !r.running && r.detail.is_some()); match (ready, blocked) { (_, true) => (launch::Readiness::Attention, "Blocked".into()), (2, _) => (launch::Readiness::Ready, "Ready".into()), // Neither stopped nor mid-start is a fault: the helpers only run // alongside a session and Launch brings up whatever is missing. This // used to read "Partly running", which sounds broken for what is the // normal idle state and gave the player nothing to act on. Say what // will happen instead. (_, _) => (launch::Readiness::Unknown, "Start with the game".into()), } } /// Hook readiness: the DLL is deployed and the address it will send FIFA to /// matches the current settings. fn hook_readiness(&self) -> (launch::Readiness, String) { if !self.hook_deployed { return (launch::Readiness::Attention, "Not deployed".into()); } match setup::read_hook_config(std::path::Path::new(&self.config.fifa_game_dir)) .and_then(|body| openfut_common::ServerConfig::parse(&body).ok()) { Some(d) if d == self.config.server_config() => { (launch::Readiness::Ready, "Installed".into()) } // Launch rewrites it, so this is not something to demand action for. Some(_) => ( launch::Readiness::Unknown, "Installed · Launch will update it".into(), ), None => (launch::Readiness::Attention, "Not set up".into()), } } /// Advanced / Diagnostics: every manual control the normal flow automates. /// /// OpenFUT is actively being reverse engineered, so independent control of /// each moving part stays available — it is just no longer the front door. fn ui_advanced(&mut self, ui: &mut Ui) { ui.label( RichText::new( "Everything here happens automatically when you press Launch. These \ controls exist for development and debugging.", ) .color(theme::TEXT_FAINT) .small(), ); ui.add_space(10.0); // ── Local services ─────────────────────────────────────────────────── ui.label(RichText::new("Local services").color(theme::TEXT).strong()); ui.label( RichText::new( "LSX (Origin emulator, loopback 4216) and autopatch (ProtoSSL cert-verify) \ must run on THIS machine. The Blaze/UTAS/roster responders run on the \ server.", ) .color(theme::TEXT_FAINT) .small(), ); ui.add_space(6.0); // Rendered unconditionally: the companions are workspace binaries // resolved relative to this launcher, so there is no configuration that // could make this panel inapplicable. A binary that is genuinely absent // surfaces as that service's own spawn error, not as a hidden panel. let runtimes = self.observe_services(); egui::Grid::new("advanced_services_grid") .num_columns(4) .spacing([12.0, 10.0]) .min_col_width(90.0) .show(ui, |ui| { for (service, runtime) in runtimes { ui.label(RichText::new(service.label()).color(theme::TEXT).strong()); if self.controller.services.lock().stopping(service) { theme::status_pill(ui, "Stopping", Status::Busy); } else if runtime.running && runtime.started_by_launcher { theme::status_pill(ui, "Running", Status::Ok); } else if runtime.running { theme::status_pill(ui, "Running (foreign)", Status::Warn); } else if runtime.detail.is_some() { theme::status_pill(ui, "Blocked", Status::Error); } else { theme::status_pill(ui, "Stopped", Status::Idle); } // Only ever facts the launcher established. let mut facts = Vec::new(); if let Some(pid) = runtime.pid { facts.push(format!("pid {pid}")); } if let Some(detail) = &runtime.detail { facts.push(detail.clone()); } ui.label( RichText::new(if facts.is_empty() { "—".to_string() } else { facts.join(" · ") }) .color(theme::TEXT_FAINT) .small(), ); ui.horizontal(|ui| { if runtime.running { if ui .add_enabled( runtime.started_by_launcher, egui::Button::new("Restart"), ) .clicked() { self.advanced_stop_service(service); self.restart_queue.push(service); } if ui .add_enabled(runtime.started_by_launcher, egui::Button::new("Stop")) .on_disabled_hover_text( "Started outside this launcher — stop it where it \ was started.", ) .clicked() { self.advanced_stop_service(service); } } else if ui.button("Start").clicked() { self.advanced_start_service(service); } }); ui.end_row(); } }); if let Some((ok, msg)) = &self.local_services_message { ui.add_space(6.0); ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg); } ui.add_space(14.0); // ── Client integration ─────────────────────────────────────────────── ui.label( RichText::new("Client integration") .color(theme::TEXT) .strong(), ); egui::Grid::new("advanced_client_grid") .num_columns(2) .spacing([14.0, 8.0]) .min_col_width(120.0) .show(ui, |ui| { ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK)); if self.hook_deployed { status_text(ui, Status::Ok, "version.dll deployed"); } else { status_text(ui, Status::Warn, "not deployed — see the Setup tab"); } ui.end_row(); ui.label(RichText::new("Hook target").color(theme::TEXT_WEAK)); match setup::read_hook_config(std::path::Path::new(&self.config.fifa_game_dir)) .and_then(|body| openfut_common::ServerConfig::parse(&body).ok()) { Some(d) if d == self.config.server_config() => { status_text(ui, Status::Ok, &d.host) } Some(d) => status_text( ui, Status::Warn, &format!("{} — differs from Settings", d.host), ), None => status_text(ui, Status::Idle, "no openfut.cfg deployed"), } ui.end_row(); }); ui.add_space(8.0); if ui .button("Prepare client") .on_hover_text( "Sets ptrace_scope, the EA-redirector DNAT and /etc/hosts in one elevated \ step (asks for your password once). Launch does this automatically when \ it is needed.", ) .clicked() { self.advanced_prepare_client(); } if let Some((ok, msg)) = &self.arm_status { ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg); } ui.add_space(14.0); // ── Diagnostics ────────────────────────────────────────────────────── ui.label(RichText::new("Diagnostics").color(theme::TEXT).strong()); let health = self.health.snapshot(); ui.label( RichText::new(format!( "Server probe: {}{}", health.detail, match health.last_checked { Some(t) => format!(" ({}s ago)", t.elapsed().as_secs()), None => String::new(), } )) .color(theme::TEXT_FAINT) .small(), ); ui.add_space(6.0); ui.horizontal(|ui| { if ui.button("Run pre-launch checks").clicked() { self.refresh_launch_state(); } if ui.button("View logs").clicked() { self.active_tab = Tab::Logs; } if ui .add_enabled( self.config.game_profile.configured() || !self.config.game_launch_command.trim().is_empty(), egui::Button::new("Launch game only"), ) .on_hover_text( "Starts FIFA without preparing anything. For debugging a launch that \ the sequence refuses.", ) .clicked() { self.launch_game_only(); } }); let snapshot = self.controller.snapshot(); if let Some(checks) = &snapshot.checks { ui.add_space(6.0); check_list(ui, checks); if let Some(age) = snapshot.checks_age { ui.label( RichText::new(format!("checked {}s ago", age.elapsed().as_secs())) .color(theme::TEXT_FAINT) .small(), ); } } else { ui.add_space(6.0); ui.label( RichText::new("Checks have not run yet.") .color(theme::TEXT_FAINT) .small(), ); } } /// Observe both services once, for rendering. fn observe_services( &self, ) -> Vec<( crate::local_services::Service, crate::local_services::ServiceRuntime, )> { use crate::local_services::Service; let mut supervisor = self.controller.services.lock(); [Service::Lsx, Service::Autopatch] .into_iter() .map(|s| { let runtime = supervisor.observe(s); (s, runtime) }) .collect() } /// Finish any Advanced "Restart" the user asked for. Stopping is asynchronous /// (it kills and reaps off the UI thread), so the start half has to wait for /// the process to actually be gone rather than racing its own kill. fn drive_restart_queue(&mut self) { if self.restart_queue.is_empty() { return; } let pending = std::mem::take(&mut self.restart_queue); for service in pending { let gone = { let mut supervisor = self.controller.services.lock(); !supervisor.stopping(service) && !supervisor.observe(service).running }; if gone { self.advanced_start_service(service); } else { self.restart_queue.push(service); } } } /// Start FIFA with no preparation at all. Debug escape hatch: the launch /// sequence refusing to start the game is usually right, and when it is /// wrong a developer still needs to get into the client. fn launch_game_only(&mut self) { let result = if self.config.game_profile.configured() { game_launch::launch(&self.config.game_profile, &self.game_logs, || {}) } else { setup::launch_game( &self.config.game_launch_command, &self.config.game_launch_workdir, Arc::clone(&self.game_logs), || {}, ) }; match result { Ok(()) => { self.local_services_message = Some((true, "FIFA started with no preparation.".into())) } Err(e) => self.local_services_message = Some((false, format!("launch failed: {e}"))), } self.active_tab = Tab::Logs; } /// The one thing the normal user does. Everything the launch needs — /// connectivity, the hook file, client preparation, LSX, autopatch, final /// validation — is decided and performed by [`launch::Controller`], which /// reuses whatever is already healthy. fn launch_game(&mut self) { if let Err(message) = self.config.validate_launch_config() { // A configuration gap is not something a launch sequence can repair. self.game_logs .lock() .push(format!("[launcher] launch blocked: {message}")); self.local_services_message = Some((false, message)); return; } self.show_failure_details = false; self.controller .launch(&self.config, &self.game_logs, &self.fifa17_caps); } /// Re-observe the world without touching it (startup, and after settings /// change). Runs on a worker thread; the checks open sockets. fn refresh_launch_state(&self) { self.controller .refresh(&self.config, &self.game_logs, &self.fifa17_caps); } /// Start one service by hand, from Advanced. Same supervisor the launch /// sequence uses, so the two can never disagree about what is running. fn advanced_start_service(&mut self, service: crate::local_services::Service) { let spec = crate::local_services::SpawnSpec { persona_id: self.config.fut_persona_id, persona_name: self.config.fut_persona_name.clone(), capability: match service { crate::local_services::Service::Autopatch => { Some(crate::local_services::CapabilityWiring { server_host: self.config.openfut_server_host.clone(), account_sync_port: self.config.openfut_account_sync_port, sink: Arc::clone(&self.fifa17_caps), }) } crate::local_services::Service::Lsx => None, }, }; let result = self .controller .services .lock() .ensure_running(service, spec); self.local_services_message = Some(match result { Ok(crate::local_services::Ensured::Started) => { (true, format!("{} started.", service.label())) } Ok(crate::local_services::Ensured::Reused) => { (true, format!("{} was already running.", service.label())) } Err(e) => (false, e), }); } /// Stop one service by hand. A service this launcher did not start is /// reported, never killed. fn advanced_stop_service(&mut self, service: crate::local_services::Service) { if service == crate::local_services::Service::Autopatch { // The verified capability belongs to the FIFA process autopatch was // serving; drop it when autopatch goes away. *self.fifa17_caps.lock() = Default::default(); } if let Err(e) = self.controller.services.lock().stop(service) { self.local_services_message = Some((false, e)); } } /// Client preparation, by hand, from Advanced. Internally this is `arm`; the /// normal flow performs it automatically and never names it. fn advanced_prepare_client(&mut self) { match crate::arm::arm(&self.config) { Ok(changes) => { for change in &changes { self.game_logs .lock() .push(format!("[launcher] prepared: {change}")); } self.arm_status = Some(( true, format!("Client prepared — {} change(s) applied.", changes.len()), )); self.refresh_launch_state(); } Err(e) => self.arm_status = Some((false, format!("Client preparation failed: {e}"))), } } 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.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)); self.drive_restart_queue(); 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 => {} }); } }); } } /// Status pill for the launch card's headline. fn readiness_pill(readiness: launch::Readiness, phase: launch::Phase) -> (&'static str, Status) { if phase == launch::Phase::Running { return ("Running", Status::Ok); } match readiness { launch::Readiness::Ready => ("Ready", Status::Ok), launch::Readiness::Busy => ("Working", Status::Busy), launch::Readiness::Attention => ("Needs attention", Status::Warn), // "Unknown" is deliberately not "Ready": the launcher has not looked, or // the thing it saw is something Launch will fix on the way. launch::Readiness::Unknown => ("Not ready", Status::Unknown), } } fn readiness_status(readiness: launch::Readiness) -> Status { match readiness { launch::Readiness::Ready => Status::Ok, launch::Readiness::Busy => Status::Busy, launch::Readiness::Attention => Status::Warn, launch::Readiness::Unknown => Status::Idle, } } /// The pre-launch checklist rows, for Advanced and the failure details. fn check_list(ui: &mut Ui, checks: &[preflight::Check]) { // States what was found, never what will happen. An earlier version predicted // "the game will probably fail" on a warning and the game then reached the // FUT hub — a checklist that overstates its findings gets ignored. let (bad, warn) = (preflight::failures(checks), preflight::warnings(checks)); let (color, summary) = match (bad, warn) { (0, 0) => (theme::SUCCESS, "no problems found".to_string()), (0, w) => ( theme::WARN, format!("{w} warning(s) — worth fixing, usually not fatal"), ), (b, 0) => (theme::ERROR, format!("{b} problem(s) found")), (b, w) => (theme::ERROR, format!("{b} problem(s), {w} warning(s)")), }; ui.colored_label(color, summary); for c in checks { let (mark, color) = match c.state { preflight::State::Pass => ("OK ", theme::SUCCESS), preflight::State::Warn => ("WARN", theme::WARN), preflight::State::Fail => ("FAIL", theme::ERROR), // Grey, never green: "not checked" must not read as "fine". preflight::State::Skipped => ("-- ", theme::TEXT_FAINT), }; ui.horizontal(|ui| { ui.colored_label(color, RichText::new(mark).monospace()); ui.colored_label(color, &c.name); ui.label(RichText::new(&c.detail).color(theme::TEXT_WEAK)); }); } } fn log_line_color(line: &str) -> Color32 { let lower = line.to_lowercase(); if lower.contains("error") || lower.contains("panic") { 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).is_multiple_of(3) { 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()) }