Merge pull request 'A launcher tile's session stops depending on invisible state' (#76) from worktree-launcher-lease into main
arch / build-publish (push) Failing after 16s
ci / bun-nix (push) Successful in 42s
ci / web (push) Successful in 1m11s
ci / docs-site (push) Successful in 1m24s
apple / swift (push) Successful in 1m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 54s
android / android (push) Failing after 3m51s
ci / rust (push) Failing after 3m48s
docker / builders-arm64cross (push) Failing after 19s
deb / build-publish (push) Successful in 3m54s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
ci / rust-arm64 (push) Successful in 5m32s
docker / deploy-docs (push) Successful in 32s
apple / screenshots (push) Successful in 5m58s
deb / build-publish-host (push) Successful in 7m57s
windows-host / package (push) Failing after 11m43s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m49s

Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
2026-08-06 17:25:44 +00:00
5 changed files with 97 additions and 2 deletions
+82 -2
View File
@@ -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,
},