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:
+21
-5
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user