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
+20 -4
View File
@@ -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)
}