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:
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+4393
File diff suppressed because it is too large
Load Diff
+14
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "openfut-launcher"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
eframe = "0.29"
|
||||
egui = "0.29"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
+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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LauncherConfig {
|
||||
pub core_binary: String,
|
||||
pub bridge_binary: String,
|
||||
pub core_database_url: String,
|
||||
pub core_data_dir: String,
|
||||
pub core_listen_addr: String,
|
||||
pub bridge_listen_addr: String,
|
||||
pub bridge_captures_dir: String,
|
||||
pub bridge_core_url: String,
|
||||
pub bridge_tls_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for LauncherConfig {
|
||||
fn default() -> Self {
|
||||
let base = dirs::home_dir()
|
||||
.map(|h| h.join("Documents/OpenFUT"))
|
||||
.unwrap_or_default();
|
||||
|
||||
Self {
|
||||
core_binary: base
|
||||
.join("openfut-core/target/release/openfut-core")
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
bridge_binary: base
|
||||
.join("openfut-bridge/target/release/openfut-bridge")
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
core_database_url: "sqlite://openfut.db".into(),
|
||||
core_data_dir: base
|
||||
.join("openfut-core/data")
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
core_listen_addr: "127.0.0.1:8080".into(),
|
||||
bridge_listen_addr: "0.0.0.0:8765".into(),
|
||||
bridge_captures_dir: base
|
||||
.join("openfut-bridge/captures")
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
bridge_core_url: "http://127.0.0.1:8080".into(),
|
||||
bridge_tls_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LauncherConfig {
|
||||
pub fn config_path() -> std::path::PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("openfut-launcher")
|
||||
.join("config.json")
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
let path = Self::config_path();
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let path = Self::config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(self) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn core_env(&self) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("DATABASE_URL".into(), self.core_database_url.clone()),
|
||||
("DATA_DIR".into(), self.core_data_dir.clone()),
|
||||
("LISTEN_ADDR".into(), self.core_listen_addr.clone()),
|
||||
("RUST_LOG".into(), "openfut_core=info,tower_http=info".into()),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn bridge_env(&self) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("CORE_URL".into(), self.bridge_core_url.clone()),
|
||||
("LISTEN_ADDR".into(), self.bridge_listen_addr.clone()),
|
||||
("CAPTURES_DIR".into(), self.bridge_captures_dir.clone()),
|
||||
("TLS_ENABLED".into(), self.bridge_tls_enabled.to_string()),
|
||||
("RUST_LOG".into(), "openfut_bridge=info".into()),
|
||||
]
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const MAX_LINES: usize = 2000;
|
||||
|
||||
pub struct LogBuffer {
|
||||
lines: VecDeque<String>,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl LogBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
lines: VecDeque::with_capacity(MAX_LINES),
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, line: String) {
|
||||
if self.lines.len() >= MAX_LINES {
|
||||
self.lines.pop_front();
|
||||
}
|
||||
self.lines.push_back(line);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
pub fn lines(&self) -> impl Iterator<Item = &str> {
|
||||
self.lines.iter().map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod logs;
|
||||
mod process;
|
||||
mod setup;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("OpenFUT Launcher")
|
||||
.with_inner_size([780.0, 560.0])
|
||||
.with_min_inner_size([600.0, 400.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"OpenFUT Launcher",
|
||||
options,
|
||||
Box::new(|cc| Ok(Box::new(app::LauncherApp::new(cc)))),
|
||||
)
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
use std::{
|
||||
io::{BufRead, BufReader},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
};
|
||||
|
||||
use crate::logs::LogBuffer;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ServiceStatus {
|
||||
Stopped,
|
||||
Starting,
|
||||
Running,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl ServiceStatus {
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
ServiceStatus::Stopped => "Stopped",
|
||||
ServiceStatus::Starting => "Starting…",
|
||||
ServiceStatus::Running => "Running",
|
||||
ServiceStatus::Failed(_) => "Failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(&self) -> egui::Color32 {
|
||||
match self {
|
||||
ServiceStatus::Running => egui::Color32::from_rgb(80, 200, 120),
|
||||
ServiceStatus::Starting => egui::Color32::from_rgb(255, 200, 0),
|
||||
ServiceStatus::Failed(_) => egui::Color32::from_rgb(220, 60, 60),
|
||||
ServiceStatus::Stopped => egui::Color32::from_rgb(150, 150, 150),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ServiceHandle {
|
||||
child: Option<Child>,
|
||||
pub status: Arc<Mutex<ServiceStatus>>,
|
||||
}
|
||||
|
||||
impl ServiceHandle {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
child: None,
|
||||
status: Arc::new(Mutex::new(ServiceStatus::Stopped)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
&mut self,
|
||||
binary: &str,
|
||||
env_pairs: &[(String, String)],
|
||||
log_buf: Arc<Mutex<LogBuffer>>,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.is_running() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
*self.status.lock().unwrap() = ServiceStatus::Starting;
|
||||
|
||||
let mut cmd = Command::new(binary);
|
||||
for (k, v) in env_pairs {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
*self.status.lock().unwrap() = ServiceStatus::Failed(e.to_string());
|
||||
e
|
||||
})?;
|
||||
|
||||
// Drain stdout
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let buf = Arc::clone(&log_buf);
|
||||
let status = Arc::clone(&self.status);
|
||||
thread::spawn(move || {
|
||||
*status.lock().unwrap() = ServiceStatus::Running;
|
||||
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drain stderr
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let buf = Arc::clone(&log_buf);
|
||||
thread::spawn(move || {
|
||||
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.child = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
*self.status.lock().unwrap() = ServiceStatus::Stopped;
|
||||
}
|
||||
|
||||
pub fn is_running(&mut self) -> bool {
|
||||
if let Some(child) = &mut self.child {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
// process exited
|
||||
self.child = None;
|
||||
*self.status.lock().unwrap() = ServiceStatus::Stopped;
|
||||
false
|
||||
}
|
||||
Ok(None) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> ServiceStatus {
|
||||
self.status.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServiceHandle {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
use std::process::Command;
|
||||
|
||||
/// EA FUT hostnames the bridge needs to intercept.
|
||||
const INTERCEPT_HOSTS: &[&str] = &[
|
||||
"fut.ea.com",
|
||||
"utas.mob.v4.fut.ea.com",
|
||||
"utas.s2.fut.ea.com",
|
||||
];
|
||||
|
||||
const HOSTS_MARKER_START: &str = "# BEGIN openfut-launcher";
|
||||
const HOSTS_MARKER_END: &str = "# END openfut-launcher";
|
||||
|
||||
// ── Cert installation ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Find the bridge cert in the given captures dir.
|
||||
pub fn find_bridge_cert(captures_dir: &str) -> Option<std::path::PathBuf> {
|
||||
let p = std::path::Path::new(captures_dir).join("bridge_cert.pem");
|
||||
p.exists().then_some(p)
|
||||
}
|
||||
|
||||
/// Install the bridge cert into the system CA store.
|
||||
/// On Linux, copies to /usr/local/share/ca-certificates/ and runs
|
||||
/// update-ca-certificates via pkexec (polkit graphical sudo).
|
||||
pub fn install_cert(cert_src: &std::path::Path) -> anyhow::Result<()> {
|
||||
let dest = std::path::Path::new("/usr/local/share/ca-certificates/openfut-bridge.crt");
|
||||
|
||||
// Write a small shell script that pkexec can run as root
|
||||
let script = format!(
|
||||
"cp '{}' '{}' && update-ca-certificates",
|
||||
cert_src.display(),
|
||||
dest.display()
|
||||
);
|
||||
|
||||
let status = Command::new("pkexec")
|
||||
.args(["sh", "-c", &script])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok(()),
|
||||
Ok(s) => anyhow::bail!("pkexec exited with status {s}"),
|
||||
Err(e) => {
|
||||
// pkexec not available — try sudo
|
||||
let status = Command::new("sudo")
|
||||
.args(["sh", "-c", &script])
|
||||
.status()?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("cert install failed: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hosts file management ─────────────────────────────────────────────────────
|
||||
|
||||
pub fn hosts_configured() -> bool {
|
||||
let content = std::fs::read_to_string("/etc/hosts").unwrap_or_default();
|
||||
content.contains(HOSTS_MARKER_START)
|
||||
}
|
||||
|
||||
/// Add OpenFUT intercept entries to /etc/hosts (requires root).
|
||||
pub fn add_hosts_entries() -> anyhow::Result<()> {
|
||||
let current = std::fs::read_to_string("/etc/hosts")?;
|
||||
if current.contains(HOSTS_MARKER_START) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entries: String = INTERCEPT_HOSTS
|
||||
.iter()
|
||||
.map(|h| format!("127.0.0.1 {h}\n"))
|
||||
.collect();
|
||||
|
||||
let addition = format!("\n{HOSTS_MARKER_START}\n{entries}{HOSTS_MARKER_END}\n");
|
||||
let new_content = format!("{current}{addition}");
|
||||
|
||||
write_hosts(&new_content)
|
||||
}
|
||||
|
||||
/// Remove OpenFUT intercept entries from /etc/hosts (requires root).
|
||||
pub fn remove_hosts_entries() -> anyhow::Result<()> {
|
||||
let current = std::fs::read_to_string("/etc/hosts")?;
|
||||
if !current.contains(HOSTS_MARKER_START) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
let mut skip = false;
|
||||
for line in current.lines() {
|
||||
if line.trim() == HOSTS_MARKER_START {
|
||||
skip = true;
|
||||
continue;
|
||||
}
|
||||
if line.trim() == HOSTS_MARKER_END {
|
||||
skip = false;
|
||||
continue;
|
||||
}
|
||||
if !skip {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
write_hosts(out.trim_end())
|
||||
}
|
||||
|
||||
fn write_hosts(content: &str) -> anyhow::Result<()> {
|
||||
// Write to a temp file, then mv into place with elevated privileges
|
||||
let tmp = "/tmp/openfut_hosts_tmp";
|
||||
std::fs::write(tmp, content)?;
|
||||
|
||||
let script = format!("mv '{tmp}' /etc/hosts && chmod 644 /etc/hosts");
|
||||
|
||||
let status = Command::new("pkexec").args(["sh", "-c", &script]).status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok(()),
|
||||
_ => {
|
||||
let s = Command::new("sudo")
|
||||
.args(["sh", "-c", &script])
|
||||
.status()?;
|
||||
if s.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("failed to write /etc/hosts")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user