use std::sync::{Arc, Mutex}; use egui::{ Color32, FontId, RichText, ScrollArea, Ui, Vec2, }; use crate::{ config::LauncherConfig, logs::LogBuffer, process::{ServiceHandle, ServiceStatus}, setup, }; #[derive(PartialEq)] enum Tab { Dashboard, Logs, Setup, Config, } pub struct LauncherApp { config: LauncherConfig, config_dirty: bool, core: ServiceHandle, bridge: ServiceHandle, core_logs: Arc>, bridge_logs: Arc>, active_tab: Tab, log_tab: usize, // 0 = core, 1 = bridge log_follow: bool, // Setup state hook_deployed: bool, bridge_has_cap: bool, cert_path: Option, setup_message: Option<(bool, String)>, // (success, text) } impl LauncherApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { // Slightly larger default font 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 bridge_has_cap = setup::bridge_has_cap443(std::path::Path::new(&config.bridge_binary)); Self { config, config_dirty: false, core: ServiceHandle::new(), bridge: ServiceHandle::new(), core_logs: Arc::new(Mutex::new(LogBuffer::new())), bridge_logs: Arc::new(Mutex::new(LogBuffer::new())), active_tab: Tab::Dashboard, log_tab: 0, log_follow: true, hook_deployed, bridge_has_cap, cert_path, setup_message: None, } } fn start_core(&mut self) { let env = self.config.core_env(); if let Err(e) = self.core.start( &self.config.core_binary.clone(), &env, Arc::clone(&self.core_logs), ) { self.core_logs .lock() .unwrap() .push(format!("[launcher] Failed to start core: {e}")); } } fn start_bridge(&mut self) { let env = self.config.bridge_env(); if let Err(e) = self.bridge.start( &self.config.bridge_binary.clone(), &env, Arc::clone(&self.bridge_logs), ) { self.bridge_logs .lock() .unwrap() .push(format!("[launcher] Failed to start bridge: {e}")); } } fn stop_all(&mut self) { self.bridge.stop(); self.core.stop(); } // ── UI sections ─────────────────────────────────────────────────────────── fn ui_dashboard(&mut self, ui: &mut Ui) { ui.add_space(8.0); ui.heading("Services"); ui.add_space(6.0); let core_running = self.core.is_running(); let bridge_running = self.bridge.is_running(); egui::Grid::new("svc_grid") .num_columns(4) .spacing([16.0, 8.0]) .show(ui, |ui| { // Header ui.strong("Service"); ui.strong("Status"); ui.label(""); // start btn ui.label(""); // stop btn ui.end_row(); // Core ui.label("openfut-core"); self.status_badge(ui, &self.core.status()); ui.add_enabled_ui(!core_running, |ui| { if ui.button("▶ Start").clicked() { self.start_core(); } }); ui.add_enabled_ui(core_running, |ui| { if ui.button("■ Stop").clicked() { self.core.stop(); } }); ui.end_row(); // Bridge ui.label("openfut-bridge"); self.status_badge(ui, &self.bridge.status()); ui.add_enabled_ui(!bridge_running, |ui| { if ui.button("▶ Start").clicked() { self.start_bridge(); } }); ui.add_enabled_ui(bridge_running, |ui| { if ui.button("■ Stop").clicked() { self.bridge.stop(); } }); ui.end_row(); }); ui.add_space(12.0); ui.separator(); ui.add_space(8.0); // Quick-launch buttons ui.horizontal(|ui| { if ui .add_sized([120.0, 32.0], egui::Button::new("▶▶ Start All")) .clicked() { self.start_core(); self.start_bridge(); } ui.add_space(8.0); if ui .add_sized([120.0, 32.0], egui::Button::new("■■ Stop All")) .clicked() { self.stop_all(); } }); ui.add_space(16.0); ui.separator(); ui.add_space(8.0); ui.heading("Quick Status"); ui.add_space(4.0); egui::Grid::new("quick_status") .num_columns(2) .spacing([12.0, 4.0]) .show(ui, |ui| { ui.label("Hook DLL:"); if self.hook_deployed { ui.colored_label(Color32::from_rgb(80, 200, 120), "Deployed (version.dll)"); } else { ui.colored_label(Color32::from_rgb(220, 150, 0), "Not deployed"); } ui.end_row(); ui.label("Bridge cert:"); match &self.cert_path { Some(p) => { ui.colored_label( Color32::from_rgb(80, 200, 120), format!("Found ({})", p.file_name().unwrap_or_default().to_string_lossy()), ); } None => { ui.colored_label(Color32::from_rgb(220, 150, 0), "Not generated yet — start bridge first"); } } ui.end_row(); ui.label("Core URL:"); ui.monospace(&self.config.core_listen_addr); ui.end_row(); ui.label("Bridge URL:"); ui.monospace(&self.config.bridge_listen_addr); ui.end_row(); }); } fn ui_logs(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { ui.selectable_value(&mut self.log_tab, 0, "Core"); ui.selectable_value(&mut self.log_tab, 1, "Bridge"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Clear").clicked() { if self.log_tab == 0 { self.core_logs.lock().unwrap().clear(); } else { self.bridge_logs.lock().unwrap().clear(); } } ui.checkbox(&mut self.log_follow, "Follow"); }); }); ui.separator(); let buf = if self.log_tab == 0 { Arc::clone(&self.core_logs) } else { Arc::clone(&self.bridge_logs) }; let follow = self.log_follow; ScrollArea::vertical() .auto_shrink([false, false]) .stick_to_bottom(follow) .show(ui, |ui| { let guard = buf.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("System Setup"); ui.add_space(4.0); ui.label("These steps route FIFA 23 traffic through the emulator via DLL injection (no hosts file changes needed)."); ui.add_space(12.0); // ── Hook DLL deployment ─────────────────────────────────────────────── ui.group(|ui| { ui.set_min_width(ui.available_width()); ui.strong("Step 1 — Deploy network hook DLL"); ui.add_space(4.0); ui.label("Copies openfut_hook.dll into the FIFA 23 folder as version.dll. \ When Proton loads the game it will intercept network calls and redirect \ EA hostnames to the local bridge — 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. Run 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 setup::deploy_hook_dll( dll_src, game_dir, &self.config.hook_redirect_ip, ) { 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}"))), } } } }); ui.add_space(8.0); // IP configuration — always editable; Update writes openfut.cfg in-place ui.horizontal(|ui| { ui.label("Redirect IP:"); let changed = ui .add(egui::TextEdit::singleline(&mut self.config.hook_redirect_ip) .desired_width(160.0)) .changed(); if changed { self.config_dirty = true; } if ui.add_enabled(self.hook_deployed, egui::Button::new("Update")).clicked() { match setup::update_hook_config( Path::new(&self.config.fifa_game_dir), &self.config.hook_redirect_ip, ) { Ok(()) => { self.config.save(); self.config_dirty = false; self.setup_message = Some((true, format!( "Config updated — hook will redirect to {}.", self.config.hook_redirect_ip ))); } Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))), } } }); ui.label(egui::RichText::new("Tip: use 127.0.0.1 if the emulator is on this machine, or the LAN IP if it's on another.") .weak().small()); ui.add_space(8.0); ui.separator(); ui.add_space(4.0); ui.strong("Steam Launch Options"); ui.label("Paste this into FIFA 23 → Properties → Launch Options in Steam:"); ui.add_space(2.0); 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); // ── Port 443 capability ─────────────────────────────────────────────── ui.group(|ui| { ui.set_min_width(ui.available_width()); ui.strong("Step 2 — Grant port 443 capability"); ui.add_space(4.0); ui.label("Allows the bridge to bind port 443 directly — the same port FIFA 23 \ uses for HTTPS — without running as root. No iptables rules needed."); ui.add_space(6.0); // Clone to avoid holding a borrow on config while we mutate it below. let binary_path = std::path::PathBuf::from(&self.config.bridge_binary); self.bridge_has_cap = setup::bridge_has_cap443(&binary_path); ui.horizontal(|ui| { if self.bridge_has_cap { ui.colored_label(Color32::from_rgb(80, 200, 120), "✔ cap_net_bind_service granted"); } else { ui.colored_label(Color32::from_rgb(220, 60, 60), "✘ Not set"); ui.add_space(8.0); let binary_exists = binary_path.exists(); if ui .add_enabled(binary_exists, egui::Button::new("Grant (requires sudo)")) .clicked() { match setup::setcap_bridge_443(&binary_path) { Ok(()) => { self.bridge_has_cap = true; // Switch listen addr to 443 automatically self.config.bridge_listen_addr = "0.0.0.0:443".into(); self.config.save(); self.setup_message = Some(( true, "cap_net_bind_service granted. Bridge listen addr set to 0.0.0.0:443.".into(), )); } Err(e) => self.setup_message = Some((false, format!("setcap failed: {e}"))), } } if !binary_exists { ui.add_space(4.0); ui.colored_label( Color32::from_rgb(220, 150, 0), "⚠ Bridge binary not found — build it first.", ); } } }); ui.add_space(4.0); ui.label( egui::RichText::new( "Re-run this step any time the bridge binary is rebuilt (setcap is cleared on recompile).", ) .weak() .small(), ); }); 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."); 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 — start the bridge at least once to generate it.", ); } 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; egui::Grid::new("config_grid") .num_columns(2) .spacing([12.0, 8.0]) .min_col_width(120.0) .show(ui, |ui| { ui.strong("Core"); ui.label(""); ui.end_row(); ui.label("Binary path:"); changed |= ui.text_edit_singleline(&mut self.config.core_binary).changed(); ui.end_row(); ui.label("Listen addr:"); changed |= ui.text_edit_singleline(&mut self.config.core_listen_addr).changed(); ui.end_row(); ui.label("Database URL:"); changed |= ui.text_edit_singleline(&mut self.config.core_database_url).changed(); ui.end_row(); ui.label("Data dir:"); changed |= ui.text_edit_singleline(&mut self.config.core_data_dir).changed(); ui.end_row(); ui.label(""); ui.label(""); ui.end_row(); ui.strong("Bridge"); ui.label(""); ui.end_row(); ui.label("Binary path:"); changed |= ui.text_edit_singleline(&mut self.config.bridge_binary).changed(); ui.end_row(); ui.label("Listen addr:"); changed |= ui.text_edit_singleline(&mut self.config.bridge_listen_addr).changed(); ui.end_row(); ui.label("Core URL:"); changed |= ui.text_edit_singleline(&mut self.config.bridge_core_url).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("TLS enabled:"); changed |= ui.checkbox(&mut self.config.bridge_tls_enabled, "").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(); ui.label("FIFA 23 game dir:"); changed |= ui.text_edit_singleline(&mut self.config.fifa_game_dir).changed(); ui.end_row(); ui.label("Redirect IP:"); changed |= ui.text_edit_singleline(&mut self.config.hook_redirect_ip).changed(); ui.end_row(); }); if changed { self.config_dirty = true; } 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; } if ui.button("Reset to defaults").clicked() { self.config = LauncherConfig::default(); self.config_dirty = true; } }); } fn status_badge(&self, ui: &mut Ui, status: &ServiceStatus) { let color = status.color(); let label = status.label(); ui.colored_label(color, label); } } impl eframe::App for LauncherApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Repaint regularly to keep log views and status fresh 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) } }