Files
openfut-launcher/src/app.rs
T
funman300 d619c992c1 feat(launcher): one-click client arming + modular preflight/services
Add a GUI "Arm client" button that reproduces client_arm.sh in a single
pkexec batch: kernel.yama.ptrace_scope=0, DNAT of EA's hardcoded redirector
IP to the OpenFUT server (+ MASQUERADE reply path), and /etc/hosts rewrites
for every dead EA hostname (removing foreign shadow lines first, so glibc's
first-match resolution can't land on a stale loopback entry). All steps are
idempotent (delete-then-add) and injection-safe: config values are charset-
validated and rejected on a surprising character, never shell-escaped. arm()
returns the concrete change list, which the button logs line-by-line and
echoes as an inline pass/fail status on the pre-launch tab (no tab jump, no
reuse of the local-services toast).

This necessarily lands the surrounding launcher modularization the arm
feature is built on, extracted from the former monolithic app.rs/process.rs:
- preflight: advisory pre-launch checks (ptrace, redirector DNAT, hostnames,
  backend reachability) that colour rows but never block Launch
- local_services: launcher-owned LSX/autopatch child processes
- game_launch, account_sync, health, netcheck helpers
- openfut-common: dependency-free shared server-destination/port mapping,
  used by both the launcher and (separately) openfut_hook.dll

openfut-hook RE changes are intentionally left uncommitted (separate concern).
fmt + clippy -D warnings clean; 46 tests pass.
2026-08-12 17:58:48 +00:00

1096 lines
41 KiB
Rust

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<Mutex<LogBuffer>>,
active_tab: Tab,
log_follow: bool,
// Setup state
hook_deployed: bool,
cert_path: Option<std::path::PathBuf>,
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<Vec<preflight::Check>>,
/// 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)
}
}