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:
@@ -0,0 +1,449 @@
|
||||
//! Launch the game directly, without an external shell script.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! The launcher used to shell out to a user-written script (`game_launch_command`)
|
||||
//! that set the Proton environment, prepared the Wine prefix, regenerated the
|
||||
//! DRM licence and finally ran the game. That script lived on the user's Desktop
|
||||
//! — and on 2026-08-11 it was moved to the Trash, after which every launch failed
|
||||
//! with `sh: No such file or directory`. Three unrelated client-side faults that
|
||||
//! morning each looked like "the game crashed"; none of them were.
|
||||
//!
|
||||
//! Everything the script did is mechanical and belongs inside the launcher, where
|
||||
//! it cannot be deleted, is covered by tests, and reports failures into the same
|
||||
//! log buffer as the rest of the launch.
|
||||
//!
|
||||
//! # What stays out of this file
|
||||
//!
|
||||
//! Every FIFA-17 fact — the runner, the executable, the prefix path, the `w:`
|
||||
//! drive symlink, the licence file id — is [`GameProfile`] *data*, not code.
|
||||
//! OpenFUT is not a FIFA 17 project; FIFA 17 is its first reference target. A
|
||||
//! second game must be a different profile, never a second branch in here.
|
||||
//!
|
||||
//! `game_launch_command` remains as an escape hatch: an unconfigured profile
|
||||
//! falls back to it, so an existing working setup cannot be broken by upgrading.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::config::GameProfile;
|
||||
use crate::logs::LogBuffer;
|
||||
|
||||
type Log = Arc<Mutex<LogBuffer>>;
|
||||
|
||||
fn say(log: &Log, msg: impl Into<String>) {
|
||||
log.lock().unwrap().push(msg.into());
|
||||
}
|
||||
|
||||
/// Prepare the prefix, satisfy the licence precondition, and start the game.
|
||||
///
|
||||
/// Returns once the game process has been spawned; its output continues to
|
||||
/// stream into `log` on background threads.
|
||||
pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
profile.validate().map_err(anyhow::Error::msg)?;
|
||||
|
||||
let game_dir = PathBuf::from(&profile.game_dir);
|
||||
if !game_dir.is_dir() {
|
||||
anyhow::bail!("game_dir does not exist: {}", game_dir.display());
|
||||
}
|
||||
|
||||
prepare_prefix(profile, log)?;
|
||||
ensure_license(profile, log)?;
|
||||
|
||||
let mut cmd = Command::new(&profile.runner);
|
||||
cmd.arg(&profile.executable)
|
||||
.current_dir(&game_dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
for (k, v) in &profile.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
if !profile.wine_prefix.trim().is_empty() {
|
||||
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
||||
}
|
||||
|
||||
say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] launching {} {} (cwd {})",
|
||||
profile.runner,
|
||||
profile.executable,
|
||||
game_dir.display()
|
||||
),
|
||||
);
|
||||
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
|
||||
stream(child, log.clone(), "[launcher] game process exited.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the Wine prefix's `dosdevices` entries the profile asks for.
|
||||
///
|
||||
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
|
||||
/// an existing link is replaced, so re-running is harmless.
|
||||
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let prefix = PathBuf::from(&profile.wine_prefix);
|
||||
for link in &profile.prefix_links {
|
||||
let path = prefix.join(&link.link);
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("prefix link has no parent: {}", link.link))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
// Replace rather than fail: `ln -sfn` semantics. Only ever remove a
|
||||
// symlink — refusing on a real file avoids destroying prefix contents
|
||||
// if a profile is misconfigured.
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_file(&path)?,
|
||||
Ok(_) => anyhow::bail!(
|
||||
"refusing to replace {}: it exists and is not a symlink",
|
||||
path.display()
|
||||
),
|
||||
Err(_) => {}
|
||||
}
|
||||
std::os::unix::fs::symlink(&link.target, &path)?;
|
||||
say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] prefix link {} -> {}",
|
||||
path.display(),
|
||||
link.target
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make sure the DRM licence file exists, running the generator if it does not.
|
||||
///
|
||||
/// A crashed or failed launch deletes the licence, so this runs before every
|
||||
/// launch rather than only on first setup — that is the behaviour the shell
|
||||
/// script proved, and it is why a crash is normally self-healing on the next try.
|
||||
fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
let Some(lic) = &profile.license else {
|
||||
return Ok(());
|
||||
};
|
||||
let path = resolve_under_prefix(&profile.wine_prefix, &lic.path);
|
||||
if non_empty_file(&path) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] licence missing ({}) — running {} to regenerate it",
|
||||
path.display(),
|
||||
lic.generator
|
||||
),
|
||||
);
|
||||
|
||||
let mut cmd = Command::new(&profile.runner);
|
||||
cmd.arg(&lic.generator)
|
||||
.current_dir(&profile.game_dir)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
for (k, v) in &profile.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
if !profile.wine_prefix.trim().is_empty() {
|
||||
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
||||
}
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("could not start licence generator: {e}"))?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(lic.timeout_secs.max(1));
|
||||
while Instant::now() < deadline {
|
||||
if non_empty_file(&path) {
|
||||
stop_generator(&mut child, lic, log);
|
||||
say(log, "[launcher] licence regenerated.");
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
stop_generator(&mut child, lic, log);
|
||||
anyhow::bail!(
|
||||
"{} did not create {} within {}s. Run it manually, choose GENERATE, then launch again.",
|
||||
lic.generator,
|
||||
path.display(),
|
||||
lic.timeout_secs
|
||||
)
|
||||
}
|
||||
|
||||
/// Stop the licence generator and the Windows process it started.
|
||||
///
|
||||
/// Killing the runner is not enough: it launches the executable through Proton,
|
||||
/// so the `.exe` outlives its parent. The shell script used `pkill -f` for this
|
||||
/// and it is reproduced deliberately — the pattern is a Windows executable name,
|
||||
/// which cannot match the launcher or a shell running it. (A `pkill -f` pattern
|
||||
/// that *can* match its own caller is a real hazard; this one cannot.)
|
||||
fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
match Command::new("pkill").arg("-f").arg(&lic.generator).status() {
|
||||
Ok(_) => {}
|
||||
Err(e) => say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] note: could not run pkill for {}: {e}",
|
||||
lic.generator
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A relative licence path is taken as relative to the Wine prefix; an absolute
|
||||
/// one is used as given.
|
||||
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() || prefix.trim().is_empty() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
Path::new(prefix).join(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// The script's `[[ -s FILE ]]`: present *and* non-empty. A zero-byte licence is
|
||||
/// as useless as a missing one, and treating it as valid would skip the
|
||||
/// regeneration that fixes it.
|
||||
fn non_empty_file(path: &Path) -> bool {
|
||||
std::fs::metadata(path)
|
||||
.map(|m| m.len() > 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Pump a child's stdout and stderr into the log buffer and reap it.
|
||||
pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let buf = Arc::clone(&log);
|
||||
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 = Arc::clone(&log);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
log.lock().unwrap().push(exit_msg.to_string());
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{LicenseCheck, PrefixLink};
|
||||
|
||||
fn log() -> Log {
|
||||
Arc::new(Mutex::new(LogBuffer::new()))
|
||||
}
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let d =
|
||||
std::env::temp_dir().join(format!("openfut-launch-test-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_licence_path_is_resolved_under_the_prefix() {
|
||||
assert_eq!(
|
||||
resolve_under_prefix("/p", "drive_c/lic.dlf"),
|
||||
PathBuf::from("/p/drive_c/lic.dlf")
|
||||
);
|
||||
// Absolute wins, so a profile can point outside the prefix.
|
||||
assert_eq!(
|
||||
resolve_under_prefix("/p", "/elsewhere/lic.dlf"),
|
||||
PathBuf::from("/elsewhere/lic.dlf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_byte_licence_does_not_count_as_present() {
|
||||
let d = tmpdir("empty-lic");
|
||||
let f = d.join("lic.dlf");
|
||||
std::fs::write(&f, b"").unwrap();
|
||||
assert!(
|
||||
!non_empty_file(&f),
|
||||
"an empty licence must trigger regeneration"
|
||||
);
|
||||
std::fs::write(&f, b"x").unwrap();
|
||||
assert!(non_empty_file(&f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_links_are_created_and_are_idempotent() {
|
||||
let d = tmpdir("links");
|
||||
let prefix = d.join("prefix");
|
||||
let target = d.join("target");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "true".into(),
|
||||
executable: "x.exe".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: prefix.to_string_lossy().into(),
|
||||
prefix_links: vec![PrefixLink {
|
||||
link: "dosdevices/w:".into(),
|
||||
target: target.to_string_lossy().into(),
|
||||
}],
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
prepare_prefix(&profile, &log()).expect("first run creates the link");
|
||||
let link = prefix.join("dosdevices/w:");
|
||||
assert!(std::fs::symlink_metadata(&link)
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink());
|
||||
|
||||
// Re-running must not fail — the launcher prepares the prefix on EVERY
|
||||
// launch, so a second launch would break if this were not idempotent.
|
||||
prepare_prefix(&profile, &log()).expect("second run replaces the link");
|
||||
assert_eq!(std::fs::read_link(&link).unwrap(), target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_file_where_a_link_belongs_is_refused_not_deleted() {
|
||||
let d = tmpdir("clobber");
|
||||
let prefix = d.join("prefix");
|
||||
std::fs::create_dir_all(prefix.join("dosdevices")).unwrap();
|
||||
let occupied = prefix.join("dosdevices/w:");
|
||||
std::fs::write(&occupied, b"important").unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "true".into(),
|
||||
executable: "x.exe".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: prefix.to_string_lossy().into(),
|
||||
prefix_links: vec![PrefixLink {
|
||||
link: "dosdevices/w:".into(),
|
||||
target: "/tmp".into(),
|
||||
}],
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
assert!(prepare_prefix(&profile, &log()).is_err());
|
||||
assert_eq!(
|
||||
std::fs::read(&occupied).unwrap(),
|
||||
b"important",
|
||||
"a misconfigured profile must not destroy prefix contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_present_licence_skips_the_generator_entirely() {
|
||||
let d = tmpdir("lic-present");
|
||||
let lic = d.join("lic.dlf");
|
||||
std::fs::write(&lic, b"valid").unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "/nonexistent/runner".into(), // would fail if it were run
|
||||
executable: "x.exe".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: d.to_string_lossy().into(),
|
||||
license: Some(LicenseCheck {
|
||||
path: "lic.dlf".into(),
|
||||
generator: "_gen.exe".into(),
|
||||
timeout_secs: 1,
|
||||
}),
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
// Proves the skip: the runner path is invalid, so reaching the generator
|
||||
// would error. Ok() means it never tried.
|
||||
ensure_license(&profile, &log()).expect("present licence must short-circuit");
|
||||
}
|
||||
|
||||
/// The whole point of the licence step: a missing licence must actually run
|
||||
/// the generator and wait for it. Without this, deleting `ensure_license`
|
||||
/// entirely would still pass every other test in this file.
|
||||
#[test]
|
||||
fn a_missing_licence_runs_the_generator_and_waits_for_it() {
|
||||
let d = tmpdir("lic-regen");
|
||||
let lic = d.join("lic.dlf");
|
||||
let gen = d.join("gen.sh");
|
||||
// Sleeps first, so passing requires actually waiting rather than
|
||||
// happening to observe a file that was already there. The target path
|
||||
// is baked in: `generator` is passed as ONE argument, exactly as
|
||||
// `umu-run "_fifa17.exe"` is.
|
||||
std::fs::write(
|
||||
&gen,
|
||||
format!(
|
||||
"#!/bin/sh\nsleep 1\nprintf licensed > '{}'\n",
|
||||
lic.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "/bin/sh".into(),
|
||||
executable: "unused".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: d.to_string_lossy().into(),
|
||||
license: Some(LicenseCheck {
|
||||
generator: gen.to_string_lossy().into(),
|
||||
path: "lic.dlf".into(),
|
||||
timeout_secs: 10,
|
||||
}),
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
assert!(!non_empty_file(&lic));
|
||||
ensure_license(&profile, &log()).expect("generator should produce the licence");
|
||||
assert!(non_empty_file(&lic), "licence was not created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_generator_that_never_delivers_times_out_with_an_actionable_error() {
|
||||
let d = tmpdir("lic-timeout");
|
||||
let gen = d.join("gen.sh");
|
||||
std::fs::write(&gen, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
let profile = GameProfile {
|
||||
runner: "/bin/sh".into(),
|
||||
executable: "unused".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: d.to_string_lossy().into(),
|
||||
license: Some(LicenseCheck {
|
||||
generator: gen.to_string_lossy().into(), // runs, writes nothing
|
||||
path: "lic.dlf".into(),
|
||||
timeout_secs: 1,
|
||||
}),
|
||||
..GameProfile::default()
|
||||
};
|
||||
let err = ensure_license(&profile, &log()).unwrap_err().to_string();
|
||||
assert!(err.contains("did not create"), "{err}");
|
||||
assert!(
|
||||
err.contains("GENERATE"),
|
||||
"the error must say what to do: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_refuses_a_missing_game_dir_before_touching_anything() {
|
||||
let profile = GameProfile {
|
||||
runner: "true".into(),
|
||||
executable: "x.exe".into(),
|
||||
game_dir: "/definitely/not/here".into(),
|
||||
..GameProfile::default()
|
||||
};
|
||||
let err = launch(&profile, &log()).unwrap_err().to_string();
|
||||
assert!(err.contains("game_dir does not exist"), "{err}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user