Files
openfut-launcher/src/app.rs
T
funman300 cf515f5584 fix(launcher): continuous vsync-paced present for stable VRR
The 60fps cap still left 16ms gaps with no present; on windowed G-Sync/FreeSync
DWM keeps moving the window in and out of the VRR path across those gaps and the
refresh rate swings, which the panel shows as flicker. Render continuously
(request_repaint every frame) with vsync on so the window stays continuously in
VRR at the display's own variable refresh.
2026-08-20 19:38:42 +00:00

2207 lines
89 KiB
Rust

use std::sync::Arc;
use parking_lot::Mutex;
use egui::{Color32, RichText, ScrollArea, Ui, Vec2};
use crate::theme::{self, Status};
use crate::{
account_monitor::AccountMonitor, config::LauncherConfig, game_launch, health::HealthMonitor,
launch, logs::LogBuffer, netcheck, preflight, setup,
};
#[derive(Clone, Copy, PartialEq)]
enum Tab {
/// Guided first-run flow: connect, claim an account, launch.
Welcome,
Dashboard,
Logs,
Setup,
Settings,
}
impl Tab {
/// The tab shown on startup.
///
/// A fresh install opens on [`Tab::Welcome`], because the Dashboard's honest
/// rendering of an unconfigured launcher is a column of warnings pointing at
/// other tabs — accurate, and useless as a first impression. An optional
/// `OPENFUT_LAUNCHER_TAB` env var (welcome|dashboard|logs|setup|settings)
/// overrides it, for screenshotting a specific tab without clicking.
fn default_active(config: &LauncherConfig) -> Tab {
match std::env::var("OPENFUT_LAUNCHER_TAB")
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"welcome" => Tab::Welcome,
"dashboard" => Tab::Dashboard,
"logs" => Tab::Logs,
"setup" => Tab::Setup,
// `config` stays accepted: it is what every existing screenshot
// script and note passes.
"settings" | "config" => Tab::Settings,
_ if config.needs_onboarding() => Tab::Welcome,
_ => Tab::Dashboard,
}
}
}
/// 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,
/// Read-only background poller for the account summary ("Your Club" card).
account: AccountMonitor,
/// 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)>,
/// Result of the last "Create account" / "Refresh from server" click on the
/// Welcome flow.
account_message: Option<(bool, String)>,
// FIFA 17 local companion services + the launch sequence that drives them.
// The controller owns the services so the launch sequence and the Advanced
// panel act on the same objects; a second copy of "is it running" is how a UI
// ends up claiming Ready over a dead process.
controller: launch::Controller,
local_services_message: Option<(bool, String)>,
/// Services the user asked to restart, waiting for their stop to finish.
restart_queue: Vec<crate::local_services::Service>,
/// Whether the Advanced / Diagnostics section is expanded.
advanced_open: bool,
/// Whether the failure card is showing implementation detail.
show_failure_details: bool,
/// Result of the last manual "Prepare client" click, shown inline where the
/// user acted.
arm_status: Option<(bool, String)>,
/// Capabilities verified for the *current* FIFA process (shared with the
/// autopatch stdout reader). Reset at each launch so a new FIFA process never
/// inherits a previous launch's capability.
fifa17_caps: Arc<Mutex<crate::fifa17_capability::Fifa17ClientCapabilities>>,
}
impl LauncherApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
crate::theme::install(&cc.egui_ctx);
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());
let account = AccountMonitor::new();
account.set_target(config.account_target());
// Decided before `config` moves into the struct: a fresh install opens
// on the guided flow, a configured one on the dashboard.
let active_tab = Tab::default_active(&config);
let game_logs = Arc::new(Mutex::new(LogBuffer::new()));
let controller = launch::Controller::new(Arc::clone(&game_logs));
let fifa17_caps: Arc<Mutex<crate::fifa17_capability::Fifa17ClientCapabilities>> =
Arc::new(Mutex::new(Default::default()));
// Look at the world once at startup, on a worker thread. Without this the
// status rows would honestly say "Not checked yet" until the user pressed
// something — accurate, and a poor answer to "am I ready?".
controller.refresh(&config, &game_logs, &fifa17_caps);
Self {
config,
config_dirty: false,
health,
account,
game_logs,
active_tab,
log_follow: true,
hook_deployed,
cert_path,
setup_message: None,
test_message: None,
account_message: None,
controller,
local_services_message: None,
restart_queue: Vec::new(),
advanced_open: false,
show_failure_details: false,
arm_status: None,
fifa17_caps,
}
}
/// Re-point the background monitors whenever the server address may have
/// changed. Both the health probe and the account poller track the config.
fn refresh_health_target(&self) {
self.health.set_target(self.config.health_target());
self.account.set_target(self.config.account_target());
}
/// Write `openfut.cfg` from the CURRENT settings, so the injected hook sends
/// the game where the UI says it does.
///
/// Everything else in this launcher — health pill, account card, preflight —
/// reads the in-memory config, but the game only ever sees this file. Until
/// this ran at launch time, changing the server in settings left FIFA talking
/// to the previous host while every panel showed the new one online.
fn write_hook_config(&self) -> Result<(), String> {
let contents = self.config.hook_cfg_contents()?;
setup::update_hook_config(std::path::Path::new(&self.config.fifa_game_dir), &contents)
.map_err(|e| {
format!(
"cannot write {} in {}: {e}",
setup::HOOK_CFG_FILE,
self.config.fifa_game_dir
)
})
}
/// Claim the account the server is configured for, and adopt it locally.
///
/// The launcher never invents a persona. It asks the server who it serves
/// (`account_sync::discover`) and stores the answer, so the identity the game
/// authenticates with is by construction the identity the server expects.
fn create_account(&mut self) {
match crate::account_sync::discover(&self.config) {
Ok(found) => {
self.config.fut_persona_id = found.persona_id;
self.config.fut_persona_name = found.persona_name.clone();
self.config.save();
self.config_dirty = false;
self.refresh_health_target();
let club = if found.club_name.trim().is_empty() {
found.persona_name.clone()
} else {
found.club_name.clone()
};
self.account_message = Some((
true,
format!(
"Signed in as {} · {} · {} coins",
found.persona_name,
club,
thousands_i64(found.coins)
),
));
self.game_logs.lock().push(format!(
"[launcher] account claimed from server: {}/{}",
found.persona_id, found.persona_name
));
}
Err(message) => self.account_message = Some((false, message)),
}
}
/// The guided first-run flow: connect, claim an account, launch.
///
/// Three steps in the order a new install must satisfy them, each showing its
/// own live state so the user is never asked to remember which tab held what.
fn ui_welcome(&mut self, ui: &mut Ui) {
let health = self.health.snapshot();
let server_ok = self.config.validate_server().is_ok();
let account_ok = self.config.account_configured();
self.hook_deployed =
setup::hook_dll_deployed(std::path::Path::new(&self.config.fifa_game_dir));
let launch_ready = self.config.validate_launch_config().is_ok() && self.hook_deployed;
ui.heading("Welcome to OpenFUT");
ui.add_space(4.0);
ui.label(
RichText::new(
"Three steps to get playing. Everything here can be changed later in \
Settings.",
)
.color(theme::TEXT_WEAK),
);
ui.add_space(16.0);
// ── Step 1 · the server ───────────────────────────────────────────────
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
step_header(ui, 1, "Connect to your OpenFUT server", server_ok);
ui.label(
RichText::new(
"The IP or hostname of the machine running OpenFUT. FIFA's EA \
traffic is redirected there.",
)
.color(theme::TEXT_WEAK),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Server:");
if 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(240.0),
)
.changed()
{
self.config_dirty = true;
self.test_message = None;
self.account_message = None;
self.refresh_health_target();
}
if ui
.add_enabled(self.config_dirty, egui::Button::new("Save"))
.clicked()
{
self.save_settings();
}
if ui
.add_enabled(server_ok, egui::Button::new("Test"))
.clicked()
{
let outcome = netcheck::test_connection(&self.config.server_config());
self.test_message = Some((outcome.ok, outcome.message));
}
});
ui.add_space(6.0);
match health.reachable {
Some(true) => status_text(ui, Status::Ok, &health.detail),
Some(false) => status_text(ui, Status::Error, &health.detail),
None if server_ok => status_text(ui, Status::Busy, "Checking…"),
None => status_text(ui, Status::Idle, "No server set yet"),
}
if let Some((ok, msg)) = &self.test_message {
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
}
});
ui.add_space(14.0);
// ── Step 2 · the account ──────────────────────────────────────────────
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
step_header(ui, 2, "Your account", account_ok);
ui.label(
RichText::new(
"Your club lives on the server. The launcher asks the server which \
account it serves and signs you in to it — there is nothing to \
make up and no password to choose.",
)
.color(theme::TEXT_WEAK),
);
ui.add_space(8.0);
if account_ok {
egui::Grid::new("welcome_account_grid")
.num_columns(2)
.spacing([18.0, 8.0])
.show(ui, |ui| {
ui.label(RichText::new("Signed in as").color(theme::TEXT_WEAK));
ui.label(RichText::new(&self.config.fut_persona_name).strong());
ui.end_row();
ui.label(RichText::new("Persona ID").color(theme::TEXT_WEAK));
ui.monospace(self.config.fut_persona_id.to_string());
ui.end_row();
});
ui.add_space(8.0);
if ui
.add_enabled(server_ok, egui::Button::new("Refresh from server"))
.clicked()
{
self.create_account();
}
} else {
// Gated on the address being usable, NOT on the health pill: that
// pill probes the HTTPS port, while claiming an account talks to
// the account port. Gating on the wrong port would disable this
// button on a server that answers it perfectly well, so let the
// request itself report the truth.
let why = (!server_ok).then_some("Set a server address first.");
if ui
.add_enabled(
why.is_none(),
egui::Button::new(
RichText::new("Create my account").color(theme::ON_ACCENT),
)
.fill(theme::ACCENT)
.min_size(Vec2::new(180.0, 34.0)),
)
.clicked()
{
self.create_account();
}
if let Some(why) = why {
ui.add_space(6.0);
ui.colored_label(theme::TEXT_FAINT, why);
}
}
if let Some((ok, msg)) = &self.account_message {
ui.add_space(6.0);
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
}
});
ui.add_space(14.0);
// ── Step 3 · the game ─────────────────────────────────────────────────
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
step_header(ui, 3, "Connect FIFA to OpenFUT", launch_ready);
ui.label(
RichText::new(
"FIFA needs the network hook deployed into its game folder, and the \
launcher needs to know how to start it.",
)
.color(theme::TEXT_WEAK),
);
ui.add_space(8.0);
egui::Grid::new("welcome_game_grid")
.num_columns(2)
.spacing([18.0, 8.0])
.show(ui, |ui| {
ui.label(RichText::new("Network hook").color(theme::TEXT_WEAK));
if self.hook_deployed {
status_text(ui, Status::Ok, "Deployed");
} else {
status_text(ui, Status::Warn, "Not deployed");
}
ui.end_row();
ui.label(RichText::new("Game").color(theme::TEXT_WEAK));
match self.config.validate_launch_config() {
Ok(()) => status_text(ui, Status::Ok, "Configured"),
Err(_) => status_text(ui, Status::Warn, "Not configured"),
}
ui.end_row();
});
ui.add_space(10.0);
ui.horizontal(|ui| {
if !self.hook_deployed && ui.button("Open Setup").clicked() {
self.active_tab = Tab::Setup;
}
if ui.button("Open Settings").clicked() {
self.active_tab = Tab::Settings;
}
});
if let Err(message) = self.config.validate_launch_config() {
ui.add_space(6.0);
ui.colored_label(theme::TEXT_FAINT, message);
}
});
ui.add_space(18.0);
// ── The payoff ────────────────────────────────────────────────────────
let cta_w = ui.available_width().min(380.0);
if ui
.add_enabled(
launch_ready,
egui::Button::new(
RichText::new("▶ Start Services & Launch Game")
.size(15.0)
.color(theme::ON_ACCENT),
)
.fill(theme::ACCENT)
.min_size(Vec2::new(cta_w, 46.0))
.rounding(egui::Rounding::same(10.0)),
)
.clicked()
{
self.launch_game();
}
ui.add_space(8.0);
if ui
.add(
egui::Button::new(RichText::new("Skip to dashboard").color(theme::TEXT_WEAK))
.frame(false),
)
.clicked()
{
self.active_tab = Tab::Dashboard;
}
}
/// Persist the config and, when the hook is already deployed, push the new
/// server address into the file the game reads. Saving settings that the game
/// then ignores is the failure this exists to prevent.
fn save_settings(&mut self) {
self.config.save();
self.config_dirty = false;
self.refresh_health_target();
if self.hook_deployed {
match self.write_hook_config() {
Ok(()) => {
self.setup_message = Some((
true,
format!(
"Saved — the hook now redirects FIFA to {}.",
self.config.openfut_server_host
),
))
}
Err(e) => self.setup_message = Some((false, e)),
}
} else {
self.setup_message = Some((true, "Saved.".to_string()));
}
}
// ── UI sections ───────────────────────────────────────────────────────────
fn ui_dashboard(&mut self, ui: &mut Ui) {
let server_ok = self.config.validate_server().is_ok();
let health = self.health.snapshot();
let account = self.account.snapshot();
// ── Your Club card ────────────────────────────────────────────────────
let account_ok = self.config.account_configured();
let mut create_clicked = false;
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
account_card(ui, &account);
// No account yet: the dashboard states the fix and offers it here,
// rather than naming another tab.
if !account_ok {
ui.add_space(10.0);
create_clicked = ui
.add_enabled(
server_ok,
egui::Button::new(
RichText::new("Create my account").color(theme::ON_ACCENT),
)
.fill(theme::ACCENT),
)
.clicked();
if !server_ok {
ui.label(
RichText::new("Set a server address in Settings first.")
.color(theme::TEXT_FAINT)
.small(),
);
}
if let Some((ok, msg)) = &self.account_message {
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
}
}
});
if create_clicked {
self.create_account();
}
ui.add_space(14.0);
// ── The launch surface ────────────────────────────────────────────────
// One card, one button. The individual moving parts — LSX, autopatch,
// client preparation, the pre-launch checklist — are OpenFUT's internal
// launch order, not a user's job; they live under Advanced.
let snapshot = self.controller.snapshot();
let phase = snapshot.phase;
let server = self.server_readiness(&health);
let integration = launch::client_integration(&snapshot);
let (services, services_label) = self.services_readiness(phase);
let (hook, hook_label) = self.hook_readiness();
let overall = launch::overall(phase, server, integration, services, hook);
let launch_config = self.config.validate_launch_config();
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
card_header(ui, "FIFA 17", Some(readiness_pill(overall, phase)));
egui::Grid::new("launch_status_grid")
.num_columns(2)
.spacing([18.0, 9.0])
.min_col_width(150.0)
.show(ui, |ui| {
ui.label(RichText::new("OpenFUT server").color(theme::TEXT_WEAK));
let server_label = if self.config.openfut_server_host.trim().is_empty() {
"Not configured".to_string()
} else {
match health.reachable {
Some(true) => {
format!("Connected · {}", self.config.openfut_server_host)
}
Some(false) => {
format!("Unreachable · {}", self.config.openfut_server_host)
}
None => "Connecting…".to_string(),
}
};
status_text(ui, readiness_status(server), &server_label);
ui.end_row();
ui.label(RichText::new("Game files").color(theme::TEXT_WEAK));
status_text(
ui,
readiness_status(integration),
match integration {
launch::Readiness::Ready => "Ready",
launch::Readiness::Busy => "Preparing…",
launch::Readiness::Attention => "Needs attention",
// Never green for "we have not looked".
launch::Readiness::Unknown => "Not checked yet",
},
);
ui.end_row();
ui.label(RichText::new("Background helpers").color(theme::TEXT_WEAK));
status_text(ui, readiness_status(services), &services_label);
ui.end_row();
ui.label(RichText::new("Game patch").color(theme::TEXT_WEAK));
status_text(ui, readiness_status(hook), &hook_label);
ui.end_row();
});
ui.add_space(16.0);
// ── The one button ───────────────────────────────────────────────
let (label, enabled) = match phase {
launch::Phase::Idle => ("▶ Launch FIFA 17", launch_config.is_ok()),
launch::Phase::Checking => ("◌ Checking…", false),
launch::Phase::PreparingClient => ("◌ Preparing FIFA 17…", false),
launch::Phase::StartingServices => ("◌ Starting local services…", false),
launch::Phase::Validating => ("◌ Validating…", false),
launch::Phase::Launching => ("◌ Starting FIFA 17…", false),
launch::Phase::Running => ("● FIFA 17 Running", false),
launch::Phase::Failed => ("Retry Launch", launch_config.is_ok()),
};
let cta_w = ui.available_width().min(380.0);
if ui
.add_enabled(
enabled,
egui::Button::new(RichText::new(label).size(15.0).color(theme::ON_ACCENT))
.fill(if phase == launch::Phase::Failed {
theme::WARN
} else {
theme::ACCENT
})
.min_size(Vec2::new(cta_w, 46.0))
.rounding(egui::Rounding::same(10.0)),
)
.clicked()
{
self.launch_game();
}
// A configuration gap is the one thing Launch cannot repair, so it is
// the one thing that disables the button.
if let Err(message) = &launch_config {
ui.add_space(6.0);
ui.colored_label(theme::WARN, message);
}
if phase == launch::Phase::Failed {
ui.add_space(12.0);
self.ui_launch_failure(ui, &snapshot);
}
ui.add_space(10.0);
let mut open = self.advanced_open;
let header = egui::CollapsingHeader::new(
RichText::new("Advanced / Diagnostics").color(theme::TEXT_WEAK),
)
.open(Some(open))
.show(ui, |ui| self.ui_advanced(ui));
if header.header_response.clicked() {
open = !open;
}
self.advanced_open = open;
});
}
/// The failure card: the high-level reason first, then which steps passed and
/// which did not. Implementation output stays behind "View details".
fn ui_launch_failure(&mut self, ui: &mut Ui, snapshot: &launch::LaunchState) {
ui.colored_label(
theme::ERROR,
RichText::new("Unable to launch FIFA 17").strong(),
);
if let Some(reason) = &snapshot.failure {
ui.colored_label(theme::ERROR, reason);
}
ui.add_space(8.0);
for (step, outcome) in &snapshot.steps {
let (mark, color) = match outcome {
// The bundled font has no ✓/✕, so use the dot the rest of the UI
// already uses; colour carries the verdict.
launch::Outcome::Done(_) => (Status::Ok.glyph(), theme::SUCCESS),
launch::Outcome::Skipped(_) => (Status::Ok.glyph(), theme::TEXT_WEAK),
launch::Outcome::Failed(_) => (Status::Error.glyph(), theme::ERROR),
};
ui.horizontal(|ui| {
ui.colored_label(color, mark);
ui.colored_label(color, step.label());
ui.label(
RichText::new(outcome.detail())
.color(theme::TEXT_FAINT)
.small(),
);
});
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Retry").clicked() {
self.launch_game();
}
let details_label = if self.show_failure_details {
"Hide details"
} else {
"View details"
};
if ui.button(details_label).clicked() {
self.show_failure_details = !self.show_failure_details;
}
});
if self.show_failure_details {
ui.add_space(8.0);
if let Some(checks) = &snapshot.checks {
check_list(ui, checks);
}
let runtimes = self.observe_services();
for (service, runtime) in runtimes {
let mut facts = vec![if runtime.running {
"running".to_string()
} else {
"stopped".to_string()
}];
if let Some(pid) = runtime.pid {
facts.push(format!("pid {pid}"));
}
if runtime.running && !runtime.started_by_launcher {
facts.push("started outside this launcher".into());
}
if let Some(detail) = &runtime.detail {
facts.push(detail.clone());
}
ui.label(
RichText::new(format!("{}: {}", service.label(), facts.join(" · ")))
.color(theme::TEXT_WEAK)
.monospace()
.small(),
);
}
ui.add_space(6.0);
if ui.button("Open full logs").clicked() {
self.active_tab = Tab::Logs;
}
}
}
/// Server readiness from the background health poll. The server is remote by
/// design, so this is the only dependency the launcher cannot repair.
fn server_readiness(&self, health: &crate::health::HealthState) -> launch::Readiness {
if self.config.validate_server().is_err() {
return launch::Readiness::Attention;
}
match health.reachable {
Some(true) => launch::Readiness::Ready,
Some(false) => launch::Readiness::Attention,
None => launch::Readiness::Unknown,
}
}
/// Local-service readiness, observed — not remembered from a button press.
fn services_readiness(&self, phase: launch::Phase) -> (launch::Readiness, String) {
if phase == launch::Phase::StartingServices {
return (launch::Readiness::Busy, "Starting…".into());
}
let runtimes = self.observe_services();
let ready = runtimes.iter().filter(|(_, r)| r.ready()).count();
let blocked = runtimes
.iter()
.any(|(_, r)| !r.running && r.detail.is_some());
match (ready, blocked) {
(_, true) => (launch::Readiness::Attention, "Blocked".into()),
(2, _) => (launch::Readiness::Ready, "Ready".into()),
// Neither stopped nor mid-start is a fault: the helpers only run
// alongside a session and Launch brings up whatever is missing. This
// used to read "Partly running", which sounds broken for what is the
// normal idle state and gave the player nothing to act on. Say what
// will happen instead.
(_, _) => (launch::Readiness::Unknown, "Start with the game".into()),
}
}
/// Hook readiness: the DLL is deployed and the address it will send FIFA to
/// matches the current settings.
fn hook_readiness(&self) -> (launch::Readiness, String) {
if !self.hook_deployed {
return (launch::Readiness::Attention, "Not deployed".into());
}
match setup::read_hook_config(std::path::Path::new(&self.config.fifa_game_dir))
.and_then(|body| openfut_common::ServerConfig::parse(&body).ok())
{
Some(d) if d == self.config.server_config() => {
(launch::Readiness::Ready, "Installed".into())
}
// Launch rewrites it, so this is not something to demand action for.
Some(_) => (
launch::Readiness::Unknown,
"Installed · Launch will update it".into(),
),
None => (launch::Readiness::Attention, "Not set up".into()),
}
}
/// Advanced / Diagnostics: every manual control the normal flow automates.
///
/// OpenFUT is actively being reverse engineered, so independent control of
/// each moving part stays available — it is just no longer the front door.
fn ui_advanced(&mut self, ui: &mut Ui) {
ui.label(
RichText::new(
"Everything here happens automatically when you press Launch. These \
controls exist for development and debugging.",
)
.color(theme::TEXT_FAINT)
.small(),
);
ui.add_space(10.0);
// ── Local services ───────────────────────────────────────────────────
ui.label(RichText::new("Local services").color(theme::TEXT).strong());
ui.label(
RichText::new(
"LSX (Origin emulator, loopback 4216) and autopatch (ProtoSSL cert-verify) \
must run on THIS machine. The Blaze/UTAS/roster responders run on the \
server.",
)
.color(theme::TEXT_FAINT)
.small(),
);
ui.add_space(6.0);
// Rendered unconditionally: the companions are workspace binaries
// resolved relative to this launcher, so there is no configuration that
// could make this panel inapplicable. A binary that is genuinely absent
// surfaces as that service's own spawn error, not as a hidden panel.
let runtimes = self.observe_services();
egui::Grid::new("advanced_services_grid")
.num_columns(4)
.spacing([12.0, 10.0])
.min_col_width(90.0)
.show(ui, |ui| {
for (service, runtime) in runtimes {
ui.label(RichText::new(service.label()).color(theme::TEXT).strong());
if self.controller.services.lock().stopping(service) {
theme::status_pill(ui, "Stopping", Status::Busy);
} else if runtime.running && runtime.started_by_launcher {
theme::status_pill(ui, "Running", Status::Ok);
} else if runtime.running {
theme::status_pill(ui, "Running (foreign)", Status::Warn);
} else if runtime.detail.is_some() {
theme::status_pill(ui, "Blocked", Status::Error);
} else {
theme::status_pill(ui, "Stopped", Status::Idle);
}
// Only ever facts the launcher established.
let mut facts = Vec::new();
if let Some(pid) = runtime.pid {
facts.push(format!("pid {pid}"));
}
if let Some(detail) = &runtime.detail {
facts.push(detail.clone());
}
ui.label(
RichText::new(if facts.is_empty() {
"".to_string()
} else {
facts.join(" · ")
})
.color(theme::TEXT_FAINT)
.small(),
);
ui.horizontal(|ui| {
if runtime.running {
if ui
.add_enabled(
runtime.started_by_launcher,
egui::Button::new("Restart"),
)
.clicked()
{
self.advanced_stop_service(service);
self.restart_queue.push(service);
}
if ui
.add_enabled(runtime.started_by_launcher, egui::Button::new("Stop"))
.on_disabled_hover_text(
"Started outside this launcher — stop it where it \
was started.",
)
.clicked()
{
self.advanced_stop_service(service);
}
} else if ui.button("Start").clicked() {
self.advanced_start_service(service);
}
});
ui.end_row();
}
});
if let Some((ok, msg)) = &self.local_services_message {
ui.add_space(6.0);
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
}
ui.add_space(14.0);
// ── Client integration ───────────────────────────────────────────────
ui.label(
RichText::new("Client integration")
.color(theme::TEXT)
.strong(),
);
egui::Grid::new("advanced_client_grid")
.num_columns(2)
.spacing([14.0, 8.0])
.min_col_width(120.0)
.show(ui, |ui| {
ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK));
if self.hook_deployed {
status_text(ui, Status::Ok, "version.dll deployed");
} else {
status_text(ui, Status::Warn, "not deployed — see the Setup tab");
}
ui.end_row();
ui.label(RichText::new("Hook target").color(theme::TEXT_WEAK));
match setup::read_hook_config(std::path::Path::new(&self.config.fifa_game_dir))
.and_then(|body| openfut_common::ServerConfig::parse(&body).ok())
{
Some(d) if d == self.config.server_config() => {
status_text(ui, Status::Ok, &d.host)
}
Some(d) => status_text(
ui,
Status::Warn,
&format!("{} — differs from Settings", d.host),
),
None => status_text(ui, Status::Idle, "no openfut.cfg deployed"),
}
ui.end_row();
});
ui.add_space(8.0);
if ui
.button("Prepare client")
.on_hover_text(
"Sets ptrace_scope, the EA-redirector DNAT and /etc/hosts in one elevated \
step (asks for your password once). Launch does this automatically when \
it is needed.",
)
.clicked()
{
self.advanced_prepare_client();
}
if let Some((ok, msg)) = &self.arm_status {
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
}
ui.add_space(14.0);
// ── Diagnostics ──────────────────────────────────────────────────────
ui.label(RichText::new("Diagnostics").color(theme::TEXT).strong());
let health = self.health.snapshot();
ui.label(
RichText::new(format!(
"Server probe: {}{}",
health.detail,
match health.last_checked {
Some(t) => format!(" ({}s ago)", t.elapsed().as_secs()),
None => String::new(),
}
))
.color(theme::TEXT_FAINT)
.small(),
);
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.button("Run pre-launch checks").clicked() {
self.refresh_launch_state();
}
if ui.button("View logs").clicked() {
self.active_tab = Tab::Logs;
}
if ui
.add_enabled(
self.config.game_profile.configured()
|| !self.config.game_launch_command.trim().is_empty(),
egui::Button::new("Launch game only"),
)
.on_hover_text(
"Starts FIFA without preparing anything. For debugging a launch that \
the sequence refuses.",
)
.clicked()
{
self.launch_game_only();
}
});
let snapshot = self.controller.snapshot();
if let Some(checks) = &snapshot.checks {
ui.add_space(6.0);
check_list(ui, checks);
if let Some(age) = snapshot.checks_age {
ui.label(
RichText::new(format!("checked {}s ago", age.elapsed().as_secs()))
.color(theme::TEXT_FAINT)
.small(),
);
}
} else {
ui.add_space(6.0);
ui.label(
RichText::new("Checks have not run yet.")
.color(theme::TEXT_FAINT)
.small(),
);
}
}
/// Observe both services once, for rendering.
fn observe_services(
&self,
) -> Vec<(
crate::local_services::Service,
crate::local_services::ServiceRuntime,
)> {
use crate::local_services::Service;
let mut supervisor = self.controller.services.lock();
[Service::Lsx, Service::Autopatch]
.into_iter()
.map(|s| {
let runtime = supervisor.observe(s);
(s, runtime)
})
.collect()
}
/// Finish any Advanced "Restart" the user asked for. Stopping is asynchronous
/// (it kills and reaps off the UI thread), so the start half has to wait for
/// the process to actually be gone rather than racing its own kill.
fn drive_restart_queue(&mut self) {
if self.restart_queue.is_empty() {
return;
}
let pending = std::mem::take(&mut self.restart_queue);
for service in pending {
let gone = {
let mut supervisor = self.controller.services.lock();
!supervisor.stopping(service) && !supervisor.observe(service).running
};
if gone {
self.advanced_start_service(service);
} else {
self.restart_queue.push(service);
}
}
}
/// Start FIFA with no preparation at all. Debug escape hatch: the launch
/// sequence refusing to start the game is usually right, and when it is
/// wrong a developer still needs to get into the client.
fn launch_game_only(&mut self) {
let result = if self.config.game_profile.configured() {
game_launch::launch(&self.config.game_profile, &self.game_logs, || {})
} else {
setup::launch_game(
&self.config.game_launch_command,
&self.config.game_launch_workdir,
Arc::clone(&self.game_logs),
|| {},
)
};
match result {
Ok(()) => {
self.local_services_message =
Some((true, "FIFA started with no preparation.".into()))
}
Err(e) => self.local_services_message = Some((false, format!("launch failed: {e}"))),
}
self.active_tab = Tab::Logs;
}
/// The one thing the normal user does. Everything the launch needs —
/// connectivity, the hook file, client preparation, LSX, autopatch, final
/// validation — is decided and performed by [`launch::Controller`], which
/// reuses whatever is already healthy.
fn launch_game(&mut self) {
if let Err(message) = self.config.validate_launch_config() {
// A configuration gap is not something a launch sequence can repair.
self.game_logs
.lock()
.push(format!("[launcher] launch blocked: {message}"));
self.local_services_message = Some((false, message));
return;
}
self.show_failure_details = false;
self.controller
.launch(&self.config, &self.game_logs, &self.fifa17_caps);
}
/// Re-observe the world without touching it (startup, and after settings
/// change). Runs on a worker thread; the checks open sockets.
fn refresh_launch_state(&self) {
self.controller
.refresh(&self.config, &self.game_logs, &self.fifa17_caps);
}
/// Start one service by hand, from Advanced. Same supervisor the launch
/// sequence uses, so the two can never disagree about what is running.
fn advanced_start_service(&mut self, service: crate::local_services::Service) {
let spec = crate::local_services::SpawnSpec {
persona_id: self.config.fut_persona_id,
persona_name: self.config.fut_persona_name.clone(),
capability: match service {
crate::local_services::Service::Autopatch => {
Some(crate::local_services::CapabilityWiring {
server_host: self.config.openfut_server_host.clone(),
account_sync_port: self.config.openfut_account_sync_port,
sink: Arc::clone(&self.fifa17_caps),
})
}
crate::local_services::Service::Lsx => None,
},
};
let result = self
.controller
.services
.lock()
.ensure_running(service, spec);
self.local_services_message = Some(match result {
Ok(crate::local_services::Ensured::Started) => {
(true, format!("{} started.", service.label()))
}
Ok(crate::local_services::Ensured::Reused) => {
(true, format!("{} was already running.", service.label()))
}
Err(e) => (false, e),
});
}
/// Stop one service by hand. A service this launcher did not start is
/// reported, never killed.
fn advanced_stop_service(&mut self, service: crate::local_services::Service) {
if service == crate::local_services::Service::Autopatch {
// The verified capability belongs to the FIFA process autopatch was
// serving; drop it when autopatch goes away.
*self.fifa17_caps.lock() = Default::default();
}
if let Err(e) = self.controller.services.lock().stop(service) {
self.local_services_message = Some((false, e));
}
}
/// Client preparation, by hand, from Advanced. Internally this is `arm`; the
/// normal flow performs it automatically and never names it.
fn advanced_prepare_client(&mut self) {
match crate::arm::arm(&self.config) {
Ok(changes) => {
for change in &changes {
self.game_logs
.lock()
.push(format!("[launcher] prepared: {change}"));
}
self.arm_status = Some((
true,
format!("Client prepared — {} change(s) applied.", changes.len()),
));
self.refresh_launch_state();
}
Err(e) => self.arm_status = Some((false, format!("Client preparation failed: {e}"))),
}
}
fn ui_logs(&mut self, ui: &mut Ui) {
let line_count = self.game_logs.lock().lines().count();
ui.horizontal(|ui| {
ui.label(RichText::new("Console").text_style(theme::text_style(theme::SUBHEADING)));
ui.label(
RichText::new(format!("{line_count} lines"))
.color(theme::TEXT_FAINT)
.small(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Clear").clicked() {
self.game_logs.lock().clear();
}
ui.checkbox(&mut self.log_follow, "Follow");
});
});
ui.add_space(8.0);
let follow = self.log_follow;
egui::Frame::none()
.fill(theme::INSET)
.stroke(egui::Stroke::new(1.0_f32, theme::BORDER))
.rounding(egui::Rounding::same(10.0))
.inner_margin(egui::Margin::same(12.0))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ScrollArea::vertical()
.auto_shrink([false, false])
.stick_to_bottom(follow)
.show(ui, |ui| {
let guard = self.game_logs.lock();
if guard.lines().next().is_none() {
ui.add_space(6.0);
ui.label(
RichText::new(
"No output yet. Launch the game or start a service to \
see logs here.",
)
.color(theme::TEXT_FAINT),
);
return;
}
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.heading("FIFA integration setup");
ui.add_space(4.0);
ui.label(
RichText::new(
"These steps route the game's EA traffic to your OpenFUT server via DLL \
injection (no hosts file changes needed).",
)
.color(theme::TEXT_WEAK),
);
ui.add_space(16.0);
// ── Server address (owned by Settings; shown here for context) ────────
// These fields used to be editable here as well as in Settings, with
// different save semantics on each copy. One owner, one behaviour.
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
card_header(ui, "Step 1 · OpenFUT server address", None);
ui.add_space(4.0);
ui.label(
RichText::new(
"The server the hook redirects FIFA's EA traffic to. Edited in \
Settings; shown here so the deployment below is unambiguous.",
)
.color(theme::TEXT_WEAK),
);
ui.add_space(8.0);
egui::Grid::new("setup_server_grid")
.num_columns(2)
.spacing([18.0, 8.0])
.show(ui, |ui| {
ui.label(RichText::new("Server").color(theme::TEXT_WEAK));
if self.config.openfut_server_host.trim().is_empty() {
status_text(ui, Status::Error, "not set");
} else {
ui.monospace(&self.config.openfut_server_host);
}
ui.end_row();
ui.label(RichText::new("Ports").color(theme::TEXT_WEAK));
ui.monospace(format!(
"https {} · blaze-redir {} · blaze-main {}",
self.config.openfut_https_port,
self.config.openfut_blaze_redirector_port,
self.config.openfut_blaze_main_port,
));
ui.end_row();
});
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button("Edit in Settings").clicked() {
self.active_tab = Tab::Settings;
}
if ui
.add_enabled(
self.config.validate_server().is_ok(),
egui::Button::new("Test Connection"),
)
.clicked()
{
let outcome = netcheck::test_connection(&self.config.server_config());
self.test_message = Some((outcome.ok, outcome.message));
}
if ui
.add_enabled(self.hook_deployed, egui::Button::new("Update hook now"))
.clicked()
{
match self.write_hook_config() {
Ok(()) => {
self.setup_message = Some((
true,
format!(
"Hook updated — FIFA will connect to {}.",
self.config.openfut_server_host
),
))
}
Err(e) => self.setup_message = Some((false, e)),
}
}
});
if let Some((ok, msg)) = &self.test_message {
let color = if *ok { theme::SUCCESS } else { theme::ERROR };
ui.colored_label(color, msg);
}
});
ui.add_space(14.0);
// ── Hook DLL deployment ───────────────────────────────────────────────
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
card_header(ui, "Step 2 · Deploy network hook DLL", None);
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(
theme::WARN,
"⚠ 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 {
status_text(ui, Status::Ok, "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 {
status_text(ui, Status::Error, "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(14.0);
// ── Cert install ──────────────────────────────────────────────────────
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
card_header(ui, "Step 3 · Install TLS certificate", None);
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(
theme::WARN,
"⚠ 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(14.0);
if let Some((ok, msg)) = &self.setup_message {
let color = if *ok { theme::SUCCESS } else { theme::ERROR };
ui.colored_label(color, msg);
}
}
fn ui_settings(&mut self, ui: &mut Ui) {
ui.heading("Settings");
ui.add_space(4.0);
ui.label(
RichText::new("Everything the launcher needs to reach your server and start the game.")
.color(theme::TEXT_WEAK),
);
ui.add_space(16.0);
let mut changed = false;
let mut server_changed = false;
// Whether the deployed hook disagrees with these settings. Enables Save
// even with nothing edited: the message below says "Save to update it",
// so the button has to actually be clickable.
let hook_drift = setup::read_hook_config(std::path::Path::new(&self.config.fifa_game_dir))
.and_then(|body| openfut_common::ServerConfig::parse(&body).ok())
.is_some_and(|deployed| deployed != self.config.server_config());
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
card_header(ui, "Game", None);
egui::Grid::new("config_game_grid")
.num_columns(2)
.spacing([14.0, 9.0])
.min_col_width(150.0)
.show(ui, |ui| {
ui.label(RichText::new("Launch command:").color(theme::TEXT_WEAK));
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(RichText::new("Launch workdir:").color(theme::TEXT_WEAK));
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(RichText::new("FIFA game dir:").color(theme::TEXT_WEAK));
changed |= ui
.text_edit_singleline(&mut self.config.fifa_game_dir)
.changed();
ui.end_row();
});
});
ui.add_space(14.0);
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
card_header(ui, "OpenFUT server", None);
ui.label(
RichText::new(
"Where this launcher — and the game — connect. This is the only \
place the server address is edited.",
)
.color(theme::TEXT_WEAK),
);
ui.add_space(8.0);
egui::Grid::new("config_server_grid")
.num_columns(2)
.spacing([14.0, 9.0])
.min_col_width(150.0)
.show(ui, |ui| {
ui.label(RichText::new("Server host:").color(theme::TEXT_WEAK));
if ui
.add(
egui::TextEdit::singleline(&mut self.config.openfut_server_host)
.hint_text("e.g. 10.10.0.120 or fut.mylan.home"),
)
.changed()
{
changed = true;
server_changed = true;
}
ui.end_row();
ui.label(RichText::new("HTTPS port:").color(theme::TEXT_WEAK));
let mut p = self.config.openfut_https_port.to_string();
if ui
.add(egui::TextEdit::singleline(&mut p).desired_width(90.0))
.changed()
{
if let Ok(v) = p.parse() {
self.config.openfut_https_port = v;
}
changed = true;
server_changed = true;
}
ui.end_row();
ui.label(RichText::new("Blaze redirector port:").color(theme::TEXT_WEAK));
let mut p = self.config.openfut_blaze_redirector_port.to_string();
if ui
.add(egui::TextEdit::singleline(&mut p).desired_width(90.0))
.changed()
{
if let Ok(v) = p.parse() {
self.config.openfut_blaze_redirector_port = v;
}
changed = true;
server_changed = true;
}
ui.end_row();
ui.label(RichText::new("Blaze main port:").color(theme::TEXT_WEAK));
let mut p = self.config.openfut_blaze_main_port.to_string();
if ui
.add(egui::TextEdit::singleline(&mut p).desired_width(90.0))
.changed()
{
if let Ok(v) = p.parse() {
self.config.openfut_blaze_main_port = v;
}
changed = true;
server_changed = true;
}
ui.end_row();
ui.label(RichText::new("Account/UTAS port:").color(theme::TEXT_WEAK));
let mut p = self.config.openfut_account_sync_port.to_string();
if ui
.add(egui::TextEdit::singleline(&mut p).desired_width(90.0))
.changed()
{
if let Ok(v) = p.parse() {
self.config.openfut_account_sync_port = v;
}
changed = true;
server_changed = true;
}
ui.end_row();
});
ui.add_space(10.0);
if ui
.add_enabled(
self.config.validate_server().is_ok(),
egui::Button::new("Test Connection"),
)
.clicked()
{
let outcome = netcheck::test_connection(&self.config.server_config());
self.test_message = Some((outcome.ok, outcome.message));
}
if let Some((ok, msg)) = &self.test_message {
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
}
// What the GAME will read, which is a different fact from what these
// fields say until the config is saved or the game is launched.
ui.add_space(8.0);
let deployed =
setup::read_hook_config(std::path::Path::new(&self.config.fifa_game_dir))
.and_then(|body| openfut_common::ServerConfig::parse(&body).ok());
match deployed {
Some(d) if d == self.config.server_config() => {
status_text(ui, Status::Ok, &format!("FIFA's hook points at {}", d.host))
}
Some(d) => status_text(
ui,
Status::Warn,
&format!("FIFA's hook still points at {} — Save to update it", d.host),
),
None => status_text(
ui,
Status::Idle,
"FIFA's hook is not deployed yet (Setup tab)",
),
}
});
ui.add_space(14.0);
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
card_header(ui, "EA / Origin account", None);
egui::Grid::new("config_account_grid")
.num_columns(2)
.spacing([14.0, 9.0])
.min_col_width(150.0)
.show(ui, |ui| {
ui.label(RichText::new("Persona ID:").color(theme::TEXT_WEAK));
changed |= ui
.add(egui::DragValue::new(&mut self.config.fut_persona_id).speed(1))
.changed();
ui.end_row();
ui.label(RichText::new("Persona name:").color(theme::TEXT_WEAK));
changed |= ui
.add(
egui::TextEdit::singleline(&mut self.config.fut_persona_name)
.hint_text("EA/Origin display name"),
)
.changed();
ui.end_row();
ui.label(RichText::new("Account-bar level:").color(theme::TEXT_WEAK));
changed |= ui
.add(
egui::DragValue::new(&mut self.config.fut_account_level)
.range(1..=u32::MAX),
)
.changed();
ui.end_row();
ui.label(RichText::new("Account-bar XP:").color(theme::TEXT_WEAK));
ui.horizontal(|ui| {
changed |= ui
.add(egui::DragValue::new(
&mut self.config.fut_account_experience,
))
.changed();
ui.label(RichText::new("/").color(theme::TEXT_FAINT));
changed |= ui
.add(egui::DragValue::new(
&mut self.config.fut_account_experience_max,
))
.changed();
});
ui.end_row();
ui.label(RichText::new("Account-bar funds:").color(theme::TEXT_WEAK));
ui.horizontal(|ui| {
changed |= ui
.add(egui::DragValue::new(&mut self.config.fut_account_funds))
.changed();
ui.label(RichText::new("/ cap").color(theme::TEXT_FAINT));
changed |= ui
.add(egui::DragValue::new(&mut self.config.fut_account_funds_cap))
.changed();
});
ui.end_row();
ui.label(RichText::new("Captures dir:").color(theme::TEXT_WEAK));
changed |= ui
.text_edit_singleline(&mut self.config.bridge_captures_dir)
.changed();
ui.end_row();
});
});
ui.add_space(14.0);
theme::card().show(ui, |ui| {
ui.set_width(ui.available_width());
card_header(ui, "Hook DLL", None);
egui::Grid::new("config_hook_grid")
.num_columns(2)
.spacing([14.0, 9.0])
.min_col_width(150.0)
.show(ui, |ui| {
ui.label(RichText::new("Hook DLL path:").color(theme::TEXT_WEAK));
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 || hook_drift,
egui::Button::new(RichText::new("Save").color(theme::ON_ACCENT))
.fill(theme::ACCENT),
)
.clicked()
{
// Not just `config.save()`: saving must also reach the file the
// game reads, or the two disagree until the next launch.
self.save_settings();
}
if ui.button("Reset to defaults").clicked() {
self.config = LauncherConfig::default();
self.config_dirty = true;
self.refresh_health_target();
}
});
if let Some((ok, msg)) = &self.setup_message {
ui.add_space(6.0);
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
}
}
/// The branded top bar: wordmark badge, product name, and a live server
/// status pill on the right.
fn ui_header(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
// Wordmark badge — an accent tile with the OpenFUT monogram.
egui::Frame::none()
.fill(theme::ACCENT)
.rounding(egui::Rounding::same(9.0))
.inner_margin(egui::Margin::symmetric(11.0, 6.0))
.show(ui, |ui| {
ui.label(
RichText::new("OF")
.text_style(theme::text_style(theme::HERO))
.size(22.0)
.color(theme::ON_ACCENT),
);
});
ui.add_space(12.0);
ui.vertical(|ui| {
ui.add_space(1.0);
ui.label(
RichText::new("OpenFUT")
.text_style(theme::text_style(theme::HERO))
.size(24.0)
.color(theme::TEXT),
);
ui.label(
RichText::new("FIFA 17 Ultimate Team · Launcher")
.color(theme::TEXT_FAINT)
.small(),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let health = self.health.snapshot();
let (status, label) = match health.reachable {
None => (Status::Unknown, "Server unknown"),
Some(true) => (Status::Ok, "Server online"),
Some(false) => (Status::Error, "Server offline"),
};
theme::status_pill(ui, label, status);
});
});
}
/// One row in the left navigation rail. Draws its own background/active
/// accent so the rail reads as a branded nav, not a plain button column.
fn nav_item(&mut self, ui: &mut Ui, tab: Tab, glyph: &str, label: &str) {
let active = self.active_tab == tab;
let full_w = ui.available_width();
let (rect, resp) = ui.allocate_exact_size(egui::vec2(full_w, 40.0), egui::Sense::click());
if resp.clicked() {
self.active_tab = tab;
}
let bg = if active {
theme::ACCENT_WASH
} else if resp.hovered() {
theme::SURFACE_HOVER
} else {
Color32::TRANSPARENT
};
let painter = ui.painter();
painter.rect_filled(rect, egui::Rounding::same(8.0), bg);
if active {
let bar = egui::Rect::from_min_size(
rect.min + egui::vec2(0.0, 8.0),
egui::vec2(3.0, rect.height() - 16.0),
);
painter.rect_filled(bar, egui::Rounding::same(2.0), theme::ACCENT);
}
let text_color = if active {
theme::TEXT
} else {
theme::TEXT_WEAK
};
let icon_color = if active {
theme::ACCENT_HOVER
} else {
theme::TEXT_FAINT
};
let mid = rect.left_center();
painter.text(
mid + egui::vec2(16.0, 0.0),
egui::Align2::LEFT_CENTER,
glyph,
egui::FontId::proportional(15.0),
icon_color,
);
painter.text(
mid + egui::vec2(42.0, 0.0),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(14.5),
text_color,
);
}
}
impl eframe::App for LauncherApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Render continuously (present every vsync) instead of reactively. egui
// normally idles at a low, bursty repaint rate; on a G-Sync / FreeSync
// (VRR) display a windowed app that presents in bursts with idle gaps
// makes DWM keep moving the window in and out of the VRR path and the
// refresh rate swing — which the panel shows as flicker. Presenting on
// every frame keeps the window continuously in VRR at the display's own
// (variable) refresh, which is stable. vsync (on by default) paces this to
// the monitor rather than spinning uncapped.
ctx.request_repaint();
self.drive_restart_queue();
egui::TopBottomPanel::top("header")
.frame(
egui::Frame::none()
.fill(theme::BG_DEEP)
.inner_margin(egui::Margin::symmetric(22.0, 14.0))
.stroke(egui::Stroke::new(1.0_f32, theme::BORDER)),
)
.show(ctx, |ui| self.ui_header(ui));
egui::SidePanel::left("nav")
.resizable(false)
.exact_width(198.0)
.frame(
egui::Frame::none()
.fill(theme::BG_DEEP)
.inner_margin(egui::Margin::symmetric(12.0, 16.0))
.stroke(egui::Stroke::new(1.0_f32, theme::BORDER)),
)
.show(ctx, |ui| {
// The guided flow earns a rail slot only while it is unfinished —
// or while the user is standing on it, so clicking away is never
// a one-way door.
if self.config.needs_onboarding() || self.active_tab == Tab::Welcome {
self.nav_item(ui, Tab::Welcome, "", "Get started");
ui.add_space(4.0);
}
self.nav_item(ui, Tab::Dashboard, "🏠", "Dashboard");
ui.add_space(4.0);
self.nav_item(ui, Tab::Logs, "", "Logs");
ui.add_space(4.0);
self.nav_item(ui, Tab::Setup, "🔧", "Setup");
ui.add_space(4.0);
self.nav_item(ui, Tab::Settings, "", "Settings");
// Version pinned to the bottom of the rail.
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
ui.add_space(2.0);
ui.label(
RichText::new(concat!("v", env!("CARGO_PKG_VERSION")))
.color(theme::TEXT_FAINT)
.small(),
);
ui.label(
RichText::new("Client-side tool")
.color(theme::TEXT_FAINT)
.small(),
);
});
});
egui::CentralPanel::default()
.frame(
egui::Frame::none()
.fill(theme::BG)
.inner_margin(egui::Margin::symmetric(24.0, 20.0)),
)
.show(ctx, |ui| {
// Logs owns a full-height console with its own scroller; every
// other tab scrolls its stacked cards.
if self.active_tab == Tab::Logs {
self.ui_logs(ui);
} else {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| match self.active_tab {
Tab::Welcome => self.ui_welcome(ui),
Tab::Dashboard => self.ui_dashboard(ui),
Tab::Setup => self.ui_setup(ui),
Tab::Settings => self.ui_settings(ui),
Tab::Logs => {}
});
}
});
}
}
/// Status pill for the launch card's headline.
fn readiness_pill(readiness: launch::Readiness, phase: launch::Phase) -> (&'static str, Status) {
if phase == launch::Phase::Running {
return ("Running", Status::Ok);
}
match readiness {
launch::Readiness::Ready => ("Ready", Status::Ok),
launch::Readiness::Busy => ("Working", Status::Busy),
launch::Readiness::Attention => ("Needs attention", Status::Warn),
// "Unknown" is deliberately not "Ready": the launcher has not looked, or
// the thing it saw is something Launch will fix on the way.
launch::Readiness::Unknown => ("Not ready", Status::Unknown),
}
}
fn readiness_status(readiness: launch::Readiness) -> Status {
match readiness {
launch::Readiness::Ready => Status::Ok,
launch::Readiness::Busy => Status::Busy,
launch::Readiness::Attention => Status::Warn,
launch::Readiness::Unknown => Status::Idle,
}
}
/// The pre-launch checklist rows, for Advanced and the failure details.
fn check_list(ui: &mut Ui, checks: &[preflight::Check]) {
// 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 findings gets ignored.
let (bad, warn) = (preflight::failures(checks), preflight::warnings(checks));
let (color, summary) = match (bad, warn) {
(0, 0) => (theme::SUCCESS, "no problems found".to_string()),
(0, w) => (
theme::WARN,
format!("{w} warning(s) — worth fixing, usually not fatal"),
),
(b, 0) => (theme::ERROR, format!("{b} problem(s) found")),
(b, w) => (theme::ERROR, format!("{b} problem(s), {w} warning(s)")),
};
ui.colored_label(color, summary);
for c in checks {
let (mark, color) = match c.state {
preflight::State::Pass => ("OK ", theme::SUCCESS),
preflight::State::Warn => ("WARN", theme::WARN),
preflight::State::Fail => ("FAIL", theme::ERROR),
// Grey, never green: "not checked" must not read as "fine".
preflight::State::Skipped => ("-- ", theme::TEXT_FAINT),
};
ui.horizontal(|ui| {
ui.colored_label(color, RichText::new(mark).monospace());
ui.colored_label(color, &c.name);
ui.label(RichText::new(&c.detail).color(theme::TEXT_WEAK));
});
}
}
fn log_line_color(line: &str) -> Color32 {
let lower = line.to_lowercase();
if lower.contains("error") || lower.contains("panic") {
theme::ERROR
} else if lower.contains("warn") {
theme::WARN
} else if lower.contains("info") {
theme::INFO
} else {
theme::TEXT
}
}
/// A card title row: bold subheading on the left, optional status pill pushed to
/// the right edge.
fn card_header(ui: &mut Ui, title: &str, pill: Option<(&str, Status)>) {
ui.horizontal(|ui| {
ui.label(
RichText::new(title)
.text_style(theme::text_style(theme::SUBHEADING))
.color(theme::TEXT),
);
if let Some((label, status)) = pill {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
theme::status_pill(ui, label, status);
});
}
});
ui.add_space(12.0);
}
/// Inline status: a coloured glyph followed by same-coloured text. Used inside
/// grids where a full pill would be too heavy.
fn status_text(ui: &mut Ui, status: Status, text: &str) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.label(
RichText::new(status.glyph())
.color(status.color())
.size(11.0),
);
ui.label(RichText::new(text).color(status.color()));
});
}
/// A numbered step title for the Welcome flow, with a tick once satisfied. The
/// number carries the ordering, so the copy never has to say "first"/"then".
fn step_header(ui: &mut Ui, number: u8, title: &str, done: bool) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
let (rect, _) = ui.allocate_exact_size(Vec2::new(24.0, 24.0), egui::Sense::hover());
let (fill, fg) = if done {
(theme::SUCCESS, theme::ON_ACCENT)
} else {
(theme::ACCENT_WASH, theme::TEXT_WEAK)
};
ui.painter().circle_filled(rect.center(), 12.0, fill);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
if done {
"".to_string()
} else {
number.to_string()
},
egui::FontId::proportional(13.0),
fg,
);
ui.label(
RichText::new(title)
.text_style(theme::text_style(theme::SUBHEADING))
.color(theme::TEXT),
);
});
ui.add_space(6.0);
}
/// The dashboard's "Your Club" card. Renders the live account summary when one
/// has been fetched, and calm, on-brand states otherwise: idle (no server),
/// loading (fetch in flight), offline/unreachable, and a loud error only for a
/// genuinely broken response. Never draws an empty/populated-looking shell.
fn account_card(ui: &mut Ui, account: &crate::account_monitor::AccountState) {
let pill = if account.summary.is_some() {
("Online", Status::Ok)
} else if !account.configured || account.unreachable() {
("Offline", Status::Idle)
} else if account.error.is_some() {
("Error", Status::Error)
} else {
("Fetching…", Status::Busy)
};
card_header(ui, "Your Club", Some(pill));
match &account.summary {
Some(summary) => account_body(ui, summary),
None => {
let (msg, color) = if !account.configured || account.unreachable() {
(
"Connect to your OpenFUT server to see your club.".to_string(),
theme::TEXT_FAINT,
)
} else if let Some(err) = &account.error {
(format!("Account unavailable — {err}"), theme::ERROR)
} else {
("Fetching account…".to_string(), theme::TEXT_WEAK)
};
ui.label(RichText::new(msg).color(color));
}
}
}
/// The populated body: club identity, the hero coin balance, an XP progress
/// bar, and a compact stat row.
fn account_body(ui: &mut Ui, s: &crate::account_sync::AccountSummary) {
// Club identity: name + abbreviation badge, with the manager beneath.
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
let name = if s.club_name.trim().is_empty() {
s.persona_name.as_str()
} else {
s.club_name.as_str()
};
ui.label(
RichText::new(name)
.text_style(theme::text_style(theme::SUBHEADING))
.color(theme::TEXT),
);
if !s.club_abbr.trim().is_empty() {
club_badge(ui, &s.club_abbr);
}
});
ui.label(
RichText::new(format!("Manager · {}", s.persona_name))
.color(theme::TEXT_FAINT)
.small(),
);
ui.add_space(16.0);
// Hero: the coin balance is the single number that matters most.
ui.label(RichText::new("COINS").color(theme::TEXT_WEAK).small());
ui.label(
RichText::new(thousands_i64(s.coins))
.color(theme::ACCENT)
.size(34.0)
.strong(),
);
ui.add_space(16.0);
// Level + XP progression.
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("Level {}", s.level))
.color(theme::TEXT)
.strong(),
);
if s.experience_max > 0 {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(format!(
"{} / {} XP",
thousands_u32(s.experience),
thousands_u32(s.experience_max)
))
.color(theme::TEXT_WEAK)
.small(),
);
});
}
});
if s.experience_max > 0 {
ui.add_space(4.0);
let frac = (s.experience as f32 / s.experience_max as f32).clamp(0.0, 1.0);
ui.add(
egui::ProgressBar::new(frac)
.desired_height(6.0)
.fill(theme::ACCENT)
.rounding(egui::Rounding::same(3.0)),
);
}
ui.add_space(14.0);
// Compact stat row for the remaining figures.
egui::Grid::new("account_stats_grid")
.num_columns(2)
.spacing([18.0, 10.0])
.show(ui, |ui| {
ui.label(RichText::new("Unopened packs").color(theme::TEXT_WEAK));
if s.unopened_packs > 0 {
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
theme::status_pill(ui, &s.unopened_packs.to_string(), Status::Warn);
});
} else {
ui.label(RichText::new("0").color(theme::TEXT));
}
ui.end_row();
ui.label(RichText::new("Account funds").color(theme::TEXT_WEAK));
let funds = if s.account_funds_cap > 0 {
format!(
"{} / {}",
thousands_u32(s.account_funds),
thousands_u32(s.account_funds_cap)
)
} else {
thousands_u32(s.account_funds)
};
ui.label(RichText::new(funds).color(theme::TEXT));
ui.end_row();
});
}
/// A small accent-washed badge for the club abbreviation (e.g. "OFC").
fn club_badge(ui: &mut Ui, abbr: &str) {
egui::Frame::none()
.fill(theme::ACCENT_WASH)
.rounding(egui::Rounding::same(6.0))
.inner_margin(egui::Margin::symmetric(8.0, 2.0))
.show(ui, |ui| {
ui.label(
RichText::new(abbr.trim().to_uppercase())
.color(theme::ACCENT_HOVER)
.strong()
.size(12.0),
);
});
}
/// Group a run of ASCII digits into thousands with commas ("29876776" →
/// "29,876,776").
fn group_thousands(digits: &str) -> String {
let bytes = digits.as_bytes();
let len = bytes.len();
let mut out = String::with_capacity(len + len / 3);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (len - i).is_multiple_of(3) {
out.push(',');
}
out.push(*b as char);
}
out
}
fn thousands_i64(n: i64) -> String {
let s = group_thousands(&n.unsigned_abs().to_string());
if n < 0 {
format!("-{s}")
} else {
s
}
}
fn thousands_u32(n: u32) -> String {
group_thousands(&n.to_string())
}