A launcher tile's session stops depending on invisible state #76
@@ -266,6 +266,20 @@ pub struct LeaseRequest {
|
||||
pub spec: DetectSpec,
|
||||
/// The game's own compositor-nested-ness: `true` when a bare-spawn gamescope owns it.
|
||||
pub nested: bool,
|
||||
/// This entry opens a LAUNCHER rather than a game (design D4), which makes the lease
|
||||
/// [`LeaseKind::Untracked`] no matter what else is known about it.
|
||||
///
|
||||
/// A launcher has no "the game exited" moment to detect, and trying to infer one is worse than
|
||||
/// not trying. Steam is the clean counterexample: Big Picture is a *mode* of an already-running
|
||||
/// Steam client, not a process — and on a Deck or SteamOS host Steam is always running — so no
|
||||
/// process signal can express "the Big Picture window closed".
|
||||
///
|
||||
/// Without this flag the lifetime would also be decided by something the user cannot see:
|
||||
/// launching a launcher that is NOT yet running leaves the host holding a live child (tracked,
|
||||
/// so quitting it ends the session), while launching one that IS running has the command
|
||||
/// forward and exit inside [`SHIM_WINDOW`] (untracked, so the session persists). Same tile, two
|
||||
/// behaviours. Untracked is the honest one of the two, so it is the one that always applies.
|
||||
pub launcher: bool,
|
||||
/// The child the host spawned for this launch, when it spawned one directly, and whether it
|
||||
/// leads its own process group (see [`OwnedChild::group_leader`]).
|
||||
pub child: Option<(std::process::Child, bool)>,
|
||||
@@ -300,11 +314,18 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
|
||||
plane,
|
||||
spec,
|
||||
nested,
|
||||
launcher,
|
||||
child,
|
||||
launch_stamp,
|
||||
} = req;
|
||||
|
||||
let kind = if nested {
|
||||
// A launcher tile is untracked FIRST, before anything else is considered — see
|
||||
// `LeaseRequest::launcher`. Checking it ahead of `child` is the whole point: a launcher the host
|
||||
// just started leaves a live child behind, and tracking that child is exactly the inconsistency
|
||||
// this removes.
|
||||
let kind = if launcher {
|
||||
LeaseKind::Untracked
|
||||
} else if nested {
|
||||
LeaseKind::Nested
|
||||
} else if child.is_some() {
|
||||
LeaseKind::Child
|
||||
@@ -335,7 +356,14 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
|
||||
last_seen_ms: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
if matches!(kind, LeaseKind::Untracked) {
|
||||
if launcher {
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
app = shared.game.id.as_deref().unwrap_or("-"),
|
||||
"this entry opens a launcher, not a game — the session stays up until the client \
|
||||
leaves, and closing the launcher does not end it"
|
||||
);
|
||||
} else if matches!(kind, LeaseKind::Untracked) {
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
app = shared.game.id.as_deref().unwrap_or("-"),
|
||||
@@ -1072,12 +1100,62 @@ mod tests {
|
||||
plane: crate::events::Plane::Native,
|
||||
spec,
|
||||
nested,
|
||||
launcher: false,
|
||||
child: None,
|
||||
// No start-time floor: these leases are never matched against real processes.
|
||||
launch_stamp: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Design D4: an entry that opens a LAUNCHER is untracked, whatever else is known about it.
|
||||
///
|
||||
/// Both cases below are the same tile - "Steam Big Picture" - differing only in whether Steam
|
||||
/// happened to be running already, which the user cannot see:
|
||||
///
|
||||
/// * not running: the host's spawned child stays alive, which would otherwise be a `Child`
|
||||
/// lease, so quitting the launcher would end the session;
|
||||
/// * already running: the command forwards to the live instance and exits inside
|
||||
/// `SHIM_WINDOW`, leaving nothing to track, so the session would persist.
|
||||
///
|
||||
/// Untracked is the honest answer of the two. Big Picture is a *mode* of an already-running
|
||||
/// Steam client rather than a process, and on a Deck or SteamOS host Steam is always running,
|
||||
/// so no process signal can express "the launcher's window closed". Pinning it here keeps the
|
||||
/// tile's behaviour from depending on invisible state.
|
||||
#[test]
|
||||
fn a_launcher_entry_is_untracked_however_it_was_started() {
|
||||
// Already running: nothing held, nothing to detect.
|
||||
let mut r = req("steam:big-picture", DetectSpec::default(), false);
|
||||
r.launcher = true;
|
||||
let lease = open(r, Box::new(|| {}));
|
||||
assert!(matches!(lease.shared().kind, LeaseKind::Untracked));
|
||||
assert!(!lease.shared().is_trackable());
|
||||
|
||||
// Not running: the entry also carries detect signals, which would normally make this a
|
||||
// `Matched` lease. `launcher` outranks them.
|
||||
let mut r = req(
|
||||
"steam:big-picture-2",
|
||||
DetectSpec::exe("/usr/bin/steam"),
|
||||
false,
|
||||
);
|
||||
r.launcher = true;
|
||||
assert!(
|
||||
!r.spec.is_empty(),
|
||||
"the guard is only meaningful with signals"
|
||||
);
|
||||
let lease = open(r, Box::new(|| {}));
|
||||
assert!(matches!(lease.shared().kind, LeaseKind::Untracked));
|
||||
assert!(!lease.shared().is_trackable());
|
||||
|
||||
// The same request WITHOUT the flag is tracked - so the assertions above are the flag's
|
||||
// doing, not an artifact of the fixture.
|
||||
let plain = open(
|
||||
req("steam:570", DetectSpec::exe("/usr/bin/steam"), false),
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(plain.shared().kind, LeaseKind::Matched));
|
||||
assert!(plain.shared().is_trackable());
|
||||
}
|
||||
|
||||
/// Is a lease for `id` currently on probation?
|
||||
fn is_pending(id: &str) -> bool {
|
||||
pending_snapshot()
|
||||
@@ -1254,6 +1332,7 @@ mod tests {
|
||||
// A real signal that no process will ever match — the game never shows up.
|
||||
spec: DetectSpec::steam(999_001),
|
||||
nested: false,
|
||||
launcher: false,
|
||||
child: Some((child, false)),
|
||||
launch_stamp: None,
|
||||
},
|
||||
@@ -1312,6 +1391,7 @@ mod tests {
|
||||
plane: crate::events::Plane::Native,
|
||||
spec: DetectSpec::dir(td.path()),
|
||||
nested: false,
|
||||
launcher: false,
|
||||
child: Some((child, true)),
|
||||
launch_stamp,
|
||||
},
|
||||
|
||||
@@ -369,6 +369,7 @@ fn run(
|
||||
plane: crate::events::Plane::Gamestream,
|
||||
spec: t.detect.clone(),
|
||||
nested,
|
||||
launcher: t.launcher,
|
||||
child,
|
||||
launch_stamp,
|
||||
},
|
||||
@@ -580,6 +581,9 @@ fn open_gs_mirror_source(
|
||||
/// run it.
|
||||
struct GsApp {
|
||||
game: crate::gamelease::GameRef,
|
||||
/// This entry opens a LAUNCHER rather than a game (design D4) — carried through from
|
||||
/// [`crate::library::LaunchTarget`] so the lease can stay untracked for it.
|
||||
launcher: bool,
|
||||
detect: crate::library::DetectSpec,
|
||||
/// The resolved shell command. `Some` on Linux, which runs it itself; `None` for a Windows
|
||||
/// library title, which launches by id through the interactive-session spawner instead.
|
||||
@@ -601,6 +605,7 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option<GsApp> {
|
||||
Some(t) => {
|
||||
return Some(GsApp {
|
||||
game: t.game,
|
||||
launcher: t.launcher,
|
||||
detect: t.detect,
|
||||
command: t.command,
|
||||
})
|
||||
@@ -619,6 +624,8 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option<GsApp> {
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())?;
|
||||
Some(GsApp {
|
||||
// An operator-typed command has no library entry behind it, so it is never a launcher tile.
|
||||
launcher: false,
|
||||
game: crate::gamelease::GameRef {
|
||||
id: None,
|
||||
store: None,
|
||||
|
||||
@@ -19,6 +19,10 @@ use super::*;
|
||||
pub struct LaunchTarget {
|
||||
/// Identity for the status surface and the `game.*` events.
|
||||
pub game: crate::gamelease::GameRef,
|
||||
/// This entry opens a LAUNCHER, not a game (design D4) — so there is no "the game exited"
|
||||
/// moment to detect, and the lease stays untracked no matter what else is known about it.
|
||||
/// See [`crate::gamelease::LeaseRequest::launcher`].
|
||||
pub launcher: bool,
|
||||
/// How to recognize the running game ([`DetectSpec`]); empty when the store offers nothing.
|
||||
pub detect: DetectSpec,
|
||||
/// The resolved shell command. `Some` on Linux (where the host runs it); `None` on Windows,
|
||||
@@ -51,6 +55,7 @@ pub fn resolve_launch(id: &str) -> Option<LaunchTarget> {
|
||||
let command = entry.launch.as_ref().and_then(command_for)?;
|
||||
Some(LaunchTarget {
|
||||
game,
|
||||
launcher: entry.role == GameRole::Launcher,
|
||||
detect: entry.detect,
|
||||
command: Some(command),
|
||||
})
|
||||
@@ -62,6 +67,7 @@ pub fn resolve_launch(id: &str) -> Option<LaunchTarget> {
|
||||
// the existing warning fires there.
|
||||
Some(LaunchTarget {
|
||||
game,
|
||||
launcher: entry.role == GameRole::Launcher,
|
||||
detect: entry.detect,
|
||||
command: None,
|
||||
})
|
||||
|
||||
@@ -1715,6 +1715,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plane: crate::events::Plane::Native,
|
||||
spec: target.detect.clone(),
|
||||
nested,
|
||||
launcher: target.launcher,
|
||||
child,
|
||||
launch_stamp,
|
||||
},
|
||||
|
||||
@@ -381,6 +381,7 @@ mod tests {
|
||||
// No signals: an inert lease, so no watcher thread races this test's assertions.
|
||||
spec: crate::library::DetectSpec::default(),
|
||||
nested: false,
|
||||
launcher: false,
|
||||
child: None,
|
||||
launch_stamp: None,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user