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.
450 lines
16 KiB
Rust
450 lines
16 KiB
Rust
//! 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}");
|
|
}
|
|
}
|