feat(launcher): one-click client arming + modular preflight/services

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.
This commit is contained in:
funman300
2026-08-12 17:58:48 +00:00
parent 87241acc1a
commit d619c992c1
17 changed files with 3814 additions and 511 deletions
+79 -37
View File
@@ -1,25 +1,7 @@
use std::{path::{Path, PathBuf}, process::Command};
// ── Port 443 capability ───────────────────────────────────────────────────────
/// Check whether the bridge binary already has cap_net_bind_service set.
pub fn bridge_has_cap443(binary: &Path) -> bool {
std::process::Command::new("getcap")
.arg(binary)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("cap_net_bind_service"))
.unwrap_or(false)
}
/// Grant cap_net_bind_service to the bridge binary so it can bind port 443
/// without running as root. Uses pkexec (or sudo as fallback).
pub fn setcap_bridge_443(binary: &Path) -> anyhow::Result<()> {
let script = format!(
"setcap cap_net_bind_service=+ep '{}'",
binary.to_string_lossy()
);
run_elevated(&script)
}
use std::{
path::{Path, PathBuf},
process::Command,
};
// ── Cert installation ─────────────────────────────────────────────────────────
@@ -67,17 +49,13 @@ fn try_wine_certutil(cert_src: &Path) -> anyhow::Result<()> {
}
}
fn run_elevated(script: &str) -> anyhow::Result<()> {
let status = Command::new("pkexec")
.args(["sh", "-c", script])
.status();
pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> {
let status = Command::new("pkexec").args(["sh", "-c", script]).status();
match status {
Ok(s) if s.success() => Ok(()),
_ => {
let s = Command::new("sudo")
.args(["sh", "-c", script])
.status()?;
let s = Command::new("sudo").args(["sh", "-c", script]).status()?;
if s.success() {
Ok(())
} else {
@@ -90,10 +68,13 @@ fn run_elevated(script: &str) -> anyhow::Result<()> {
// ── DLL hook deployment ───────────────────────────────────────────────────────
/// Deploy openfut_hook.dll into the FIFA 23 game directory and write
/// openfut.cfg with the redirect IP the hook will use.
/// openfut.cfg with the structured server configuration the hook reads.
/// `cfg_contents` must be the full `openfut.cfg` body (see
/// `LauncherConfig::hook_cfg_contents`) — this function does not invent any
/// address itself, so a missing server can never silently become loopback.
/// Uses `version.dll` as the hijack name — FIFA 23 loads it but defers to
/// the system copy, so Proton picks up our local one first.
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, redirect_ip: &str) -> anyhow::Result<()> {
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
if !dll_src.exists() {
anyhow::bail!(
"Hook DLL not found at {}. Build it first with:\n\
@@ -104,17 +85,18 @@ pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, redirect_ip: &str) -> an
}
std::fs::create_dir_all(game_dir)?;
std::fs::copy(dll_src, game_dir.join("version.dll"))?;
std::fs::write(game_dir.join("openfut.cfg"), redirect_ip)?;
std::fs::write(game_dir.join("openfut.cfg"), cfg_contents)?;
Ok(())
}
/// Update only openfut.cfg without redeploying the DLL.
pub fn update_hook_config(game_dir: &Path, redirect_ip: &str) -> anyhow::Result<()> {
/// Update only openfut.cfg without redeploying the DLL. `cfg_contents` is the
/// full structured `openfut.cfg` body.
pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
let cfg = game_dir.join("openfut.cfg");
if !cfg.exists() {
anyhow::bail!("Hook DLL not deployed yet — deploy first.");
}
std::fs::write(cfg, redirect_ip)?;
std::fs::write(cfg, cfg_contents)?;
Ok(())
}
@@ -134,5 +116,65 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
/// The Steam launch options the user needs to paste in to enable the override.
/// Proton loads local DLLs named in WINEDLLOVERRIDES ahead of system ones.
pub const STEAM_LAUNCH_OPTIONS: &str =
"WINEDLLOVERRIDES=\"version=n,b\" %command%";
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
// ── Game launch ───────────────────────────────────────────────────────────────
/// Launch the game via the user-provided shell command. Runs `sh -c <command>`
/// (optionally from `workdir`), streaming stdout+stderr into `log_buf` on a
/// background thread. The launcher does not assume Steam vs umu-run vs a custom
/// script — whatever the user configured is what runs.
pub fn launch_game(
command: &str,
workdir: &str,
log_buf: std::sync::Arc<std::sync::Mutex<crate::logs::LogBuffer>>,
) -> anyhow::Result<()> {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
if command.trim().is_empty() {
anyhow::bail!("No game launch command configured (set it in the Config tab).");
}
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(command);
if !workdir.trim().is_empty() {
cmd.current_dir(workdir);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
log_buf
.lock()
.unwrap()
.push(format!("[launcher] launching game: {command}"));
let mut child = cmd.spawn()?;
if let Some(out) = child.stdout.take() {
let buf = std::sync::Arc::clone(&log_buf);
std::thread::spawn(move || {
for line in BufReader::new(out).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line);
}
});
}
if let Some(err) = child.stderr.take() {
let buf = std::sync::Arc::clone(&log_buf);
std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line);
}
});
}
// Reap the child in the background so a finished game doesn't linger as a
// zombie; we don't block the UI on it.
std::thread::spawn(move || {
let _ = child.wait();
log_buf
.lock()
.unwrap()
.push("[launcher] game process exited.".to_string());
});
Ok(())
}