057cf92c3b
Port the egui launcher to run natively on Windows (no Wine/Proton). The GUI,
launch state machine, config, health/account monitors, and openfut.cfg writing
are unchanged and cross-platform; only the effect layer is branched:
- game_launch: cfg(windows) launch spawns the game executable directly with its
working dir (the version.dll hijack loads from the game dir; no WINEDLLOVERRIDES,
Wine prefix, or licence regen). Requires the launcher to run elevated so the
child inherits admin. Linux Proton path gated cfg(unix).
- arm: cfg(windows) is a no-op (routing is openfut.cfg, written by the client-files
step; no ptrace_scope/DNAT/hosts). Linux arming gated cfg(unix).
- local_services: on Windows LSX/autopatch are in-process (stp-origin_emu.dll +
version.dll hook), so ensure_running reports ready without spawning. Gated the
unix-only CommandExt/process_group.
- preflight: cfg(windows) run() keeps only backend-reachable + hook-config checks.
- config: GameProfile configured()/validate() accept a runner-less Windows profile.
theme: fix a latent cross-platform panic — egui 0.29 keeps a Style per theme, so
set_style only reached the active one and TextStyle::resolve("Hero") panicked when
the other theme rendered. Install the full style into both themes and pin Dark.
Cross-built for x86_64-pc-windows-gnu; Linux build + 75 tests unchanged.
685 lines
24 KiB
Rust
685 lines
24 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 parking_lot::Mutex;
|
|
#[cfg(unix)]
|
|
use std::collections::BTreeMap;
|
|
use std::io::{BufRead, BufReader};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::Arc;
|
|
#[cfg(unix)]
|
|
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().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. `on_exit` fires when the process
|
|
/// ends, which is how the launch state machine leaves its Running state.
|
|
#[cfg(unix)]
|
|
pub fn launch(
|
|
profile: &GameProfile,
|
|
log: &Log,
|
|
on_exit: impl FnOnce() + Send + 'static,
|
|
) -> 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_dll_override(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);
|
|
}
|
|
cmd.env("WINEDLLOVERRIDES", hook_dll_overrides(&profile.env));
|
|
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.",
|
|
on_exit,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Windows-native launch: no Wine prefix, no `WINEDLLOVERRIDES` (the game loads
|
|
/// the `version.dll` hook from its own directory through the normal search
|
|
/// order), and no licence regeneration (the native loader handles DRM).
|
|
/// Routing is the `openfut.cfg` that the client-files step already wrote into
|
|
/// the game directory.
|
|
///
|
|
/// The launcher must itself be running elevated (its shortcut carries the
|
|
/// RunAsAdmin bit): the loader requires administrator rights, and a child
|
|
/// started with `CreateProcess` inherits the launcher's token instead of
|
|
/// raising its own UAC prompt.
|
|
#[cfg(windows)]
|
|
pub fn launch(
|
|
profile: &GameProfile,
|
|
log: &Log,
|
|
on_exit: impl FnOnce() + Send + 'static,
|
|
) -> 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());
|
|
}
|
|
let exe = game_dir.join(&profile.executable);
|
|
if !exe.is_file() {
|
|
anyhow::bail!("game executable not found: {}", exe.display());
|
|
}
|
|
|
|
let mut cmd = Command::new(&exe);
|
|
cmd.current_dir(&game_dir)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
for (k, v) in &profile.env {
|
|
cmd.env(k, v);
|
|
}
|
|
|
|
say(
|
|
log,
|
|
format!(
|
|
"[launcher] launching {} (cwd {})",
|
|
exe.display(),
|
|
game_dir.display()
|
|
),
|
|
);
|
|
|
|
let child = cmd
|
|
.spawn()
|
|
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", exe.display()))?;
|
|
stream(
|
|
child,
|
|
log.clone(),
|
|
"[launcher] game process exited.",
|
|
on_exit,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// The registry key Wine reads DLL overrides from, and the one value the hook needs.
|
|
///
|
|
/// Wine loads its own builtin `version.dll` unless an override says otherwise, so the
|
|
/// game-directory proxy is ignored by default. `WINEDLLOVERRIDES` fixes that only for
|
|
/// a process we spawn ourselves — it cannot help a player who presses Play in Steam,
|
|
/// which is why the old advice was to paste launch options by hand (see
|
|
/// `setup::STEAM_LAUNCH_OPTIONS`). Asking a player to edit launch options is exactly
|
|
/// the kind of step that makes this unusable for anyone who does not already know what
|
|
/// a DLL override is.
|
|
///
|
|
/// Persisting the override in the prefix registry removes the manual step entirely: it
|
|
/// survives restarts and applies to every launch path, including Steam. This mirrors
|
|
/// what BepInEx documents for Proton (configure the proxy in winecfg rather than the
|
|
/// environment) and what Proton itself already does in this prefix for other titles.
|
|
#[cfg(unix)]
|
|
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
|
|
#[cfg(unix)]
|
|
const HOOK_DLL_VALUE: &str = "version";
|
|
#[cfg(unix)]
|
|
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
|
|
|
|
/// `reg add` argv that persists the hook's DLL override, native-first with a builtin
|
|
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
|
|
/// repairs a prefix a player has reset or replaced.
|
|
#[cfg(unix)]
|
|
fn dll_override_args() -> [&'static str; 10] {
|
|
[
|
|
"reg",
|
|
"add",
|
|
DLL_OVERRIDE_KEY,
|
|
"/v",
|
|
HOOK_DLL_VALUE,
|
|
"/t",
|
|
"REG_SZ",
|
|
"/d",
|
|
HOOK_DLL_OVERRIDE,
|
|
"/f",
|
|
]
|
|
}
|
|
|
|
/// Persist the hook's DLL override into the prefix, so the game loads the proxy no
|
|
/// matter how it is started.
|
|
///
|
|
/// Best-effort by design: a failure here is not fatal, because a launch we spawn also
|
|
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
|
|
/// error, since the player cannot act on the latter.
|
|
#[cfg(unix)]
|
|
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
|
|
if profile.wine_prefix.trim().is_empty() {
|
|
return;
|
|
}
|
|
let mut cmd = Command::new(&profile.runner);
|
|
cmd.args(dll_override_args())
|
|
.current_dir(&profile.game_dir)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
for (k, v) in &profile.env {
|
|
cmd.env(k, v);
|
|
}
|
|
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
|
match cmd.status() {
|
|
Ok(status) if status.success() => {
|
|
say(log, "[launcher] game files ready (mod support enabled)");
|
|
}
|
|
Ok(_) | Err(_) => say(
|
|
log,
|
|
"[launcher] could not pre-enable mod support in the game prefix; \
|
|
launching anyway (this launch still enables it directly)",
|
|
),
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, unix))]
|
|
mod override_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn dll_override_is_persisted_native_first_and_idempotently() {
|
|
let args = dll_override_args();
|
|
assert_eq!(args[0], "reg");
|
|
assert_eq!(args[1], "add");
|
|
assert_eq!(
|
|
args[2], r"HKCU\Software\Wine\DllOverrides",
|
|
"Wine reads overrides from this key; a typo silently leaves the hook unloaded"
|
|
);
|
|
assert_eq!(args[4], "version", "the hook ships as a version.dll proxy");
|
|
assert_eq!(
|
|
args[8], "native,builtin",
|
|
"native first so the proxy wins, builtin as fallback so a missing proxy \
|
|
cannot make the game unlaunchable"
|
|
);
|
|
assert_eq!(
|
|
args[9], "/f",
|
|
"idempotent, so running it on every launch repairs a reset prefix"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The `WINEDLLOVERRIDES` value the game must be started with.
|
|
///
|
|
/// The hook ships as a `version.dll` proxy inside the game directory, and Proton
|
|
/// prefers a local DLL over its own builtin ONLY when `WINEDLLOVERRIDES` names it
|
|
/// (see `setup::STEAM_LAUNCH_OPTIONS`). Steam users get that from their launch
|
|
/// options; when the launcher spawns the runner itself, nothing else supplies it.
|
|
///
|
|
/// Without it the failure is silent and badly misleading: the hook never loads, so
|
|
/// the `openfut.cfg` the launcher just wrote is inert, the game ignores the
|
|
/// configured Blaze ports, and `/etc/hosts` quietly routes it to whatever answers
|
|
/// on EA's real ports. It looks like a working launch against the configured
|
|
/// server while actually talking to a different one.
|
|
///
|
|
/// A profile that already pins `version=` wins: an operator overriding the hijack
|
|
/// deliberately must not be silently overruled.
|
|
#[cfg(unix)]
|
|
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
|
|
const HOOK: &str = "version=n,b";
|
|
match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) {
|
|
Some(existing) if existing.contains("version=") => existing.to_string(),
|
|
Some(existing) if !existing.is_empty() => format!("{existing};{HOOK}"),
|
|
_ => HOOK.to_string(),
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
#[cfg(unix)]
|
|
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.
|
|
#[cfg(unix)]
|
|
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.)
|
|
#[cfg(unix)]
|
|
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.
|
|
#[cfg(unix)]
|
|
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.
|
|
#[cfg(unix)]
|
|
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,
|
|
on_exit: impl FnOnce() + Send + 'static,
|
|
) {
|
|
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().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().push(line);
|
|
}
|
|
});
|
|
}
|
|
std::thread::spawn(move || {
|
|
let _ = child.wait();
|
|
log.lock().push(exit_msg.to_string());
|
|
on_exit();
|
|
});
|
|
}
|
|
|
|
#[cfg(all(test, unix))]
|
|
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}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_profile_without_overrides_still_gets_the_hook_hijack() {
|
|
// The regression this guards: FIFA launched from the launcher ignored the
|
|
// configured Blaze ports entirely, because Proton loaded its own builtin
|
|
// version.dll and the hook proxy never ran. The launch looked healthy.
|
|
assert_eq!(hook_dll_overrides(&BTreeMap::new()), "version=n,b");
|
|
}
|
|
|
|
#[test]
|
|
fn unrelated_overrides_are_preserved_and_appended_to() {
|
|
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "d3d11=n".to_string())]);
|
|
assert_eq!(hook_dll_overrides(&env), "d3d11=n;version=n,b");
|
|
}
|
|
|
|
#[test]
|
|
fn an_explicit_version_override_is_never_overruled() {
|
|
// An operator disabling the hijack on purpose must win, otherwise the
|
|
// setting is a lie.
|
|
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "version=b".to_string())]);
|
|
assert_eq!(hook_dll_overrides(&env), "version=b");
|
|
}
|
|
|
|
#[test]
|
|
fn a_blank_override_is_treated_as_absent_rather_than_appended_to() {
|
|
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), " ".to_string())]);
|
|
assert_eq!(hook_dll_overrides(&env), "version=n,b");
|
|
}
|
|
}
|