87241acc1a
Adds connect_hook, connectex_hook, recv_hook, ssl_patch, tls_bypass, lsx, ea_stub, and origin_spy modules to intercept EA's TLS and socket layers in addition to getaddrinfo. Adds DLL-level logging to C:\openfut_hook.log for debugging. Also patches windows-sys feature flags to include Cryptography and Threading APIs needed by the new hooks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
638 lines
23 KiB
Rust
638 lines
23 KiB
Rust
use std::sync::{Arc, Mutex};
|
|
|
|
use egui::{
|
|
Color32, FontId, RichText, ScrollArea, Ui, Vec2,
|
|
};
|
|
|
|
use crate::{
|
|
config::LauncherConfig,
|
|
logs::LogBuffer,
|
|
process::{ServiceHandle, ServiceStatus},
|
|
setup,
|
|
};
|
|
|
|
#[derive(PartialEq)]
|
|
enum Tab {
|
|
Dashboard,
|
|
Logs,
|
|
Setup,
|
|
Config,
|
|
}
|
|
|
|
pub struct LauncherApp {
|
|
config: LauncherConfig,
|
|
config_dirty: bool,
|
|
|
|
core: ServiceHandle,
|
|
bridge: ServiceHandle,
|
|
core_logs: Arc<Mutex<LogBuffer>>,
|
|
bridge_logs: Arc<Mutex<LogBuffer>>,
|
|
|
|
active_tab: Tab,
|
|
log_tab: usize, // 0 = core, 1 = bridge
|
|
log_follow: bool,
|
|
|
|
// Setup state
|
|
hook_deployed: bool,
|
|
bridge_has_cap: bool,
|
|
cert_path: Option<std::path::PathBuf>,
|
|
setup_message: Option<(bool, String)>, // (success, text)
|
|
}
|
|
|
|
impl LauncherApp {
|
|
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
|
// Slightly larger default font
|
|
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 bridge_has_cap =
|
|
setup::bridge_has_cap443(std::path::Path::new(&config.bridge_binary));
|
|
|
|
Self {
|
|
config,
|
|
config_dirty: false,
|
|
core: ServiceHandle::new(),
|
|
bridge: ServiceHandle::new(),
|
|
core_logs: Arc::new(Mutex::new(LogBuffer::new())),
|
|
bridge_logs: Arc::new(Mutex::new(LogBuffer::new())),
|
|
active_tab: Tab::Dashboard,
|
|
log_tab: 0,
|
|
log_follow: true,
|
|
hook_deployed,
|
|
bridge_has_cap,
|
|
cert_path,
|
|
setup_message: None,
|
|
}
|
|
}
|
|
|
|
fn start_core(&mut self) {
|
|
let env = self.config.core_env();
|
|
if let Err(e) = self.core.start(
|
|
&self.config.core_binary.clone(),
|
|
&env,
|
|
Arc::clone(&self.core_logs),
|
|
) {
|
|
self.core_logs
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("[launcher] Failed to start core: {e}"));
|
|
}
|
|
}
|
|
|
|
fn start_bridge(&mut self) {
|
|
let env = self.config.bridge_env();
|
|
if let Err(e) = self.bridge.start(
|
|
&self.config.bridge_binary.clone(),
|
|
&env,
|
|
Arc::clone(&self.bridge_logs),
|
|
) {
|
|
self.bridge_logs
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("[launcher] Failed to start bridge: {e}"));
|
|
}
|
|
}
|
|
|
|
fn stop_all(&mut self) {
|
|
self.bridge.stop();
|
|
self.core.stop();
|
|
}
|
|
|
|
// ── UI sections ───────────────────────────────────────────────────────────
|
|
|
|
fn ui_dashboard(&mut self, ui: &mut Ui) {
|
|
ui.add_space(8.0);
|
|
ui.heading("Services");
|
|
ui.add_space(6.0);
|
|
|
|
let core_running = self.core.is_running();
|
|
let bridge_running = self.bridge.is_running();
|
|
|
|
egui::Grid::new("svc_grid")
|
|
.num_columns(4)
|
|
.spacing([16.0, 8.0])
|
|
.show(ui, |ui| {
|
|
// Header
|
|
ui.strong("Service");
|
|
ui.strong("Status");
|
|
ui.label(""); // start btn
|
|
ui.label(""); // stop btn
|
|
ui.end_row();
|
|
|
|
// Core
|
|
ui.label("openfut-core");
|
|
self.status_badge(ui, &self.core.status());
|
|
ui.add_enabled_ui(!core_running, |ui| {
|
|
if ui.button("▶ Start").clicked() {
|
|
self.start_core();
|
|
}
|
|
});
|
|
ui.add_enabled_ui(core_running, |ui| {
|
|
if ui.button("■ Stop").clicked() {
|
|
self.core.stop();
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
// Bridge
|
|
ui.label("openfut-bridge");
|
|
self.status_badge(ui, &self.bridge.status());
|
|
ui.add_enabled_ui(!bridge_running, |ui| {
|
|
if ui.button("▶ Start").clicked() {
|
|
self.start_bridge();
|
|
}
|
|
});
|
|
ui.add_enabled_ui(bridge_running, |ui| {
|
|
if ui.button("■ Stop").clicked() {
|
|
self.bridge.stop();
|
|
}
|
|
});
|
|
ui.end_row();
|
|
});
|
|
|
|
ui.add_space(12.0);
|
|
ui.separator();
|
|
ui.add_space(8.0);
|
|
|
|
// Quick-launch buttons
|
|
ui.horizontal(|ui| {
|
|
if ui
|
|
.add_sized([120.0, 32.0], egui::Button::new("▶▶ Start All"))
|
|
.clicked()
|
|
{
|
|
self.start_core();
|
|
self.start_bridge();
|
|
}
|
|
ui.add_space(8.0);
|
|
if ui
|
|
.add_sized([120.0, 32.0], egui::Button::new("■■ Stop All"))
|
|
.clicked()
|
|
{
|
|
self.stop_all();
|
|
}
|
|
});
|
|
|
|
ui.add_space(16.0);
|
|
ui.separator();
|
|
ui.add_space(8.0);
|
|
ui.heading("Quick Status");
|
|
ui.add_space(4.0);
|
|
|
|
egui::Grid::new("quick_status")
|
|
.num_columns(2)
|
|
.spacing([12.0, 4.0])
|
|
.show(ui, |ui| {
|
|
ui.label("Hook DLL:");
|
|
if self.hook_deployed {
|
|
ui.colored_label(Color32::from_rgb(80, 200, 120), "Deployed (version.dll)");
|
|
} else {
|
|
ui.colored_label(Color32::from_rgb(220, 150, 0), "Not deployed");
|
|
}
|
|
ui.end_row();
|
|
|
|
ui.label("Bridge cert:");
|
|
match &self.cert_path {
|
|
Some(p) => {
|
|
ui.colored_label(
|
|
Color32::from_rgb(80, 200, 120),
|
|
format!("Found ({})", p.file_name().unwrap_or_default().to_string_lossy()),
|
|
);
|
|
}
|
|
None => {
|
|
ui.colored_label(Color32::from_rgb(220, 150, 0), "Not generated yet — start bridge first");
|
|
}
|
|
}
|
|
ui.end_row();
|
|
|
|
ui.label("Core URL:");
|
|
ui.monospace(&self.config.core_listen_addr);
|
|
ui.end_row();
|
|
|
|
ui.label("Bridge URL:");
|
|
ui.monospace(&self.config.bridge_listen_addr);
|
|
ui.end_row();
|
|
});
|
|
}
|
|
|
|
fn ui_logs(&mut self, ui: &mut Ui) {
|
|
ui.horizontal(|ui| {
|
|
ui.selectable_value(&mut self.log_tab, 0, "Core");
|
|
ui.selectable_value(&mut self.log_tab, 1, "Bridge");
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if ui.button("Clear").clicked() {
|
|
if self.log_tab == 0 {
|
|
self.core_logs.lock().unwrap().clear();
|
|
} else {
|
|
self.bridge_logs.lock().unwrap().clear();
|
|
}
|
|
}
|
|
ui.checkbox(&mut self.log_follow, "Follow");
|
|
});
|
|
});
|
|
ui.separator();
|
|
|
|
let buf = if self.log_tab == 0 {
|
|
Arc::clone(&self.core_logs)
|
|
} else {
|
|
Arc::clone(&self.bridge_logs)
|
|
};
|
|
|
|
let follow = self.log_follow;
|
|
ScrollArea::vertical()
|
|
.auto_shrink([false, false])
|
|
.stick_to_bottom(follow)
|
|
.show(ui, |ui| {
|
|
let guard = buf.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("System Setup");
|
|
ui.add_space(4.0);
|
|
ui.label("These steps route FIFA 23 traffic through the emulator via DLL injection (no hosts file changes needed).");
|
|
ui.add_space(12.0);
|
|
|
|
// ── Hook DLL deployment ───────────────────────────────────────────────
|
|
ui.group(|ui| {
|
|
ui.set_min_width(ui.available_width());
|
|
ui.strong("Step 1 — Deploy network hook DLL");
|
|
ui.add_space(4.0);
|
|
ui.label("Copies openfut_hook.dll into the FIFA 23 folder as version.dll. \
|
|
When Proton loads the game it will intercept network calls and redirect \
|
|
EA hostnames to the local bridge — 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. Run 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 setup::deploy_hook_dll(
|
|
dll_src,
|
|
game_dir,
|
|
&self.config.hook_redirect_ip,
|
|
) {
|
|
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}"))),
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
ui.add_space(8.0);
|
|
|
|
// IP configuration — always editable; Update writes openfut.cfg in-place
|
|
ui.horizontal(|ui| {
|
|
ui.label("Redirect IP:");
|
|
let changed = ui
|
|
.add(egui::TextEdit::singleline(&mut self.config.hook_redirect_ip)
|
|
.desired_width(160.0))
|
|
.changed();
|
|
if changed {
|
|
self.config_dirty = true;
|
|
}
|
|
if ui.add_enabled(self.hook_deployed, egui::Button::new("Update")).clicked() {
|
|
match setup::update_hook_config(
|
|
Path::new(&self.config.fifa_game_dir),
|
|
&self.config.hook_redirect_ip,
|
|
) {
|
|
Ok(()) => {
|
|
self.config.save();
|
|
self.config_dirty = false;
|
|
self.setup_message = Some((true, format!(
|
|
"Config updated — hook will redirect to {}.",
|
|
self.config.hook_redirect_ip
|
|
)));
|
|
}
|
|
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
|
|
}
|
|
}
|
|
});
|
|
ui.label(egui::RichText::new("Tip: use 127.0.0.1 if the emulator is on this machine, or the LAN IP if it's on another.")
|
|
.weak().small());
|
|
|
|
ui.add_space(8.0);
|
|
ui.separator();
|
|
ui.add_space(4.0);
|
|
ui.strong("Steam Launch Options");
|
|
ui.label("Paste this into FIFA 23 → Properties → Launch Options in Steam:");
|
|
ui.add_space(2.0);
|
|
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);
|
|
|
|
// ── Port 443 capability ───────────────────────────────────────────────
|
|
ui.group(|ui| {
|
|
ui.set_min_width(ui.available_width());
|
|
ui.strong("Step 2 — Grant port 443 capability");
|
|
ui.add_space(4.0);
|
|
ui.label("Allows the bridge to bind port 443 directly — the same port FIFA 23 \
|
|
uses for HTTPS — without running as root. No iptables rules needed.");
|
|
ui.add_space(6.0);
|
|
|
|
// Clone to avoid holding a borrow on config while we mutate it below.
|
|
let binary_path = std::path::PathBuf::from(&self.config.bridge_binary);
|
|
self.bridge_has_cap = setup::bridge_has_cap443(&binary_path);
|
|
|
|
ui.horizontal(|ui| {
|
|
if self.bridge_has_cap {
|
|
ui.colored_label(Color32::from_rgb(80, 200, 120), "✔ cap_net_bind_service granted");
|
|
} else {
|
|
ui.colored_label(Color32::from_rgb(220, 60, 60), "✘ Not set");
|
|
ui.add_space(8.0);
|
|
let binary_exists = binary_path.exists();
|
|
if ui
|
|
.add_enabled(binary_exists, egui::Button::new("Grant (requires sudo)"))
|
|
.clicked()
|
|
{
|
|
match setup::setcap_bridge_443(&binary_path) {
|
|
Ok(()) => {
|
|
self.bridge_has_cap = true;
|
|
// Switch listen addr to 443 automatically
|
|
self.config.bridge_listen_addr = "0.0.0.0:443".into();
|
|
self.config.save();
|
|
self.setup_message = Some((
|
|
true,
|
|
"cap_net_bind_service granted. Bridge listen addr set to 0.0.0.0:443.".into(),
|
|
));
|
|
}
|
|
Err(e) => self.setup_message = Some((false, format!("setcap failed: {e}"))),
|
|
}
|
|
}
|
|
if !binary_exists {
|
|
ui.add_space(4.0);
|
|
ui.colored_label(
|
|
Color32::from_rgb(220, 150, 0),
|
|
"⚠ Bridge binary not found — build it first.",
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Re-run this step any time the bridge binary is rebuilt (setcap is cleared on recompile).",
|
|
)
|
|
.weak()
|
|
.small(),
|
|
);
|
|
});
|
|
|
|
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.");
|
|
|
|
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 — start the bridge at least once to generate it.",
|
|
);
|
|
}
|
|
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;
|
|
|
|
egui::Grid::new("config_grid")
|
|
.num_columns(2)
|
|
.spacing([12.0, 8.0])
|
|
.min_col_width(120.0)
|
|
.show(ui, |ui| {
|
|
ui.strong("Core");
|
|
ui.label("");
|
|
ui.end_row();
|
|
|
|
ui.label("Binary path:");
|
|
changed |= ui.text_edit_singleline(&mut self.config.core_binary).changed();
|
|
ui.end_row();
|
|
|
|
ui.label("Listen addr:");
|
|
changed |= ui.text_edit_singleline(&mut self.config.core_listen_addr).changed();
|
|
ui.end_row();
|
|
|
|
ui.label("Database URL:");
|
|
changed |= ui.text_edit_singleline(&mut self.config.core_database_url).changed();
|
|
ui.end_row();
|
|
|
|
ui.label("Data dir:");
|
|
changed |= ui.text_edit_singleline(&mut self.config.core_data_dir).changed();
|
|
ui.end_row();
|
|
|
|
ui.label("");
|
|
ui.label("");
|
|
ui.end_row();
|
|
|
|
ui.strong("Bridge");
|
|
ui.label("");
|
|
ui.end_row();
|
|
|
|
ui.label("Binary path:");
|
|
changed |= ui.text_edit_singleline(&mut self.config.bridge_binary).changed();
|
|
ui.end_row();
|
|
|
|
ui.label("Listen addr:");
|
|
changed |= ui.text_edit_singleline(&mut self.config.bridge_listen_addr).changed();
|
|
ui.end_row();
|
|
|
|
ui.label("Core URL:");
|
|
changed |= ui.text_edit_singleline(&mut self.config.bridge_core_url).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("TLS enabled:");
|
|
changed |= ui.checkbox(&mut self.config.bridge_tls_enabled, "").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();
|
|
|
|
ui.label("FIFA 23 game dir:");
|
|
changed |= ui.text_edit_singleline(&mut self.config.fifa_game_dir).changed();
|
|
ui.end_row();
|
|
|
|
ui.label("Redirect IP:");
|
|
changed |= ui.text_edit_singleline(&mut self.config.hook_redirect_ip).changed();
|
|
ui.end_row();
|
|
});
|
|
|
|
if changed {
|
|
self.config_dirty = true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
if ui.button("Reset to defaults").clicked() {
|
|
self.config = LauncherConfig::default();
|
|
self.config_dirty = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
fn status_badge(&self, ui: &mut Ui, status: &ServiceStatus) {
|
|
let color = status.color();
|
|
let label = status.label();
|
|
ui.colored_label(color, label);
|
|
}
|
|
}
|
|
|
|
impl eframe::App for LauncherApp {
|
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
// Repaint regularly to keep log views and status fresh
|
|
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)
|
|
}
|
|
}
|