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:
@@ -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
|
||||
|
||||
@@ -62,9 +62,9 @@ impl Scanner {
|
||||
/// Every process matching any of `spec`'s signals, restricted to those that started at or after
|
||||
/// `min_start` (seconds on the [`Self::now_stamp`] timeline; `None` disables the filter).
|
||||
pub fn find(&self, spec: &DetectSpec, min_start: Option<f64>) -> Vec<ProcRef> {
|
||||
// Only the path-based signals exist on Windows: no reaper argv, no readable environment. A spec
|
||||
// carrying neither must match *nothing* — falling through would scan on an empty predicate.
|
||||
if spec.exe.is_none() && spec.install_dir.is_none() {
|
||||
// Only the image-based signals exist on Windows: no reaper argv, no readable environment. A
|
||||
// spec carrying none must match *nothing* — falling through would scan on an empty predicate.
|
||||
if spec.exe.is_none() && spec.install_dir.is_none() && spec.process_name.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let exe = spec
|
||||
@@ -91,7 +91,13 @@ impl Scanner {
|
||||
}
|
||||
}
|
||||
let hit = exe.as_deref().is_some_and(|w| same_path(&image, w))
|
||||
|| dir.as_deref().is_some_and(|d| under_dir(&image, d));
|
||||
|| dir.as_deref().is_some_and(|d| under_dir(&image, d))
|
||||
// The operator-supplied fallback: the image's file name alone. Windows paths are
|
||||
// case-insensitive anyway, so this matches the platform rather than relaxing anything.
|
||||
|| spec
|
||||
.process_name
|
||||
.as_deref()
|
||||
.is_some_and(|w| same_name(&image, w));
|
||||
if hit {
|
||||
out.push(ProcRef { pid, start });
|
||||
}
|
||||
@@ -234,6 +240,16 @@ fn under_dir(image: &Path, dir: &Path) -> bool {
|
||||
rest.starts_with('\\') || rest.starts_with('/')
|
||||
}
|
||||
|
||||
/// Is `image`'s file name `want`? The operator-supplied [`DetectSpec::process_name`] fallback: a bare
|
||||
/// name, compared against the image's last component only, so `Hades.exe` never matches a path that
|
||||
/// merely contains it.
|
||||
fn same_name(image: &Path, want: &str) -> bool {
|
||||
image
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case(want.trim()))
|
||||
}
|
||||
|
||||
fn eq_ignore_case(a: &Path, b: &Path) -> bool {
|
||||
wide_lower(a) == wide_lower(b)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user