feat(library/providers): let a provider say how to recognize its games

A plugin's titles launch through the provider's own client, which hands off and
exits — so the host had nothing left to watch, and both lifetime behaviors went
quiet for exactly the entries a provider contributes. A `ProviderEntry` (and a
manual custom entry) may now carry an optional `detect` hint: install dir, exe,
or process name.

It is deliberately a subset of what the host tracks internally. A Steam appid or
a launcher's environment marker are things the host discovers for itself and
would be meaningless — or dangerous — to take on someone's word; where a title
is installed is something only the provider knows. The host's own findings win
where both exist, so a stale export can never redirect the matcher, and a blank
field is treated as absent rather than as "match everything" — an empty install
dir would otherwise prefix-match every process on the box, and this feature can
end processes.

`process_name` is the weakest of the three and the only one typed by hand, so it
is matched case-insensitively against the image's file name and nothing else:
`retroarch` finds RetroArch, not a helper whose name merely starts the same way,
and not a script that happens to live in a `retroarch/` directory. The
never-adopt-a-pre-existing-process rule still bounds it.

Also: the tray summary gains the running-game row (with the closing-in countdown
for a game whose client is gone — visible at the machine without opening the
console), the SDK mirrors the `game.*` events, and its generated client catches
up with the endpoints Phase 1 added.

Gates on .21: check + clippy --all-targets clean, 299 tests, fmt CI-parity,
openapi regenerated (GameEntry still carries no `detect` outbound); SDK tsc +
54 tests green.
This commit is contained in:
2026-07-26 18:28:13 +02:00
parent 98121ccd53
commit 110eabf281
12 changed files with 462 additions and 22 deletions
@@ -147,6 +147,19 @@ impl Scanner {
return true;
}
}
// A bare executable name, the operator-supplied fallback. Case-insensitive because it is typed
// by hand and the cost of a case mismatch (the game is never recognized) far outweighs the
// cost of a case collision (two differently-cased binaries, both started since this launch).
if let Some(want) = spec.process_name.as_deref() {
let named = image
.as_deref()
.and_then(|i| i.file_name())
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case(want));
if named {
return true;
}
}
// The command line covers what the image can't: a Proton/Wine title's image is the *runtime*
// (`…/proton`, `wine64-preloader`), and the game only appears as an argument; Steam's launch
@@ -359,6 +372,29 @@ mod tests {
);
}
/// The operator-supplied fallback ([`DetectSpec::process_name`]): the image's own file name and
/// nothing else — never a directory that happens to be called the same, and never a process whose
/// path merely contains the name.
#[test]
fn matches_a_bare_process_name_against_the_image_name_only() {
let td = fake_proc_root(
1000.0,
&[
FakeProc::new(20, 50_000).exe("/opt/retroarch/bin/RetroArch"),
FakeProc::new(21, 50_000).exe("/usr/bin/retroarch-assets-helper"),
FakeProc::new(22, 50_000).exe("/home/p/retroarch/launcher.sh"),
],
);
let s = scanner(td.path());
let spec = DetectSpec {
process_name: Some("retroarch".into()),
..Default::default()
};
// Case-insensitive (the name is typed by hand), but a whole-name match: neither the
// longer-named helper nor the script living in a `retroarch/` directory qualifies.
assert_eq!(pids(s.find(&spec, None)), vec![20]);
}
#[test]
fn install_dir_does_not_match_a_sibling_with_the_same_prefix() {
// `/games/x` must not adopt a process running out of `/games/xyz` — a real hazard when one