//! 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>; fn say(log: &Log, msg: impl Into) { 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 `: /// 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}"); } }