af7a5948a7
The dashboard named OpenFUT internals at a player: "Client integration", "Local services", "Hook DLL", and a "Deployed -> 10.10.0.120" value that conflates a DLL with a server address. None of it tells someone who just wants to play whether they can press Play. Rows are now Game files / Background helpers / Game patch, and the patch row states Installed rather than echoing the host it will point FIFA at. The important fix is the helper state. Two of the three cases returned labels that sound like faults for what is the NORMAL idle condition - the helpers only run alongside a session, and Launch starts whatever is missing - with "Partly running" being the worst: it reads broken and offers nothing to act on. Both collapse to "Start with the game", which says what will happen. Observed live: the dashboard showed "Partly running" while genuinely healthy, and pressing Launch brought autopatch up on its own. No behaviour change: readiness values are untouched, so the overall verdict pill and the launch gating are identical. fmt, clippy -D warnings, 75 tests clean.
2199 lines
88 KiB
Rust
2199 lines
88 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) {
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
|
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())
|
|
}
|