launcher: one Launch button, driven by an explicit launch state machine

The launcher used to make the user perform OpenFUT's internal launch order by
hand — Start LSX, Start autopatch, Run pre-launch checks, "Arm client", then a
button called *Start Services & Launch Game*. Those are implementation details
of how FIFA 17 is persuaded to talk to OpenFUT, and getting the order wrong
produced failures that surfaced much later as "the game crashed": autopatch
started before ptrace_scope is 0 silently patches nothing at all.

The normal flow is now: open the launcher, read one status card, press
**Launch FIFA 17**.

New `launch` module holds the sequence as a state machine (Phase: Idle,
Checking, PreparingClient, StartingServices, Validating, Launching, Running,
Failed) and runs it on a worker thread, so the UI thread never blocks on a
socket, a Polkit prompt or a process spawn. The UI renders that state; it does
not coordinate services.

Every step asks what is already true before acting:

  - a healthy service is reused, never restarted;
  - client preparation is skipped when the checks it would repair already pass,
    which also avoids a pointless password prompt;
  - the hook config is reconciled from the current settings.

It stops at the first failed step and never starts FIFA into a client it knows
is broken. Preparation deliberately runs BEFORE autopatch, against the order in
the brief, because autopatch cannot write FIFA's memory until arming has set
ptrace_scope and would otherwise "succeed" while doing nothing.

Ownership is now tracked, which the old model could not express: it only knew
about children it had spawned, so a service started by hand for a debugging
session read as "stopped" and starting it again just collided on the port.
`ServiceSupervisor` observes our own child first, then scans /proc for a foreign
instance, and reports `ServiceRuntime { running, started_by_launcher, pid,
detail }`. `stop_permitted` refuses to kill anything the launcher did not start,
under any cleanup policy. `CleanupPolicy` states the shipped behaviour — leave
launcher-started services running for the next launch — instead of leaving it to
chance, and the FIFA-exit path goes through it.

Readiness comes from observation, never from a button press: LSX is ready only
when the port FIFA dials is actually held, and "we have not looked" renders as
"Not checked yet", never as green.

Manual controls all survive under **Advanced / Diagnostics** — per-service
start/stop/restart with PIDs and ownership, "Prepare client" (the old "Arm
client", renamed; internals still say arm), "Run pre-launch checks", "View
logs", and a new "Launch game only" escape hatch for debugging a launch the
sequence refuses.

Tests: 73 pass (15 new). Sequencing and ownership are unit-tested through a
`LaunchOps` fake, so "don't launch after a failed step", "don't restart healthy
services" and "don't kill what we didn't start" hold without a FIFA install, a
Polkit agent or root.

Exercised live under Xvfb: the card shows four observed rows and one button; a
launch stopped at LSX with "127.0.0.1:4216 is held by an unrelated process",
listed every step's verdict, and did NOT start the game; Advanced showed a real
pre-existing autopatch as "Running (foreign) · pid 382382 · started outside this
launcher" with Stop/Restart disabled.
This commit is contained in:
funman300
2026-08-17 22:44:21 +00:00
parent 504ceeec87
commit 3174fe4c1f
7 changed files with 1934 additions and 428 deletions
+21 -5
View File
@@ -42,8 +42,13 @@ fn say(log: &Log, msg: impl Into<String>) {
/// 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<()> {
/// stream into `log` on background threads. `on_exit` fires when the process
/// ends, which is how the launch state machine leaves its Running state.
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);
@@ -79,7 +84,12 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
let child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
stream(child, log.clone(), "[launcher] game process exited.");
stream(
child,
log.clone(),
"[launcher] game process exited.",
on_exit,
);
Ok(())
}
@@ -222,7 +232,12 @@ fn non_empty_file(path: &Path) -> bool {
}
/// 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) {
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 || {
@@ -242,6 +257,7 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
std::thread::spawn(move || {
let _ = child.wait();
log.lock().push(exit_msg.to_string());
on_exit();
});
}
@@ -444,7 +460,7 @@ mod tests {
game_dir: "/definitely/not/here".into(),
..GameProfile::default()
};
let err = launch(&profile, &log()).unwrap_err().to_string();
let err = launch(&profile, &log(), || {}).unwrap_err().to_string();
assert!(err.contains("game_dir does not exist"), "{err}");
}
}