use std::sync::{Arc, Mutex}; use egui::{Color32, FontId, RichText, ScrollArea, Ui, Vec2}; use crate::{ config::LauncherConfig, game_launch, health::HealthMonitor, logs::LogBuffer, netcheck, preflight, setup, }; #[derive(PartialEq)] enum Tab { Dashboard, Logs, Setup, Config, } /// 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, /// 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)>, // 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)>, } impl LauncherApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { let mut style = (*cc.egui_ctx.style()).clone(); style .text_styles .insert(egui::TextStyle::Body, FontId::proportional(14.0)); style .text_styles .insert(egui::TextStyle::Monospace, FontId::monospace(13.0)); cc.egui_ctx.set_style(style); 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()); Self { config, config_dirty: false, health, game_logs: Arc::new(Mutex::new(LogBuffer::new())), active_tab: Tab::Dashboard, log_follow: true, hook_deployed, cert_path, setup_message: None, test_message: None, lsx: crate::local_services::ManagedService::default(), autopatch: crate::local_services::ManagedService::default(), local_services_message: None, preflight: None, arm_status: None, } } /// Re-point the health monitor whenever the server address may have changed. fn refresh_health_target(&self) { self.health.set_target(self.config.health_target()); } // ── UI sections ─────────────────────────────────────────────────────────── fn ui_dashboard(&mut self, ui: &mut Ui) { ui.add_space(8.0); ui.heading("OpenFUT Server"); ui.add_space(6.0); let server_ok = self.config.validate_server().is_ok(); if !server_ok { ui.colored_label( Color32::from_rgb(220, 150, 0), "No OpenFUT server configured. Enter the hostname or IP address of \ your OpenFUT server in the Setup tab.", ); ui.add_space(6.0); } // ── Server health (read-only) ───────────────────────────────────────── let health = self.health.snapshot(); egui::Grid::new("health_grid") .num_columns(2) .spacing([16.0, 8.0]) .show(ui, |ui| { ui.strong("Server"); 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.strong("Status"); match health.reachable { None => ui.colored_label(Color32::from_rgb(150, 150, 150), "Unknown"), Some(true) => ui.colored_label(Color32::from_rgb(80, 200, 120), "● Online"), Some(false) => { ui.colored_label(Color32::from_rgb(220, 60, 60), "● Unreachable") } }; ui.end_row(); ui.label("Detail"); ui.label(RichText::new(&health.detail).weak()); ui.end_row(); if let Some(t) = health.last_checked { ui.label("Checked"); ui.label(RichText::new(format!("{}s ago", t.elapsed().as_secs())).weak()); ui.end_row(); } }); ui.add_space(6.0); ui.label( RichText::new( "The server runs elsewhere (e.g. Docker on the server host). This \ launcher only monitors it — it does not start or stop it.", ) .weak() .small(), ); ui.add_space(16.0); ui.separator(); ui.add_space(8.0); // ── FIFA 17 local companion services ────────────────────────────────── self.ui_local_services(ui); ui.add_space(16.0); ui.separator(); ui.add_space(8.0); // ── Game launch ─────────────────────────────────────────────────────── ui.heading("Game"); ui.add_space(6.0); let launch_config = self.config.validate_launch_config(); let hook_ready = self.hook_deployed; let can_launch = launch_config.is_ok() && hook_ready; egui::Grid::new("game_status_grid") .num_columns(2) .spacing([12.0, 4.0]) .show(ui, |ui| { ui.label("Hook DLL:"); if hook_ready { ui.colored_label(Color32::from_rgb(80, 200, 120), "Deployed (version.dll)"); } else { ui.colored_label( Color32::from_rgb(220, 150, 0), "Not deployed — see Setup tab", ); } ui.end_row(); ui.label("Launch command:"); 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 { ui.colored_label( Color32::from_rgb(220, 150, 0), "Not set — configure it in the Config tab", ); } ui.end_row(); }); ui.add_space(10.0); self.preflight_ui(ui); ui.add_space(10.0); if ui .add_enabled( can_launch, egui::Button::new( RichText::new("▶ Start Local Services & Launch Game").size(16.0), ) .min_size(Vec2::new(160.0, 40.0)), ) .clicked() { self.launch_game(); } if let Err(message) = &launch_config { ui.add_space(4.0); ui.colored_label(Color32::from_rgb(220, 150, 0), message); } if !server_ok { ui.add_space(4.0); ui.colored_label( Color32::from_rgb(220, 150, 0), "Tip: the game can launch, but without a configured/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; ui.heading("FIFA 17 local services"); ui.add_space(4.0); 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 the game.", ) .weak() .small(), ); ui.add_space(6.0); let configured = !self.config.fifa17_tools_dir.trim().is_empty(); if !configured { ui.colored_label( Color32::from_rgb(220, 150, 0), "FIFA 17 tools dir not set — configure it in the Config tab.", ); return; } 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(); egui::Grid::new("local_services_grid") .num_columns(3) .spacing([12.0, 8.0]) .show(ui, |ui| { // LSX row ui.strong("LSX"); if lsx_stopping { ui.colored_label(Color32::from_rgb(220, 150, 0), "◌ Stopping"); } else if lsx_running { ui.colored_label(Color32::from_rgb(80, 200, 120), "● Running"); } else { ui.colored_label(Color32::from_rgb(150, 150, 150), "○ Stopped"); } 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.strong("autopatch"); if ap_stopping { ui.colored_label(Color32::from_rgb(220, 150, 0), "◌ Stopping"); } else if ap_running { ui.colored_label(Color32::from_rgb(80, 200, 120), "● Running"); } else { ui.colored_label(Color32::from_rgb(150, 150, 150), "○ Stopped"); } 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); } } else if ui.button("Start").clicked() { let _ = self.start_local_service(Service::Autopatch); } ui.end_row(); }); ui.add_space(6.0); if ui .button(RichText::new("Start both local services").size(14.0)) .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 { let color = if *ok { Color32::from_rgb(80, 200, 120) } else { Color32::from_rgb(220, 90, 90) }; ui.add_space(4.0); ui.colored_label(color, 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().unwrap(); 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() .unwrap() .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) => ( Color32::from_rgb(80, 200, 120), "no problems found".to_string(), ), (0, w) => ( Color32::from_rgb(220, 150, 0), format!("{w} warning(s) — worth fixing, usually not fatal"), ), (b, 0) => ( Color32::from_rgb(220, 90, 90), format!("{b} problem(s) — expect the game to fail"), ), (b, w) => ( Color32::from_rgb(220, 90, 90), format!("{b} problem(s), {w} warning(s)"), ), }; ui.colored_label(color, text); } }); if let Some((ok, msg)) = &self.arm_status { let color = if *ok { Color32::from_rgb(80, 200, 120) } else { Color32::from_rgb(220, 90, 90) }; 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 ", Color32::from_rgb(80, 200, 120)), preflight::State::Warn => ("WARN", Color32::from_rgb(220, 150, 0)), preflight::State::Fail => ("FAIL", Color32::from_rgb(220, 90, 90)), // Grey, never green: "not checked" must not read as "fine". preflight::State::Skipped => ("-- ", Color32::from_gray(140)), }; ui.horizontal(|ui| { ui.colored_label(color, RichText::new(mark).monospace()); ui.colored_label(color, &c.name); ui.label(RichText::new(&c.detail).weak()); }); } } fn launch_game(&mut self) { if let Err(message) = self.config.validate_launch_config() { self.game_logs .lock() .unwrap() .push(format!("[launcher] launch blocked: {message}")); self.local_services_message = Some((false, message)); self.active_tab = Tab::Logs; return; } if !self.hook_deployed { let message = "Hook DLL is not deployed. Complete Setup before launching.".to_string(); self.game_logs .lock() .unwrap() .push(format!("[launcher] launch blocked: {message}")); self.local_services_message = Some((false, message)); self.active_tab = Tab::Logs; return; } let account = match crate::account_sync::sync(&self.config) { Ok(account) => account, Err(message) => { self.game_logs .lock() .unwrap() .push(format!("[launcher] account sync failed: {message}")); self.local_services_message = Some((false, message)); self.active_tab = Tab::Logs; return; } }; self.game_logs.lock().unwrap().push(format!( "[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() .unwrap() .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() .unwrap() .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 slot = match which { crate::local_services::Service::Lsx => &mut self.lsx, crate::local_services::Service::Autopatch => &mut self.autopatch, }; match spawn( which, &py, &dir, self.config.fut_persona_id, &self.config.fut_persona_name, Arc::clone(&self.game_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) { ui.horizontal(|ui| { ui.strong("Game / launcher output"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Clear").clicked() { self.game_logs.lock().unwrap().clear(); } ui.checkbox(&mut self.log_follow, "Follow"); }); }); ui.separator(); let follow = self.log_follow; ScrollArea::vertical() .auto_shrink([false, false]) .stick_to_bottom(follow) .show(ui, |ui| { let guard = self.game_logs.lock().unwrap(); 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.add_space(8.0); ui.heading("FIFA Integration Setup"); ui.add_space(4.0); ui.label( "These steps route the game's EA traffic to your OpenFUT server via DLL \ injection (no hosts file changes needed).", ); ui.add_space(12.0); // ── Server address ──────────────────────────────────────────────────── ui.group(|ui| { ui.set_min_width(ui.available_width()); ui.strong("Step 1 — OpenFUT server address"); ui.add_space(4.0); ui.label( "The IP or hostname of your OpenFUT server. FIFA's intercepted EA \ traffic is redirected here. No loopback default — an empty value \ blocks setup.", ); ui.add_space(6.0); ui.horizontal(|ui| { ui.label("Server (IP or hostname):"); let changed = ui .add( egui::TextEdit::singleline(&mut self.config.openfut_server_host) .hint_text("e.g. 10.10.0.120 or fut.mylan.home") .desired_width(220.0), ) .changed(); if changed { self.config_dirty = true; self.test_message = None; self.refresh_health_target(); } }); ui.horizontal(|ui| { ui.label("Ports:"); ui.label("HTTPS"); let mut p = self.config.openfut_https_port.to_string(); if ui .add(egui::TextEdit::singleline(&mut p).desired_width(64.0)) .changed() { if let Ok(v) = p.parse() { self.config.openfut_https_port = v; } self.config_dirty = true; self.refresh_health_target(); } ui.label("Blaze-redir"); let mut r = self.config.openfut_blaze_redirector_port.to_string(); if ui .add(egui::TextEdit::singleline(&mut r).desired_width(64.0)) .changed() { if let Ok(v) = r.parse() { self.config.openfut_blaze_redirector_port = v; } self.config_dirty = true; } ui.label("Blaze-main"); let mut m = self.config.openfut_blaze_main_port.to_string(); if ui .add(egui::TextEdit::singleline(&mut m).desired_width(64.0)) .changed() { if let Ok(v) = m.parse() { self.config.openfut_blaze_main_port = v; } self.config_dirty = true; } }); ui.add_space(6.0); ui.horizontal(|ui| { if ui.button("Test Connection").clicked() { match self.config.validate_server() { Ok(()) => { let outcome = netcheck::test_connection(&self.config.server_config()); self.test_message = Some((outcome.ok, outcome.message)); } Err(msg) => self.test_message = Some((false, msg)), } } if ui .add_enabled(self.hook_deployed, egui::Button::new("Save & Update hook")) .clicked() { match self.config.hook_cfg_contents() { Ok(cfg) => match setup::update_hook_config( Path::new(&self.config.fifa_game_dir), &cfg, ) { Ok(()) => { self.config.save(); self.config_dirty = false; self.setup_message = Some(( true, format!( "Config updated — hook will redirect to {}.", self.config.openfut_server_host ), )); } Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))), }, Err(msg) => self.setup_message = Some((false, msg)), } } }); if let Some((ok, msg)) = &self.test_message { let color = if *ok { Color32::from_rgb(80, 200, 120) } else { Color32::from_rgb(220, 90, 90) }; ui.colored_label(color, msg); } }); ui.add_space(12.0); // ── Hook DLL deployment ─────────────────────────────────────────────── ui.group(|ui| { ui.set_min_width(ui.available_width()); ui.strong("Step 2 — Deploy network hook DLL"); 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( Color32::from_rgb(220, 150, 0), "⚠ 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 { ui.colored_label(Color32::from_rgb(80, 200, 120), "✔ 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 { ui.colored_label(Color32::from_rgb(220, 60, 60), "✘ 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(12.0); // ── Cert install ────────────────────────────────────────────────────── ui.group(|ui| { ui.set_min_width(ui.available_width()); ui.strong("Step 3 — Install TLS certificate"); 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( Color32::from_rgb(220, 150, 0), "⚠ 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(12.0); if let Some((ok, msg)) = &self.setup_message { let color = if *ok { Color32::from_rgb(80, 200, 120) } else { Color32::from_rgb(220, 60, 60) }; ui.colored_label(color, msg); } } fn ui_config(&mut self, ui: &mut Ui) { ui.add_space(8.0); ui.heading("Configuration"); ui.add_space(8.0); let mut changed = false; let mut server_changed = false; egui::Grid::new("config_grid") .num_columns(2) .spacing([12.0, 8.0]) .min_col_width(140.0) .show(ui, |ui| { ui.strong("Game"); ui.label(""); ui.end_row(); ui.label("Launch command:"); 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("Launch workdir:"); 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("FIFA game dir:"); changed |= ui .text_edit_singleline(&mut self.config.fifa_game_dir) .changed(); ui.end_row(); ui.label("FIFA17 tools dir:"); 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("Python:"); changed |= ui .add( egui::TextEdit::singleline(&mut self.config.fifa17_python) .hint_text("python3"), ) .changed(); ui.end_row(); ui.label(""); ui.label(""); ui.end_row(); ui.strong("OpenFUT server"); ui.label(""); ui.end_row(); ui.label("Server host:"); let r = ui.text_edit_singleline(&mut self.config.openfut_server_host); if r.changed() { changed = true; server_changed = true; } ui.end_row(); ui.label("HTTPS port:"); let mut p = self.config.openfut_https_port.to_string(); if ui.text_edit_singleline(&mut p).changed() { if let Ok(v) = p.parse() { self.config.openfut_https_port = v; } changed = true; server_changed = true; } ui.end_row(); ui.label("Account/UTAS port:"); let mut p = self.config.openfut_account_sync_port.to_string(); if ui.text_edit_singleline(&mut p).changed() { if let Ok(v) = p.parse() { self.config.openfut_account_sync_port = v; } changed = true; } ui.end_row(); ui.label(""); ui.label(""); ui.end_row(); ui.strong("EA / Origin account"); ui.label(""); ui.end_row(); ui.label("Persona ID:"); changed |= ui .add(egui::DragValue::new(&mut self.config.fut_persona_id).speed(1)) .changed(); ui.end_row(); ui.label("Persona name:"); changed |= ui .add( egui::TextEdit::singleline(&mut self.config.fut_persona_name) .hint_text("EA/Origin display name"), ) .changed(); ui.end_row(); ui.label("Account-bar level:"); changed |= ui .add( egui::DragValue::new(&mut self.config.fut_account_level) .range(1..=u32::MAX), ) .changed(); ui.end_row(); ui.label("Account-bar XP:"); ui.horizontal(|ui| { changed |= ui .add(egui::DragValue::new( &mut self.config.fut_account_experience, )) .changed(); ui.label("/"); changed |= ui .add(egui::DragValue::new( &mut self.config.fut_account_experience_max, )) .changed(); }); ui.end_row(); ui.label("Account-bar funds:"); ui.horizontal(|ui| { changed |= ui .add(egui::DragValue::new(&mut self.config.fut_account_funds)) .changed(); ui.label("/ cap"); changed |= ui .add(egui::DragValue::new(&mut self.config.fut_account_funds_cap)) .changed(); }); ui.end_row(); ui.label("Captures dir:"); changed |= ui .text_edit_singleline(&mut self.config.bridge_captures_dir) .changed(); ui.end_row(); ui.label(""); ui.label(""); ui.end_row(); ui.strong("Hook DLL"); ui.label(""); ui.end_row(); ui.label("Hook DLL path:"); 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, egui::Button::new("Save")) .clicked() { self.config.save(); self.config_dirty = false; self.refresh_health_target(); } if ui.button("Reset to defaults").clicked() { self.config = LauncherConfig::default(); self.config_dirty = true; self.refresh_health_target(); } }); } } 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("tab_bar").show(ctx, |ui| { ui.add_space(4.0); ui.horizontal(|ui| { ui.selectable_value(&mut self.active_tab, Tab::Dashboard, "Dashboard"); ui.selectable_value(&mut self.active_tab, Tab::Logs, "Logs"); ui.selectable_value(&mut self.active_tab, Tab::Setup, "Setup"); ui.selectable_value(&mut self.active_tab, Tab::Config, "Config"); }); ui.add_space(2.0); }); egui::CentralPanel::default().show(ctx, |ui| { ui.set_min_size(Vec2::new(600.0, 400.0)); match self.active_tab { Tab::Dashboard => self.ui_dashboard(ui), Tab::Logs => self.ui_logs(ui), Tab::Setup => self.ui_setup(ui), Tab::Config => self.ui_config(ui), } }); } } fn log_line_color(line: &str) -> Color32 { let lower = line.to_lowercase(); if lower.contains("error") || lower.contains("panic") { Color32::from_rgb(220, 80, 80) } else if lower.contains("warn") { Color32::from_rgb(255, 200, 0) } else if lower.contains("info") { Color32::from_rgb(160, 210, 255) } else { Color32::from_rgb(210, 210, 210) } }