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
File diff suppressed because one or more lines are too long
+38
View File
@@ -43,6 +43,22 @@ export const DeviceRef = S.Struct({
});
export type DeviceRef = S.Schema.Type<typeof DeviceRef>;
/** A launched game, as the `game.*` events identify it. */
export const GameRef = S.Struct({
/** Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream command. */
app: S.optional(S.String),
title: S.String,
store: S.optional(S.String),
/** Client-supplied device name of the session that launched it; may be empty. */
client: S.String,
plane: Plane,
});
export type GameRef = S.Schema.Type<typeof GameRef>;
/** Why a launched game is no longer running. */
export const GameEndReason = S.Literals(["exited", "terminated"]);
export type GameEndReason = S.Schema.Type<typeof GameEndReason>;
/** The `{seq, ts_ms, schema}` envelope every event carries. */
const envelope = {
seq: S.Number,
@@ -81,6 +97,26 @@ export const StreamStopped = S.Struct({
kind: S.Literal("stream.stopped"),
stream: StreamRef,
});
/**
* A launched game's process was seen running not merely its launcher spawned. Fires once per
* session that launched a title.
*/
export const GameRunning = S.Struct({
...envelope,
kind: S.Literal("game.running"),
game: GameRef,
});
/**
* A launched game is gone. `reason` separates the player quitting (`exited` which is what ends the
* streaming session, when that is enabled) from the host ending it per the lifetime policy
* (`terminated`).
*/
export const GameExited = S.Struct({
...envelope,
kind: S.Literal("game.exited"),
game: GameRef,
reason: GameEndReason,
});
export const PairingPending = S.Struct({
...envelope,
kind: S.Literal("pairing.pending"),
@@ -131,6 +167,8 @@ export const HostEvent = S.Union([
SessionEnded,
StreamStarted,
StreamStopped,
GameRunning,
GameExited,
PairingPending,
PairingCompleted,
PairingDenied,