d619c992c1
Add a GUI "Arm client" button that reproduces client_arm.sh in a single pkexec batch: kernel.yama.ptrace_scope=0, DNAT of EA's hardcoded redirector IP to the OpenFUT server (+ MASQUERADE reply path), and /etc/hosts rewrites for every dead EA hostname (removing foreign shadow lines first, so glibc's first-match resolution can't land on a stale loopback entry). All steps are idempotent (delete-then-add) and injection-safe: config values are charset- validated and rejected on a surprising character, never shell-escaped. arm() returns the concrete change list, which the button logs line-by-line and echoes as an inline pass/fail status on the pre-launch tab (no tab jump, no reuse of the local-services toast). This necessarily lands the surrounding launcher modularization the arm feature is built on, extracted from the former monolithic app.rs/process.rs: - preflight: advisory pre-launch checks (ptrace, redirector DNAT, hostnames, backend reachability) that colour rows but never block Launch - local_services: launcher-owned LSX/autopatch child processes - game_launch, account_sync, health, netcheck helpers - openfut-common: dependency-free shared server-destination/port mapping, used by both the launcher and (separately) openfut_hook.dll openfut-hook RE changes are intentionally left uncommitted (separate concern). fmt + clippy -D warnings clean; 46 tests pass.
1096 lines
41 KiB
Rust
1096 lines
41 KiB
Rust
use std::sync::{Arc, Mutex};
|
|
|
|
use egui::{Color32, FontId, RichText, ScrollArea, Ui, Vec2};
|
|
|
|
use crate::{
|
|
config::LauncherConfig, game_launch, health::HealthMonitor, logs::LogBuffer, netcheck,
|
|
preflight, setup,
|
|
};
|
|
|
|
#[derive(PartialEq)]
|
|
enum Tab {
|
|
Dashboard,
|
|
Logs,
|
|
Setup,
|
|
Config,
|
|
}
|
|
|
|
/// The launcher is a *client-side* tool: the OpenFUT servers run elsewhere
|
|
/// (e.g. Docker on the server host). It monitors server health read-only,
|
|
/// prepares the FIFA integration (hook DLL + cert), and launches the game.
|
|
pub struct LauncherApp {
|
|
config: LauncherConfig,
|
|
config_dirty: bool,
|
|
|
|
/// Read-only health monitor for the configured (remote) server.
|
|
health: HealthMonitor,
|
|
/// Game process output + launcher messages.
|
|
game_logs: Arc<Mutex<LogBuffer>>,
|
|
|
|
active_tab: Tab,
|
|
log_follow: bool,
|
|
|
|
// Setup state
|
|
hook_deployed: bool,
|
|
cert_path: Option<std::path::PathBuf>,
|
|
setup_message: Option<(bool, String)>,
|
|
/// Result of the last "Test Connection" click.
|
|
test_message: Option<(bool, String)>,
|
|
|
|
// FIFA 17 local companion services (client-side daemons).
|
|
lsx: crate::local_services::ManagedService,
|
|
autopatch: crate::local_services::ManagedService,
|
|
local_services_message: Option<(bool, String)>,
|
|
|
|
/// Last pre-launch check results. `None` until run — deliberately not run
|
|
/// automatically on every frame: the checks open sockets, and a 2s probe
|
|
/// on the UI thread would stall the window.
|
|
preflight: Option<Vec<preflight::Check>>,
|
|
|
|
/// Result of the last "Arm client" click, shown inline beneath the button so
|
|
/// the outcome appears where the user acted — not on another tab.
|
|
arm_status: Option<(bool, String)>,
|
|
}
|
|
|
|
impl LauncherApp {
|
|
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
|
let mut style = (*cc.egui_ctx.style()).clone();
|
|
style
|
|
.text_styles
|
|
.insert(egui::TextStyle::Body, FontId::proportional(14.0));
|
|
style
|
|
.text_styles
|
|
.insert(egui::TextStyle::Monospace, FontId::monospace(13.0));
|
|
cc.egui_ctx.set_style(style);
|
|
|
|
let config = LauncherConfig::load();
|
|
let cert_path = setup::find_bridge_cert(&config.bridge_captures_dir);
|
|
let hook_deployed = setup::hook_dll_deployed(std::path::Path::new(&config.fifa_game_dir));
|
|
|
|
let health = HealthMonitor::new();
|
|
health.set_target(config.health_target());
|
|
|
|
Self {
|
|
config,
|
|
config_dirty: false,
|
|
health,
|
|
game_logs: Arc::new(Mutex::new(LogBuffer::new())),
|
|
active_tab: Tab::Dashboard,
|
|
log_follow: true,
|
|
hook_deployed,
|
|
cert_path,
|
|
setup_message: None,
|
|
test_message: None,
|
|
lsx: crate::local_services::ManagedService::default(),
|
|
autopatch: crate::local_services::ManagedService::default(),
|
|
local_services_message: None,
|
|
preflight: None,
|
|
arm_status: None,
|
|
}
|
|
}
|
|
|
|
/// Re-point the health monitor whenever the server address may have changed.
|
|
fn refresh_health_target(&self) {
|
|
self.health.set_target(self.config.health_target());
|
|
}
|
|
|
|
// ── UI sections ───────────────────────────────────────────────────────────
|
|
|
|
fn ui_dashboard(&mut self, ui: &mut Ui) {
|
|
ui.add_space(8.0);
|
|
ui.heading("OpenFUT Server");
|
|
ui.add_space(6.0);
|
|
|
|
let server_ok = self.config.validate_server().is_ok();
|
|
|
|
if !server_ok {
|
|
ui.colored_label(
|
|
Color32::from_rgb(220, 150, 0),
|
|
"No OpenFUT server configured. Enter the hostname or IP address of \
|
|
your OpenFUT server in the Setup tab.",
|
|
);
|
|
ui.add_space(6.0);
|
|
}
|
|
|
|
// ── Server health (read-only) ─────────────────────────────────────────
|
|
let health = self.health.snapshot();
|
|
egui::Grid::new("health_grid")
|
|
.num_columns(2)
|
|
.spacing([16.0, 8.0])
|
|
.show(ui, |ui| {
|
|
ui.strong("Server");
|
|
ui.monospace(if self.config.openfut_server_host.is_empty() {
|
|
"—".to_string()
|
|
} else {
|
|
format!(
|
|
"{}:{}",
|
|
self.config.openfut_server_host, self.config.openfut_https_port
|
|
)
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.strong("Status");
|
|
match health.reachable {
|
|
None => ui.colored_label(Color32::from_rgb(150, 150, 150), "Unknown"),
|
|
Some(true) => ui.colored_label(Color32::from_rgb(80, 200, 120), "● Online"),
|
|
Some(false) => {
|
|
ui.colored_label(Color32::from_rgb(220, 60, 60), "● Unreachable")
|
|
}
|
|
};
|
|
ui.end_row();
|
|
|
|
ui.label("Detail");
|
|
ui.label(RichText::new(&health.detail).weak());
|
|
ui.end_row();
|
|
|
|
if let Some(t) = health.last_checked {
|
|
ui.label("Checked");
|
|
ui.label(RichText::new(format!("{}s ago", t.elapsed().as_secs())).weak());
|
|
ui.end_row();
|
|
}
|
|
});
|
|
|
|
ui.add_space(6.0);
|
|
ui.label(
|
|
RichText::new(
|
|
"The server runs elsewhere (e.g. Docker on the server host). This \
|
|
launcher only monitors it — it does not start or stop it.",
|
|
)
|
|
.weak()
|
|
.small(),
|
|
);
|
|
|
|
ui.add_space(16.0);
|
|
ui.separator();
|
|
ui.add_space(8.0);
|
|
|
|
// ── FIFA 17 local companion services ──────────────────────────────────
|
|
self.ui_local_services(ui);
|
|
|
|
ui.add_space(16.0);
|
|
ui.separator();
|
|
ui.add_space(8.0);
|
|
|
|
// ── Game launch ───────────────────────────────────────────────────────
|
|
ui.heading("Game");
|
|
ui.add_space(6.0);
|
|
|
|
let launch_config = self.config.validate_launch_config();
|
|
let hook_ready = self.hook_deployed;
|
|
let can_launch = launch_config.is_ok() && hook_ready;
|
|
|
|
egui::Grid::new("game_status_grid")
|
|
.num_columns(2)
|
|
.spacing([12.0, 4.0])
|
|
.show(ui, |ui| {
|
|
ui.label("Hook DLL:");
|
|
if hook_ready {
|
|
ui.colored_label(Color32::from_rgb(80, 200, 120), "Deployed (version.dll)");
|
|
} else {
|
|
ui.colored_label(
|
|
Color32::from_rgb(220, 150, 0),
|
|
"Not deployed — see Setup tab",
|
|
);
|
|
}
|
|
ui.end_row();
|
|
|
|
ui.label("Launch command:");
|
|
if self.config.game_profile.configured() {
|
|
ui.monospace(format!(
|
|
"{} {}",
|
|
self.config.game_profile.runner, self.config.game_profile.executable
|
|
));
|
|
} else if can_launch {
|
|
ui.monospace(&self.config.game_launch_command);
|
|
} else {
|
|
ui.colored_label(
|
|
Color32::from_rgb(220, 150, 0),
|
|
"Not set — configure it in the Config tab",
|
|
);
|
|
}
|
|
ui.end_row();
|
|
});
|
|
|
|
ui.add_space(10.0);
|
|
self.preflight_ui(ui);
|
|
ui.add_space(10.0);
|
|
|
|
if ui
|
|
.add_enabled(
|
|
can_launch,
|
|
egui::Button::new(
|
|
RichText::new("▶ Start Local Services & Launch Game").size(16.0),
|
|
)
|
|
.min_size(Vec2::new(160.0, 40.0)),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.launch_game();
|
|
}
|
|
|
|
if let Err(message) = &launch_config {
|
|
ui.add_space(4.0);
|
|
ui.colored_label(Color32::from_rgb(220, 150, 0), message);
|
|
}
|
|
|
|
if !server_ok {
|
|
ui.add_space(4.0);
|
|
ui.colored_label(
|
|
Color32::from_rgb(220, 150, 0),
|
|
"Tip: the game can launch, but without a configured/reachable server \
|
|
FUT features won't connect.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Dashboard section for the two client-side FIFA 17 daemons.
|
|
fn ui_local_services(&mut self, ui: &mut Ui) {
|
|
use crate::local_services::Service;
|
|
|
|
ui.heading("FIFA 17 local services");
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
RichText::new(
|
|
"LSX (Origin emulator, loopback 4216) and autopatch (ProtoSSL cert-verify) \
|
|
run on THIS machine — the game needs them locally. The Blaze/UTAS/roster/POW \
|
|
responders run in the server container. Start these before launching the game.",
|
|
)
|
|
.weak()
|
|
.small(),
|
|
);
|
|
ui.add_space(6.0);
|
|
|
|
let configured = !self.config.fifa17_tools_dir.trim().is_empty();
|
|
if !configured {
|
|
ui.colored_label(
|
|
Color32::from_rgb(220, 150, 0),
|
|
"FIFA 17 tools dir not set — configure it in the Config tab.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
let lsx_running = self.lsx.running(&self.game_logs, Service::Lsx.label());
|
|
let ap_running = self
|
|
.autopatch
|
|
.running(&self.game_logs, Service::Autopatch.label());
|
|
let lsx_stopping = self.lsx.stopping();
|
|
let ap_stopping = self.autopatch.stopping();
|
|
|
|
egui::Grid::new("local_services_grid")
|
|
.num_columns(3)
|
|
.spacing([12.0, 8.0])
|
|
.show(ui, |ui| {
|
|
// LSX row
|
|
ui.strong("LSX");
|
|
if lsx_stopping {
|
|
ui.colored_label(Color32::from_rgb(220, 150, 0), "◌ Stopping");
|
|
} else if lsx_running {
|
|
ui.colored_label(Color32::from_rgb(80, 200, 120), "● Running");
|
|
} else {
|
|
ui.colored_label(Color32::from_rgb(150, 150, 150), "○ Stopped");
|
|
}
|
|
if lsx_stopping {
|
|
ui.add_enabled(false, egui::Button::new("Stopping…"));
|
|
} else if lsx_running {
|
|
if ui.button("Stop").clicked() {
|
|
self.lsx.stop(&self.game_logs, Service::Lsx);
|
|
}
|
|
} else if ui.button("Start").clicked() {
|
|
let _ = self.start_local_service(Service::Lsx);
|
|
}
|
|
ui.end_row();
|
|
|
|
// autopatch row
|
|
ui.strong("autopatch");
|
|
if ap_stopping {
|
|
ui.colored_label(Color32::from_rgb(220, 150, 0), "◌ Stopping");
|
|
} else if ap_running {
|
|
ui.colored_label(Color32::from_rgb(80, 200, 120), "● Running");
|
|
} else {
|
|
ui.colored_label(Color32::from_rgb(150, 150, 150), "○ Stopped");
|
|
}
|
|
if ap_stopping {
|
|
ui.add_enabled(false, egui::Button::new("Stopping…"));
|
|
} else if ap_running {
|
|
if ui.button("Stop").clicked() {
|
|
self.autopatch.stop(&self.game_logs, Service::Autopatch);
|
|
}
|
|
} else if ui.button("Start").clicked() {
|
|
let _ = self.start_local_service(Service::Autopatch);
|
|
}
|
|
ui.end_row();
|
|
});
|
|
|
|
ui.add_space(6.0);
|
|
if ui
|
|
.button(RichText::new("Start both local services").size(14.0))
|
|
.clicked()
|
|
{
|
|
if !lsx_running {
|
|
let _ = self.start_local_service(Service::Lsx);
|
|
}
|
|
if !ap_running {
|
|
let _ = self.start_local_service(Service::Autopatch);
|
|
}
|
|
}
|
|
|
|
if let Some((ok, msg)) = &self.local_services_message {
|
|
let color = if *ok {
|
|
Color32::from_rgb(80, 200, 120)
|
|
} else {
|
|
Color32::from_rgb(220, 90, 90)
|
|
};
|
|
ui.add_space(4.0);
|
|
ui.colored_label(color, msg);
|
|
}
|
|
}
|
|
|
|
/// The pre-launch checklist.
|
|
///
|
|
/// Advisory by design: a failing check colours the row red and explains the
|
|
/// fix, but never disables Launch. Every one of these checks can itself be
|
|
/// wrong, and a wrong check that locks the user out of their own game is a
|
|
/// worse failure than the one it is guarding against.
|
|
fn preflight_ui(&mut self, ui: &mut Ui) {
|
|
ui.horizontal(|ui| {
|
|
if ui.button("Run pre-launch checks").clicked() {
|
|
self.preflight = Some(preflight::run(&self.config));
|
|
}
|
|
if ui
|
|
.button("Arm client")
|
|
.on_hover_text(
|
|
"Sets ptrace_scope, the EA-redirector DNAT, and /etc/hosts in one step \
|
|
(asks for your password once). Replaces client_arm.sh.",
|
|
)
|
|
.clicked()
|
|
{
|
|
match crate::arm::arm(&self.config) {
|
|
Ok(summary) => {
|
|
{
|
|
let mut logs = self.game_logs.lock().unwrap();
|
|
logs.push("[launcher] client armed:".to_string());
|
|
for line in &summary {
|
|
logs.push(format!("[launcher] - {line}"));
|
|
}
|
|
}
|
|
self.arm_status = Some((
|
|
true,
|
|
format!("Client armed — {} change(s) applied.", summary.len()),
|
|
));
|
|
// Re-run the checklist so the result shows immediately.
|
|
self.preflight = Some(preflight::run(&self.config));
|
|
}
|
|
Err(e) => {
|
|
self.game_logs
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("[launcher] arming failed: {e}"));
|
|
self.arm_status = Some((false, format!("Arming failed: {e}")));
|
|
}
|
|
}
|
|
}
|
|
if let Some(checks) = &self.preflight {
|
|
let bad = preflight::failures(checks);
|
|
let warn = preflight::warnings(checks);
|
|
// States what was found, never what will happen. An earlier
|
|
// version predicted "the game will probably fail" on a warning
|
|
// and the game then reached the FUT hub — a checklist that
|
|
// overstates its own findings gets ignored.
|
|
let (color, text) = match (bad, warn) {
|
|
(0, 0) => (
|
|
Color32::from_rgb(80, 200, 120),
|
|
"no problems found".to_string(),
|
|
),
|
|
(0, w) => (
|
|
Color32::from_rgb(220, 150, 0),
|
|
format!("{w} warning(s) — worth fixing, usually not fatal"),
|
|
),
|
|
(b, 0) => (
|
|
Color32::from_rgb(220, 90, 90),
|
|
format!("{b} problem(s) — expect the game to fail"),
|
|
),
|
|
(b, w) => (
|
|
Color32::from_rgb(220, 90, 90),
|
|
format!("{b} problem(s), {w} warning(s)"),
|
|
),
|
|
};
|
|
ui.colored_label(color, text);
|
|
}
|
|
});
|
|
|
|
if let Some((ok, msg)) = &self.arm_status {
|
|
let color = if *ok {
|
|
Color32::from_rgb(80, 200, 120)
|
|
} else {
|
|
Color32::from_rgb(220, 90, 90)
|
|
};
|
|
ui.colored_label(color, msg);
|
|
}
|
|
|
|
let Some(checks) = &self.preflight else {
|
|
return;
|
|
};
|
|
ui.add_space(4.0);
|
|
for c in checks {
|
|
let (mark, color) = match c.state {
|
|
preflight::State::Pass => ("OK ", Color32::from_rgb(80, 200, 120)),
|
|
preflight::State::Warn => ("WARN", Color32::from_rgb(220, 150, 0)),
|
|
preflight::State::Fail => ("FAIL", Color32::from_rgb(220, 90, 90)),
|
|
// Grey, never green: "not checked" must not read as "fine".
|
|
preflight::State::Skipped => ("-- ", Color32::from_gray(140)),
|
|
};
|
|
ui.horizontal(|ui| {
|
|
ui.colored_label(color, RichText::new(mark).monospace());
|
|
ui.colored_label(color, &c.name);
|
|
ui.label(RichText::new(&c.detail).weak());
|
|
});
|
|
}
|
|
}
|
|
|
|
fn launch_game(&mut self) {
|
|
if let Err(message) = self.config.validate_launch_config() {
|
|
self.game_logs
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("[launcher] launch blocked: {message}"));
|
|
self.local_services_message = Some((false, message));
|
|
self.active_tab = Tab::Logs;
|
|
return;
|
|
}
|
|
if !self.hook_deployed {
|
|
let message = "Hook DLL is not deployed. Complete Setup before launching.".to_string();
|
|
self.game_logs
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("[launcher] launch blocked: {message}"));
|
|
self.local_services_message = Some((false, message));
|
|
self.active_tab = Tab::Logs;
|
|
return;
|
|
}
|
|
let account = match crate::account_sync::sync(&self.config) {
|
|
Ok(account) => account,
|
|
Err(message) => {
|
|
self.game_logs
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("[launcher] account sync failed: {message}"));
|
|
self.local_services_message = Some((false, message));
|
|
self.active_tab = Tab::Logs;
|
|
return;
|
|
}
|
|
};
|
|
self.game_logs.lock().unwrap().push(format!(
|
|
"[launcher] account synchronized: {}/{} level={} XP={} account-funds={} FUT-coins={} unopened-packs={}",
|
|
account.persona_id,
|
|
account.persona_name,
|
|
account.level,
|
|
account.experience,
|
|
account.account_funds,
|
|
account.coins,
|
|
account.unopened_packs,
|
|
));
|
|
if let Err(message) = self.ensure_local_services() {
|
|
self.game_logs
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("[launcher] launch blocked: {message}"));
|
|
self.local_services_message = Some((false, message));
|
|
self.active_tab = Tab::Logs;
|
|
return;
|
|
}
|
|
|
|
// Prefer the native profile; fall back to the user's shell command.
|
|
// The fallback is why an existing setup keeps working after upgrading,
|
|
// and why a profile that misbehaves is recoverable without a rebuild.
|
|
let result = if self.config.game_profile.configured() {
|
|
game_launch::launch(&self.config.game_profile, &self.game_logs)
|
|
} else {
|
|
let cmd = self.config.game_launch_command.clone();
|
|
let workdir = self.config.game_launch_workdir.clone();
|
|
setup::launch_game(&cmd, &workdir, Arc::clone(&self.game_logs))
|
|
};
|
|
if let Err(e) = result {
|
|
self.game_logs
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("[launcher] launch failed: {e}"));
|
|
}
|
|
self.active_tab = Tab::Logs;
|
|
}
|
|
|
|
/// Start one companion service, recording a user-facing result message.
|
|
fn ensure_local_services(&mut self) -> Result<(), String> {
|
|
use crate::local_services::Service;
|
|
|
|
if !self.lsx.running(&self.game_logs, Service::Lsx.label()) {
|
|
self.start_local_service(Service::Lsx)?;
|
|
}
|
|
if !self
|
|
.autopatch
|
|
.running(&self.game_logs, Service::Autopatch.label())
|
|
{
|
|
self.start_local_service(Service::Autopatch)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn start_local_service(&mut self, which: crate::local_services::Service) -> Result<(), String> {
|
|
use crate::local_services::spawn;
|
|
let py = self.config.fifa17_python.clone();
|
|
let dir = self.config.fifa17_tools_dir.clone();
|
|
let slot = match which {
|
|
crate::local_services::Service::Lsx => &mut self.lsx,
|
|
crate::local_services::Service::Autopatch => &mut self.autopatch,
|
|
};
|
|
match spawn(
|
|
which,
|
|
&py,
|
|
&dir,
|
|
self.config.fut_persona_id,
|
|
&self.config.fut_persona_name,
|
|
Arc::clone(&self.game_logs),
|
|
) {
|
|
Ok(child) => {
|
|
*slot = crate::local_services::ManagedService::from_child(child);
|
|
self.local_services_message = Some((true, format!("{} started.", which.label())));
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
let message = format!("{}: {e}", which.label());
|
|
self.local_services_message = Some((false, message.clone()));
|
|
Err(message)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ui_logs(&mut self, ui: &mut Ui) {
|
|
ui.horizontal(|ui| {
|
|
ui.strong("Game / launcher output");
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if ui.button("Clear").clicked() {
|
|
self.game_logs.lock().unwrap().clear();
|
|
}
|
|
ui.checkbox(&mut self.log_follow, "Follow");
|
|
});
|
|
});
|
|
ui.separator();
|
|
|
|
let follow = self.log_follow;
|
|
ScrollArea::vertical()
|
|
.auto_shrink([false, false])
|
|
.stick_to_bottom(follow)
|
|
.show(ui, |ui| {
|
|
let guard = self.game_logs.lock().unwrap();
|
|
for line in guard.lines() {
|
|
let color = log_line_color(line);
|
|
ui.add(
|
|
egui::Label::new(RichText::new(line).monospace().color(color).size(12.5))
|
|
.wrap(),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn ui_setup(&mut self, ui: &mut Ui) {
|
|
use std::path::Path;
|
|
|
|
ui.add_space(8.0);
|
|
ui.heading("FIFA Integration Setup");
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
"These steps route the game's EA traffic to your OpenFUT server via DLL \
|
|
injection (no hosts file changes needed).",
|
|
);
|
|
ui.add_space(12.0);
|
|
|
|
// ── Server address ────────────────────────────────────────────────────
|
|
ui.group(|ui| {
|
|
ui.set_min_width(ui.available_width());
|
|
ui.strong("Step 1 — OpenFUT server address");
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
"The IP or hostname of your OpenFUT server. FIFA's intercepted EA \
|
|
traffic is redirected here. No loopback default — an empty value \
|
|
blocks setup.",
|
|
);
|
|
ui.add_space(6.0);
|
|
|
|
ui.horizontal(|ui| {
|
|
ui.label("Server (IP or hostname):");
|
|
let changed = ui
|
|
.add(
|
|
egui::TextEdit::singleline(&mut self.config.openfut_server_host)
|
|
.hint_text("e.g. 10.10.0.120 or fut.mylan.home")
|
|
.desired_width(220.0),
|
|
)
|
|
.changed();
|
|
if changed {
|
|
self.config_dirty = true;
|
|
self.test_message = None;
|
|
self.refresh_health_target();
|
|
}
|
|
});
|
|
ui.horizontal(|ui| {
|
|
ui.label("Ports:");
|
|
ui.label("HTTPS");
|
|
let mut p = self.config.openfut_https_port.to_string();
|
|
if ui
|
|
.add(egui::TextEdit::singleline(&mut p).desired_width(64.0))
|
|
.changed()
|
|
{
|
|
if let Ok(v) = p.parse() {
|
|
self.config.openfut_https_port = v;
|
|
}
|
|
self.config_dirty = true;
|
|
self.refresh_health_target();
|
|
}
|
|
ui.label("Blaze-redir");
|
|
let mut r = self.config.openfut_blaze_redirector_port.to_string();
|
|
if ui
|
|
.add(egui::TextEdit::singleline(&mut r).desired_width(64.0))
|
|
.changed()
|
|
{
|
|
if let Ok(v) = r.parse() {
|
|
self.config.openfut_blaze_redirector_port = v;
|
|
}
|
|
self.config_dirty = true;
|
|
}
|
|
ui.label("Blaze-main");
|
|
let mut m = self.config.openfut_blaze_main_port.to_string();
|
|
if ui
|
|
.add(egui::TextEdit::singleline(&mut m).desired_width(64.0))
|
|
.changed()
|
|
{
|
|
if let Ok(v) = m.parse() {
|
|
self.config.openfut_blaze_main_port = v;
|
|
}
|
|
self.config_dirty = true;
|
|
}
|
|
});
|
|
|
|
ui.add_space(6.0);
|
|
ui.horizontal(|ui| {
|
|
if ui.button("Test Connection").clicked() {
|
|
match self.config.validate_server() {
|
|
Ok(()) => {
|
|
let outcome = netcheck::test_connection(&self.config.server_config());
|
|
self.test_message = Some((outcome.ok, outcome.message));
|
|
}
|
|
Err(msg) => self.test_message = Some((false, msg)),
|
|
}
|
|
}
|
|
if ui
|
|
.add_enabled(self.hook_deployed, egui::Button::new("Save & Update hook"))
|
|
.clicked()
|
|
{
|
|
match self.config.hook_cfg_contents() {
|
|
Ok(cfg) => match setup::update_hook_config(
|
|
Path::new(&self.config.fifa_game_dir),
|
|
&cfg,
|
|
) {
|
|
Ok(()) => {
|
|
self.config.save();
|
|
self.config_dirty = false;
|
|
self.setup_message = Some((
|
|
true,
|
|
format!(
|
|
"Config updated — hook will redirect to {}.",
|
|
self.config.openfut_server_host
|
|
),
|
|
));
|
|
}
|
|
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
|
|
},
|
|
Err(msg) => self.setup_message = Some((false, msg)),
|
|
}
|
|
}
|
|
});
|
|
if let Some((ok, msg)) = &self.test_message {
|
|
let color = if *ok {
|
|
Color32::from_rgb(80, 200, 120)
|
|
} else {
|
|
Color32::from_rgb(220, 90, 90)
|
|
};
|
|
ui.colored_label(color, msg);
|
|
}
|
|
});
|
|
|
|
ui.add_space(12.0);
|
|
|
|
// ── Hook DLL deployment ───────────────────────────────────────────────
|
|
ui.group(|ui| {
|
|
ui.set_min_width(ui.available_width());
|
|
ui.strong("Step 2 — Deploy network hook DLL");
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
"Copies openfut_hook.dll into the game folder as version.dll. When \
|
|
Proton loads the game it intercepts network calls and redirects EA \
|
|
hostnames to your OpenFUT server — no hosts file changes required.",
|
|
);
|
|
ui.add_space(6.0);
|
|
|
|
let game_dir = Path::new(&self.config.fifa_game_dir);
|
|
self.hook_deployed = setup::hook_dll_deployed(game_dir);
|
|
|
|
let dll_src = Path::new(&self.config.hook_dll_path);
|
|
let dll_built = dll_src.exists();
|
|
|
|
if !dll_built {
|
|
ui.colored_label(
|
|
Color32::from_rgb(220, 150, 0),
|
|
"⚠ Hook DLL not built yet. Build in openfut-hook/:",
|
|
);
|
|
ui.monospace("cargo build --release --target x86_64-pc-windows-gnu");
|
|
ui.add_space(4.0);
|
|
}
|
|
|
|
ui.horizontal(|ui| {
|
|
if self.hook_deployed {
|
|
ui.colored_label(Color32::from_rgb(80, 200, 120), "✔ Deployed (version.dll)");
|
|
ui.add_space(8.0);
|
|
if ui.button("Remove").clicked() {
|
|
match setup::remove_hook_dll(game_dir) {
|
|
Ok(()) => {
|
|
self.setup_message = Some((true, "Hook DLL removed.".into()));
|
|
self.hook_deployed = false;
|
|
}
|
|
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
|
|
}
|
|
}
|
|
} else {
|
|
ui.colored_label(Color32::from_rgb(220, 60, 60), "✘ Not deployed");
|
|
ui.add_space(8.0);
|
|
if ui
|
|
.add_enabled(dll_built, egui::Button::new("Deploy"))
|
|
.clicked()
|
|
{
|
|
match self.config.hook_cfg_contents() {
|
|
Ok(cfg) => match setup::deploy_hook_dll(dll_src, game_dir, &cfg) {
|
|
Ok(()) => {
|
|
self.setup_message =
|
|
Some((true, "Hook DLL deployed as version.dll.".into()));
|
|
self.hook_deployed = true;
|
|
}
|
|
Err(e) => {
|
|
self.setup_message = Some((false, format!("Failed: {e}")))
|
|
}
|
|
},
|
|
Err(msg) => self.setup_message = Some((false, msg)),
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
ui.add_space(8.0);
|
|
ui.label(
|
|
RichText::new(
|
|
"For Steam, paste this into the game's Launch Options; for a custom \
|
|
launch script, export it before running the game:",
|
|
)
|
|
.weak()
|
|
.small(),
|
|
);
|
|
let launch_opt = setup::STEAM_LAUNCH_OPTIONS;
|
|
ui.horizontal(|ui| {
|
|
ui.monospace(launch_opt);
|
|
if ui.small_button("Copy").clicked() {
|
|
ui.output_mut(|o| o.copied_text = launch_opt.to_string());
|
|
}
|
|
});
|
|
});
|
|
|
|
ui.add_space(12.0);
|
|
|
|
// ── Cert install ──────────────────────────────────────────────────────
|
|
ui.group(|ui| {
|
|
ui.set_min_width(ui.available_width());
|
|
ui.strong("Step 3 — Install TLS certificate");
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
"Installs the bridge's self-signed cert into the Wine/Proton cert store \
|
|
so the game accepts HTTPS connections to the bridge. The cert file must \
|
|
be reachable at the configured captures path (copy it from the server).",
|
|
);
|
|
|
|
ui.add_space(6.0);
|
|
self.cert_path = setup::find_bridge_cert(&self.config.bridge_captures_dir);
|
|
|
|
match &self.cert_path.clone() {
|
|
None => {
|
|
ui.colored_label(
|
|
Color32::from_rgb(220, 150, 0),
|
|
"⚠ Cert not found at the captures path. Copy bridge_cert.pem \
|
|
from the server into that directory.",
|
|
);
|
|
}
|
|
Some(cert) => {
|
|
ui.label(format!("Cert: {}", cert.display()));
|
|
ui.add_space(4.0);
|
|
if ui.button("Install cert (Wine cert store)").clicked() {
|
|
match setup::install_cert(cert) {
|
|
Ok(()) => {
|
|
self.setup_message = Some((true, "Certificate installed.".into()))
|
|
}
|
|
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
ui.add_space(12.0);
|
|
|
|
if let Some((ok, msg)) = &self.setup_message {
|
|
let color = if *ok {
|
|
Color32::from_rgb(80, 200, 120)
|
|
} else {
|
|
Color32::from_rgb(220, 60, 60)
|
|
};
|
|
ui.colored_label(color, msg);
|
|
}
|
|
}
|
|
|
|
fn ui_config(&mut self, ui: &mut Ui) {
|
|
ui.add_space(8.0);
|
|
ui.heading("Configuration");
|
|
ui.add_space(8.0);
|
|
|
|
let mut changed = false;
|
|
let mut server_changed = false;
|
|
|
|
egui::Grid::new("config_grid")
|
|
.num_columns(2)
|
|
.spacing([12.0, 8.0])
|
|
.min_col_width(140.0)
|
|
.show(ui, |ui| {
|
|
ui.strong("Game");
|
|
ui.label("");
|
|
ui.end_row();
|
|
|
|
ui.label("Launch command:");
|
|
changed |= ui
|
|
.add(
|
|
egui::TextEdit::singleline(&mut self.config.game_launch_command)
|
|
.hint_text("e.g. ~/Desktop/launch-fifa17.sh"),
|
|
)
|
|
.changed();
|
|
ui.end_row();
|
|
|
|
ui.label("Launch workdir:");
|
|
changed |= ui
|
|
.add(
|
|
egui::TextEdit::singleline(&mut self.config.game_launch_workdir)
|
|
.hint_text("optional, e.g. /mnt/games/FIFA 17"),
|
|
)
|
|
.changed();
|
|
ui.end_row();
|
|
|
|
ui.label("FIFA game dir:");
|
|
changed |= ui
|
|
.text_edit_singleline(&mut self.config.fifa_game_dir)
|
|
.changed();
|
|
ui.end_row();
|
|
|
|
ui.label("FIFA17 tools dir:");
|
|
changed |= ui
|
|
.add(
|
|
egui::TextEdit::singleline(&mut self.config.fifa17_tools_dir)
|
|
.hint_text("fifa17-recon/tools (LSX + autopatch scripts)"),
|
|
)
|
|
.changed();
|
|
ui.end_row();
|
|
|
|
ui.label("Python:");
|
|
changed |= ui
|
|
.add(
|
|
egui::TextEdit::singleline(&mut self.config.fifa17_python)
|
|
.hint_text("python3"),
|
|
)
|
|
.changed();
|
|
ui.end_row();
|
|
|
|
ui.label("");
|
|
ui.label("");
|
|
ui.end_row();
|
|
|
|
ui.strong("OpenFUT server");
|
|
ui.label("");
|
|
ui.end_row();
|
|
|
|
ui.label("Server host:");
|
|
let r = ui.text_edit_singleline(&mut self.config.openfut_server_host);
|
|
if r.changed() {
|
|
changed = true;
|
|
server_changed = true;
|
|
}
|
|
ui.end_row();
|
|
|
|
ui.label("HTTPS port:");
|
|
let mut p = self.config.openfut_https_port.to_string();
|
|
if ui.text_edit_singleline(&mut p).changed() {
|
|
if let Ok(v) = p.parse() {
|
|
self.config.openfut_https_port = v;
|
|
}
|
|
changed = true;
|
|
server_changed = true;
|
|
}
|
|
ui.end_row();
|
|
|
|
ui.label("Account/UTAS port:");
|
|
let mut p = self.config.openfut_account_sync_port.to_string();
|
|
if ui.text_edit_singleline(&mut p).changed() {
|
|
if let Ok(v) = p.parse() {
|
|
self.config.openfut_account_sync_port = v;
|
|
}
|
|
changed = true;
|
|
}
|
|
ui.end_row();
|
|
|
|
ui.label("");
|
|
ui.label("");
|
|
ui.end_row();
|
|
|
|
ui.strong("EA / Origin account");
|
|
ui.label("");
|
|
ui.end_row();
|
|
|
|
ui.label("Persona ID:");
|
|
changed |= ui
|
|
.add(egui::DragValue::new(&mut self.config.fut_persona_id).speed(1))
|
|
.changed();
|
|
ui.end_row();
|
|
|
|
ui.label("Persona name:");
|
|
changed |= ui
|
|
.add(
|
|
egui::TextEdit::singleline(&mut self.config.fut_persona_name)
|
|
.hint_text("EA/Origin display name"),
|
|
)
|
|
.changed();
|
|
ui.end_row();
|
|
|
|
ui.label("Account-bar level:");
|
|
changed |= ui
|
|
.add(
|
|
egui::DragValue::new(&mut self.config.fut_account_level)
|
|
.range(1..=u32::MAX),
|
|
)
|
|
.changed();
|
|
ui.end_row();
|
|
|
|
ui.label("Account-bar XP:");
|
|
ui.horizontal(|ui| {
|
|
changed |= ui
|
|
.add(egui::DragValue::new(
|
|
&mut self.config.fut_account_experience,
|
|
))
|
|
.changed();
|
|
ui.label("/");
|
|
changed |= ui
|
|
.add(egui::DragValue::new(
|
|
&mut self.config.fut_account_experience_max,
|
|
))
|
|
.changed();
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Account-bar funds:");
|
|
ui.horizontal(|ui| {
|
|
changed |= ui
|
|
.add(egui::DragValue::new(&mut self.config.fut_account_funds))
|
|
.changed();
|
|
ui.label("/ cap");
|
|
changed |= ui
|
|
.add(egui::DragValue::new(&mut self.config.fut_account_funds_cap))
|
|
.changed();
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Captures dir:");
|
|
changed |= ui
|
|
.text_edit_singleline(&mut self.config.bridge_captures_dir)
|
|
.changed();
|
|
ui.end_row();
|
|
|
|
ui.label("");
|
|
ui.label("");
|
|
ui.end_row();
|
|
|
|
ui.strong("Hook DLL");
|
|
ui.label("");
|
|
ui.end_row();
|
|
|
|
ui.label("Hook DLL path:");
|
|
changed |= ui
|
|
.text_edit_singleline(&mut self.config.hook_dll_path)
|
|
.changed();
|
|
ui.end_row();
|
|
});
|
|
|
|
if changed {
|
|
self.config_dirty = true;
|
|
}
|
|
if server_changed {
|
|
self.refresh_health_target();
|
|
}
|
|
|
|
ui.add_space(12.0);
|
|
|
|
ui.horizontal(|ui| {
|
|
if ui
|
|
.add_enabled(self.config_dirty, egui::Button::new("Save"))
|
|
.clicked()
|
|
{
|
|
self.config.save();
|
|
self.config_dirty = false;
|
|
self.refresh_health_target();
|
|
}
|
|
if ui.button("Reset to defaults").clicked() {
|
|
self.config = LauncherConfig::default();
|
|
self.config_dirty = true;
|
|
self.refresh_health_target();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
impl eframe::App for LauncherApp {
|
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
|
|
|
egui::TopBottomPanel::top("tab_bar").show(ctx, |ui| {
|
|
ui.add_space(4.0);
|
|
ui.horizontal(|ui| {
|
|
ui.selectable_value(&mut self.active_tab, Tab::Dashboard, "Dashboard");
|
|
ui.selectable_value(&mut self.active_tab, Tab::Logs, "Logs");
|
|
ui.selectable_value(&mut self.active_tab, Tab::Setup, "Setup");
|
|
ui.selectable_value(&mut self.active_tab, Tab::Config, "Config");
|
|
});
|
|
ui.add_space(2.0);
|
|
});
|
|
|
|
egui::CentralPanel::default().show(ctx, |ui| {
|
|
ui.set_min_size(Vec2::new(600.0, 400.0));
|
|
match self.active_tab {
|
|
Tab::Dashboard => self.ui_dashboard(ui),
|
|
Tab::Logs => self.ui_logs(ui),
|
|
Tab::Setup => self.ui_setup(ui),
|
|
Tab::Config => self.ui_config(ui),
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn log_line_color(line: &str) -> Color32 {
|
|
let lower = line.to_lowercase();
|
|
if lower.contains("error") || lower.contains("panic") {
|
|
Color32::from_rgb(220, 80, 80)
|
|
} else if lower.contains("warn") {
|
|
Color32::from_rgb(255, 200, 0)
|
|
} else if lower.contains("info") {
|
|
Color32::from_rgb(160, 210, 255)
|
|
} else {
|
|
Color32::from_rgb(210, 210, 210)
|
|
}
|
|
}
|