feat: initial OpenFUT Launcher

GUI desktop app (egui/eframe) that manages the openfut-core and
openfut-bridge services with a single window.

- Dashboard: start/stop each service independently or together; live
  status indicators; quick-status for cert and hosts
- Logs: real-time stdout/stderr from both processes, colour-coded by
  level, follow mode and manual clear
- Setup: add/remove EA hostname redirects in /etc/hosts (pkexec/sudo);
  install the bridge TLS cert into the system CA store
- Config: all binary paths and env vars editable in-app; persisted to
  ~/.config/openfut-launcher/config.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 20:02:02 -07:00
commit fc894a7f77
9 changed files with 5306 additions and 0 deletions
+488
View File
@@ -0,0 +1,488 @@
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<Mutex<LogBuffer>>,
bridge_logs: Arc<Mutex<LogBuffer>>,
active_tab: Tab,
log_tab: usize, // 0 = core, 1 = bridge
log_follow: bool,
// Setup state
hosts_configured: bool,
cert_path: Option<std::path::PathBuf>,
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 hosts_configured = setup::hosts_configured();
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,
hosts_configured,
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("Hosts file:");
if self.hosts_configured {
ui.colored_label(Color32::from_rgb(80, 200, 120), "Configured");
} else {
ui.colored_label(Color32::from_rgb(220, 150, 0), "Not configured");
}
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) {
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. Both require elevated privileges (polkit/sudo).");
ui.add_space(12.0);
// ── Hosts file ────────────────────────────────────────────────────────
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.strong("Step 1 — Redirect EA hostnames");
ui.add_space(4.0);
ui.label("Adds entries to /etc/hosts so the game connects to the bridge instead of EA's servers.");
ui.add_space(6.0);
// Refresh status
self.hosts_configured = setup::hosts_configured();
ui.horizontal(|ui| {
if self.hosts_configured {
ui.colored_label(Color32::from_rgb(80, 200, 120), "✔ Configured");
ui.add_space(8.0);
if ui.button("Remove entries").clicked() {
match setup::remove_hosts_entries() {
Ok(()) => {
self.setup_message = Some((true, "Hosts entries removed.".into()));
self.hosts_configured = false;
}
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
}
}
} else {
ui.colored_label(Color32::from_rgb(220, 60, 60), "✘ Not configured");
ui.add_space(8.0);
if ui.button("Add entries").clicked() {
match setup::add_hosts_entries() {
Ok(()) => {
self.setup_message = Some((true, "Hosts entries added.".into()));
self.hosts_configured = true;
}
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
}
}
}
});
});
ui.add_space(12.0);
// ── Cert install ──────────────────────────────────────────────────────
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.strong("Step 2 — Install TLS certificate");
ui.add_space(4.0);
ui.label("Installs the bridge's self-signed cert into the system CA store so the game accepts HTTPS connections.");
ui.add_space(6.0);
// Refresh cert path
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 (requires sudo)").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);
// ── Status message ────────────────────────────────────────────────────
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.add_space(4.0);
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();
});
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)
}
}