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:
+488
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user