357501f549
Release-readiness pass on the launcher, driven by the end state "open it,
create an account, launch the game".
Fixes a silent correctness bug. `openfut.cfg` in the game dir is the only
server address the *game* can see, but it was written only by Setup's deploy
and its "Save & Update hook" button. Changing the server anywhere else left
FIFA connecting to the previous host while every panel in the launcher showed
the new one online. Now:
- `write_hook_config` reconciles the file from the live config, and runs
fail-closed before every launch, so the file and the UI cannot disagree at
the moment it matters;
- saving Settings pushes the address into the hook immediately;
- a `hook_config` preflight check reads the file back and warns, naming both
addresses, instead of leaving the drift invisible;
- Settings shows the same fact inline, and Save is enabled by drift alone —
a message saying "Save to update it" beside a disabled button is a dead end.
Account creation is now server-authoritative. `account_sync::discover` POSTs
`/openfut/account/sync` with the persona fields *omitted*, which makes the host
answer with the persona it was started with, its club, and the Core coin
balance. The launcher adopts that answer, so it never invents an identity and
the persona the game authenticates with is by construction the one the server
expects. Claiming is gated on the address being valid, NOT on the health pill:
that pill probes the HTTPS port while this talks to the account port, so gating
on it disabled the button on servers that answer it perfectly well.
UX consolidation:
- new Welcome ("Get started") tab: three numbered steps — connect, claim an
account, connect FIFA — each showing live state, ending in the launch CTA;
a fresh install opens on it and it leaves the nav rail once satisfied;
- Config renamed Settings, and made the single owner of the server address:
Setup's duplicate editors (same fields, different save semantics) are now a
read-only summary with actions;
- the dashboard offers account creation in place instead of naming a tab, and
the stale "set the host in the Setup tab" pointers are corrected.
Locks move to parking_lot per project rule (already the convention in
openfut-utas-host and openfut-identity); 47 poisoning unwraps go away.
Verified: 58 tests pass, fmt clean, clippy clean apart from one pre-existing
lint. Driven through the real UI under Xvfb as a fresh install — typed a server,
clicked Create my account, and the config on disk came back with persona
33068179/CAGE claimed from the live host; clicking Save rewrote a stale
`openfut.cfg` from host=10.10.0.99 to host=127.0.0.1.
2011 lines
80 KiB
Rust
2011 lines
80 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,
|
|
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 (client-side daemons).
|
|
lsx: crate::local_services::ManagedService,
|
|
autopatch: crate::local_services::ManagedService,
|
|
local_services_message: Option<(bool, String)>,
|
|
|
|
/// Last pre-launch check results. `None` until run — deliberately not run
|
|
/// automatically on every frame: the checks open sockets, and a 2s probe
|
|
/// on the UI thread would stall the window.
|
|
preflight: Option<Vec<preflight::Check>>,
|
|
|
|
/// Result of the last "Arm client" click, shown inline beneath the button so
|
|
/// the outcome appears where the user acted — not on another tab.
|
|
arm_status: Option<(bool, String)>,
|
|
|
|
/// Capabilities verified for the *current* FIFA process (shared with the
|
|
/// autopatch stdout reader). Reset to unknown 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);
|
|
|
|
Self {
|
|
config,
|
|
config_dirty: false,
|
|
health,
|
|
account,
|
|
game_logs: Arc::new(Mutex::new(LogBuffer::new())),
|
|
active_tab,
|
|
log_follow: true,
|
|
hook_deployed,
|
|
cert_path,
|
|
setup_message: None,
|
|
test_message: None,
|
|
account_message: None,
|
|
lsx: crate::local_services::ManagedService::default(),
|
|
autopatch: crate::local_services::ManagedService::default(),
|
|
local_services_message: None,
|
|
preflight: None,
|
|
arm_status: None,
|
|
fifa17_caps: Arc::new(Mutex::new(Default::default())),
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
|
|
// ── Server status card ────────────────────────────────────────────────
|
|
theme::card().show(ui, |ui| {
|
|
ui.set_width(ui.available_width());
|
|
let (status, pill) = match health.reachable {
|
|
None => (Status::Unknown, "Unknown"),
|
|
Some(true) => (Status::Ok, "Online"),
|
|
Some(false) => (Status::Error, "Unreachable"),
|
|
};
|
|
card_header(ui, "Server status", Some((pill, status)));
|
|
|
|
egui::Grid::new("health_grid")
|
|
.num_columns(2)
|
|
.spacing([18.0, 8.0])
|
|
.show(ui, |ui| {
|
|
ui.label(RichText::new("Address").color(theme::TEXT_WEAK));
|
|
ui.monospace(if self.config.openfut_server_host.is_empty() {
|
|
"—".to_string()
|
|
} else {
|
|
format!(
|
|
"{}:{}",
|
|
self.config.openfut_server_host, self.config.openfut_https_port
|
|
)
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label(RichText::new("Detail").color(theme::TEXT_WEAK));
|
|
ui.label(RichText::new(&health.detail).color(theme::TEXT));
|
|
ui.end_row();
|
|
|
|
if let Some(t) = health.last_checked {
|
|
ui.label(RichText::new("Checked").color(theme::TEXT_WEAK));
|
|
ui.label(
|
|
RichText::new(format!("{}s ago", t.elapsed().as_secs()))
|
|
.color(theme::TEXT_FAINT),
|
|
);
|
|
ui.end_row();
|
|
}
|
|
});
|
|
|
|
if !server_ok {
|
|
ui.add_space(8.0);
|
|
ui.colored_label(
|
|
theme::WARN,
|
|
"No OpenFUT server configured — set the host in Settings.",
|
|
);
|
|
}
|
|
ui.add_space(8.0);
|
|
ui.label(
|
|
RichText::new(
|
|
"The server runs elsewhere (e.g. Docker on the server host). This \
|
|
launcher monitors it read-only — it does not start or stop it.",
|
|
)
|
|
.color(theme::TEXT_FAINT)
|
|
.small(),
|
|
);
|
|
});
|
|
|
|
ui.add_space(14.0);
|
|
|
|
// ── Local services card ───────────────────────────────────────────────
|
|
theme::card().show(ui, |ui| {
|
|
ui.set_width(ui.available_width());
|
|
self.ui_local_services(ui);
|
|
});
|
|
|
|
ui.add_space(14.0);
|
|
|
|
// ── Game & launch card ────────────────────────────────────────────────
|
|
let launch_config = self.config.validate_launch_config();
|
|
let hook_ready = self.hook_deployed;
|
|
let can_launch = launch_config.is_ok() && hook_ready;
|
|
|
|
theme::card().show(ui, |ui| {
|
|
ui.set_width(ui.available_width());
|
|
let (gstatus, glabel) = if can_launch {
|
|
(Status::Ok, "Ready")
|
|
} else {
|
|
(Status::Warn, "Setup needed")
|
|
};
|
|
card_header(ui, "Game & launch", Some((glabel, gstatus)));
|
|
|
|
egui::Grid::new("game_status_grid")
|
|
.num_columns(2)
|
|
.spacing([18.0, 8.0])
|
|
.show(ui, |ui| {
|
|
ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK));
|
|
if hook_ready {
|
|
status_text(ui, Status::Ok, "Deployed (version.dll)");
|
|
} else {
|
|
status_text(ui, Status::Warn, "Not deployed — see the Setup tab");
|
|
}
|
|
ui.end_row();
|
|
|
|
ui.label(RichText::new("Launch command").color(theme::TEXT_WEAK));
|
|
if self.config.game_profile.configured() {
|
|
ui.monospace(format!(
|
|
"{} {}",
|
|
self.config.game_profile.runner, self.config.game_profile.executable
|
|
));
|
|
} else if can_launch {
|
|
ui.monospace(&self.config.game_launch_command);
|
|
} else {
|
|
status_text(ui, Status::Warn, "Not set — configure it in Settings");
|
|
}
|
|
ui.end_row();
|
|
});
|
|
|
|
ui.add_space(12.0);
|
|
ui.separator();
|
|
ui.add_space(12.0);
|
|
self.preflight_ui(ui);
|
|
ui.add_space(14.0);
|
|
|
|
let cta_w = ui.available_width().min(380.0);
|
|
if ui
|
|
.add_enabled(
|
|
can_launch,
|
|
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();
|
|
}
|
|
|
|
if let Err(message) = &launch_config {
|
|
ui.add_space(6.0);
|
|
ui.colored_label(theme::WARN, message);
|
|
}
|
|
if !server_ok {
|
|
ui.add_space(6.0);
|
|
ui.colored_label(
|
|
theme::WARN,
|
|
"Tip: the game can launch, but without a reachable server FUT features \
|
|
won't connect.",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Dashboard section for the two client-side FIFA 17 daemons.
|
|
fn ui_local_services(&mut self, ui: &mut Ui) {
|
|
use crate::local_services::Service;
|
|
|
|
let configured = !self.config.fifa17_tools_dir.trim().is_empty();
|
|
let lsx_running = self.lsx.running(&self.game_logs, Service::Lsx.label());
|
|
let ap_running = self
|
|
.autopatch
|
|
.running(&self.game_logs, Service::Autopatch.label());
|
|
let lsx_stopping = self.lsx.stopping();
|
|
let ap_stopping = self.autopatch.stopping();
|
|
|
|
let (sum_status, sum_label) = if !configured {
|
|
(Status::Warn, "Not configured")
|
|
} else if lsx_running && ap_running {
|
|
(Status::Ok, "Both running")
|
|
} else if lsx_running || ap_running {
|
|
(Status::Warn, "Partial")
|
|
} else {
|
|
(Status::Idle, "Stopped")
|
|
};
|
|
card_header(ui, "Local services", Some((sum_label, sum_status)));
|
|
|
|
ui.label(
|
|
RichText::new(
|
|
"LSX (Origin emulator, loopback 4216) and autopatch (ProtoSSL cert-verify) \
|
|
run on THIS machine — the game needs them locally. The Blaze/UTAS/roster/POW \
|
|
responders run in the server container. Start these before launching.",
|
|
)
|
|
.color(theme::TEXT_FAINT)
|
|
.small(),
|
|
);
|
|
ui.add_space(12.0);
|
|
|
|
if !configured {
|
|
ui.colored_label(
|
|
theme::WARN,
|
|
"FIFA 17 tools dir not set — configure it in Settings.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
egui::Grid::new("local_services_grid")
|
|
.num_columns(3)
|
|
.spacing([14.0, 10.0])
|
|
.min_col_width(96.0)
|
|
.show(ui, |ui| {
|
|
// LSX row
|
|
ui.label(RichText::new("LSX").color(theme::TEXT).strong());
|
|
if lsx_stopping {
|
|
theme::status_pill(ui, "Stopping", Status::Busy);
|
|
} else if lsx_running {
|
|
theme::status_pill(ui, "Running", Status::Ok);
|
|
} else {
|
|
theme::status_pill(ui, "Stopped", Status::Idle);
|
|
}
|
|
if lsx_stopping {
|
|
ui.add_enabled(false, egui::Button::new("Stopping…"));
|
|
} else if lsx_running {
|
|
if ui.button("Stop").clicked() {
|
|
self.lsx.stop(&self.game_logs, Service::Lsx);
|
|
}
|
|
} else if ui.button("Start").clicked() {
|
|
let _ = self.start_local_service(Service::Lsx);
|
|
}
|
|
ui.end_row();
|
|
|
|
// autopatch row
|
|
ui.label(RichText::new("autopatch").color(theme::TEXT).strong());
|
|
if ap_stopping {
|
|
theme::status_pill(ui, "Stopping", Status::Busy);
|
|
} else if ap_running {
|
|
theme::status_pill(ui, "Running", Status::Ok);
|
|
} else {
|
|
theme::status_pill(ui, "Stopped", Status::Idle);
|
|
}
|
|
if ap_stopping {
|
|
ui.add_enabled(false, egui::Button::new("Stopping…"));
|
|
} else if ap_running {
|
|
if ui.button("Stop").clicked() {
|
|
self.autopatch.stop(&self.game_logs, Service::Autopatch);
|
|
// The verified capability belongs to the FIFA process
|
|
// autopatch was serving; drop it when autopatch stops.
|
|
*self.fifa17_caps.lock() = Default::default();
|
|
}
|
|
} else if ui.button("Start").clicked() {
|
|
let _ = self.start_local_service(Service::Autopatch);
|
|
}
|
|
ui.end_row();
|
|
});
|
|
|
|
ui.add_space(10.0);
|
|
if ui.button("Start both local services").clicked() {
|
|
if !lsx_running {
|
|
let _ = self.start_local_service(Service::Lsx);
|
|
}
|
|
if !ap_running {
|
|
let _ = self.start_local_service(Service::Autopatch);
|
|
}
|
|
}
|
|
|
|
if let Some((ok, msg)) = &self.local_services_message {
|
|
ui.add_space(6.0);
|
|
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
|
|
}
|
|
}
|
|
|
|
/// The pre-launch checklist.
|
|
///
|
|
/// Advisory by design: a failing check colours the row red and explains the
|
|
/// fix, but never disables Launch. Every one of these checks can itself be
|
|
/// wrong, and a wrong check that locks the user out of their own game is a
|
|
/// worse failure than the one it is guarding against.
|
|
fn preflight_ui(&mut self, ui: &mut Ui) {
|
|
ui.horizontal(|ui| {
|
|
if ui.button("Run pre-launch checks").clicked() {
|
|
self.preflight = Some(preflight::run(&self.config));
|
|
}
|
|
if ui
|
|
.button("Arm client")
|
|
.on_hover_text(
|
|
"Sets ptrace_scope, the EA-redirector DNAT, and /etc/hosts in one step \
|
|
(asks for your password once). Replaces client_arm.sh.",
|
|
)
|
|
.clicked()
|
|
{
|
|
match crate::arm::arm(&self.config) {
|
|
Ok(summary) => {
|
|
{
|
|
let mut logs = self.game_logs.lock();
|
|
logs.push("[launcher] client armed:".to_string());
|
|
for line in &summary {
|
|
logs.push(format!("[launcher] - {line}"));
|
|
}
|
|
}
|
|
self.arm_status = Some((
|
|
true,
|
|
format!("Client armed — {} change(s) applied.", summary.len()),
|
|
));
|
|
// Re-run the checklist so the result shows immediately.
|
|
self.preflight = Some(preflight::run(&self.config));
|
|
}
|
|
Err(e) => {
|
|
self.game_logs
|
|
.lock()
|
|
.push(format!("[launcher] arming failed: {e}"));
|
|
self.arm_status = Some((false, format!("Arming failed: {e}")));
|
|
}
|
|
}
|
|
}
|
|
if let Some(checks) = &self.preflight {
|
|
let bad = preflight::failures(checks);
|
|
let warn = preflight::warnings(checks);
|
|
// States what was found, never what will happen. An earlier
|
|
// version predicted "the game will probably fail" on a warning
|
|
// and the game then reached the FUT hub — a checklist that
|
|
// overstates its own findings gets ignored.
|
|
let (color, text) = match (bad, warn) {
|
|
(0, 0) => (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) — expect the game to fail"),
|
|
),
|
|
(b, w) => (theme::ERROR, format!("{b} problem(s), {w} warning(s)")),
|
|
};
|
|
ui.colored_label(color, text);
|
|
}
|
|
});
|
|
|
|
if let Some((ok, msg)) = &self.arm_status {
|
|
let color = if *ok { theme::SUCCESS } else { theme::ERROR };
|
|
ui.colored_label(color, msg);
|
|
}
|
|
|
|
let Some(checks) = &self.preflight else {
|
|
return;
|
|
};
|
|
ui.add_space(4.0);
|
|
for c in checks {
|
|
let (mark, color) = match c.state {
|
|
preflight::State::Pass => ("OK ", 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 launch_game(&mut self) {
|
|
// A new FIFA process starts UNKNOWN: never inherit a prior launch's
|
|
// verified capability. The autopatch stdout reader re-populates this.
|
|
*self.fifa17_caps.lock() = Default::default();
|
|
if let Err(message) = self.config.validate_launch_config() {
|
|
self.game_logs
|
|
.lock()
|
|
.push(format!("[launcher] launch blocked: {message}"));
|
|
self.local_services_message = Some((false, message));
|
|
self.active_tab = Tab::Logs;
|
|
return;
|
|
}
|
|
if !self.hook_deployed {
|
|
let message = "Hook DLL is not deployed. Complete Setup before launching.".to_string();
|
|
self.game_logs
|
|
.lock()
|
|
.push(format!("[launcher] launch blocked: {message}"));
|
|
self.local_services_message = Some((false, message));
|
|
self.active_tab = Tab::Logs;
|
|
return;
|
|
}
|
|
// Fail-closed: launching FIFA at an unknown server is worse than not
|
|
// launching. This is the only moment the file the game reads is
|
|
// guaranteed to agree with the settings the user is looking at.
|
|
if let Err(message) = self.write_hook_config() {
|
|
self.game_logs
|
|
.lock()
|
|
.push(format!("[launcher] launch blocked: {message}"));
|
|
self.local_services_message = Some((false, message));
|
|
self.active_tab = Tab::Logs;
|
|
return;
|
|
}
|
|
self.game_logs.lock().push(format!(
|
|
"[launcher] hook config written: server={} https={} blaze-redir={} blaze-main={}",
|
|
self.config.openfut_server_host,
|
|
self.config.openfut_https_port,
|
|
self.config.openfut_blaze_redirector_port,
|
|
self.config.openfut_blaze_main_port,
|
|
));
|
|
let account = match crate::account_sync::sync(&self.config) {
|
|
Ok(account) => account,
|
|
Err(message) => {
|
|
self.game_logs
|
|
.lock()
|
|
.push(format!("[launcher] account sync failed: {message}"));
|
|
self.local_services_message = Some((false, message));
|
|
self.active_tab = Tab::Logs;
|
|
return;
|
|
}
|
|
};
|
|
self.game_logs.lock().push(format!(
|
|
"[launcher] account synchronized: {}/{} level={} XP={} account-funds={} FUT-coins={} unopened-packs={}",
|
|
account.persona_id,
|
|
account.persona_name,
|
|
account.level,
|
|
account.experience,
|
|
account.account_funds,
|
|
account.coins,
|
|
account.unopened_packs,
|
|
));
|
|
if let Err(message) = self.ensure_local_services() {
|
|
self.game_logs
|
|
.lock()
|
|
.push(format!("[launcher] launch blocked: {message}"));
|
|
self.local_services_message = Some((false, message));
|
|
self.active_tab = Tab::Logs;
|
|
return;
|
|
}
|
|
|
|
// Prefer the native profile; fall back to the user's shell command.
|
|
// The fallback is why an existing setup keeps working after upgrading,
|
|
// and why a profile that misbehaves is recoverable without a rebuild.
|
|
let result = if self.config.game_profile.configured() {
|
|
game_launch::launch(&self.config.game_profile, &self.game_logs)
|
|
} else {
|
|
let cmd = self.config.game_launch_command.clone();
|
|
let workdir = self.config.game_launch_workdir.clone();
|
|
setup::launch_game(&cmd, &workdir, Arc::clone(&self.game_logs))
|
|
};
|
|
if let Err(e) = result {
|
|
self.game_logs
|
|
.lock()
|
|
.push(format!("[launcher] launch failed: {e}"));
|
|
}
|
|
self.active_tab = Tab::Logs;
|
|
}
|
|
|
|
/// Start one companion service, recording a user-facing result message.
|
|
fn ensure_local_services(&mut self) -> Result<(), String> {
|
|
use crate::local_services::Service;
|
|
|
|
if !self.lsx.running(&self.game_logs, Service::Lsx.label()) {
|
|
self.start_local_service(Service::Lsx)?;
|
|
}
|
|
if !self
|
|
.autopatch
|
|
.running(&self.game_logs, Service::Autopatch.label())
|
|
{
|
|
self.start_local_service(Service::Autopatch)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn start_local_service(&mut self, which: crate::local_services::Service) -> Result<(), String> {
|
|
use crate::local_services::spawn;
|
|
let py = self.config.fifa17_python.clone();
|
|
let dir = self.config.fifa17_tools_dir.clone();
|
|
let persona_id = self.config.fut_persona_id;
|
|
let persona_name = self.config.fut_persona_name.clone();
|
|
let logs = Arc::clone(&self.game_logs);
|
|
// Only autopatch advertises the verified resolver guard, so only it
|
|
// receives the shared capability sink; LSX passes None.
|
|
let capability = match which {
|
|
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 slot = match which {
|
|
crate::local_services::Service::Lsx => &mut self.lsx,
|
|
crate::local_services::Service::Autopatch => &mut self.autopatch,
|
|
};
|
|
match spawn(
|
|
which,
|
|
&py,
|
|
&dir,
|
|
persona_id,
|
|
&persona_name,
|
|
capability,
|
|
logs,
|
|
) {
|
|
Ok(child) => {
|
|
*slot = crate::local_services::ManagedService::from_child(child);
|
|
self.local_services_message = Some((true, format!("{} started.", which.label())));
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
let message = format!("{}: {e}", which.label());
|
|
self.local_services_message = Some((false, message.clone()));
|
|
Err(message)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ui_logs(&mut self, ui: &mut Ui) {
|
|
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.label(RichText::new("FIFA17 tools dir:").color(theme::TEXT_WEAK));
|
|
changed |= ui
|
|
.add(
|
|
egui::TextEdit::singleline(&mut self.config.fifa17_tools_dir)
|
|
.hint_text("fifa17-recon/tools (LSX + autopatch scripts)"),
|
|
)
|
|
.changed();
|
|
ui.end_row();
|
|
|
|
ui.label(RichText::new("Python:").color(theme::TEXT_WEAK));
|
|
changed |= ui
|
|
.add(
|
|
egui::TextEdit::singleline(&mut self.config.fifa17_python)
|
|
.hint_text("python3"),
|
|
)
|
|
.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));
|
|
|
|
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 => {}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
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) % 3 == 0 {
|
|
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())
|
|
}
|