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:
@@ -4022,6 +4022,10 @@
|
|||||||
"art": {
|
"art": {
|
||||||
"$ref": "#/components/schemas/Artwork"
|
"$ref": "#/components/schemas/Artwork"
|
||||||
},
|
},
|
||||||
|
"detect": {
|
||||||
|
"$ref": "#/components/schemas/DetectHint",
|
||||||
|
"description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns."
|
||||||
|
},
|
||||||
"external_id": {
|
"external_id": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
@@ -4072,6 +4076,10 @@
|
|||||||
"art": {
|
"art": {
|
||||||
"$ref": "#/components/schemas/Artwork"
|
"$ref": "#/components/schemas/Artwork"
|
||||||
},
|
},
|
||||||
|
"detect": {
|
||||||
|
"$ref": "#/components/schemas/DetectHint",
|
||||||
|
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||||
|
},
|
||||||
"launch": {
|
"launch": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -4140,6 +4148,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DetectHint": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
|
||||||
|
"properties": {
|
||||||
|
"exe": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "The game's own executable, as an absolute path."
|
||||||
|
},
|
||||||
|
"install_dir": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one."
|
||||||
|
},
|
||||||
|
"process_name": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"DeviceRef": {
|
"DeviceRef": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A device in the pairing flow.",
|
"description": "A device in the pairing flow.",
|
||||||
@@ -5449,6 +5484,13 @@
|
|||||||
},
|
},
|
||||||
"description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails."
|
"description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails."
|
||||||
},
|
},
|
||||||
|
"games": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).\n\nThe countdown form is the one that matters: it means the game's client is gone and the host\nwill end the game when the window closes — something a user at the machine should be able to\nsee (and stop) without opening the console. Empty when nothing was launched."
|
||||||
|
},
|
||||||
"kept_displays": {
|
"kept_displays": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32",
|
||||||
@@ -5971,6 +6013,10 @@
|
|||||||
"art": {
|
"art": {
|
||||||
"$ref": "#/components/schemas/Artwork"
|
"$ref": "#/components/schemas/Artwork"
|
||||||
},
|
},
|
||||||
|
"detect": {
|
||||||
|
"$ref": "#/components/schemas/DetectHint",
|
||||||
|
"description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits."
|
||||||
|
},
|
||||||
"external_id": {
|
"external_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ pub struct CustomEntry {
|
|||||||
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
|
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub external_id: Option<String>,
|
pub external_id: Option<String>,
|
||||||
|
/// How to recognize this title's process once it is running (design §9) — the one thing a
|
||||||
|
/// provider knows that the host cannot work out for itself.
|
||||||
|
///
|
||||||
|
/// Optional: without it the entry is still tracked by the child the host spawns for it, which
|
||||||
|
/// covers every command that stays in the foreground. It earns its keep for a command that hands
|
||||||
|
/// off and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where
|
||||||
|
/// the host would otherwise lose the game the moment the shim returns.
|
||||||
|
#[serde(default, skip_serializing_if = "DetectHint::is_empty")]
|
||||||
|
pub detect: DetectHint,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body to create or replace a custom entry (no `id` — the host owns it).
|
/// Request body to create or replace a custom entry (no `id` — the host owns it).
|
||||||
@@ -41,6 +50,9 @@ pub struct CustomInput {
|
|||||||
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prep: Vec<crate::hooks::PrepCmd>,
|
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||||
|
/// How to recognize this title's process — see [`CustomEntry::detect`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub detect: DetectHint,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the
|
/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the
|
||||||
@@ -57,20 +69,27 @@ pub struct ProviderEntryInput {
|
|||||||
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prep: Vec<crate::hooks::PrepCmd>,
|
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||||
|
/// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its
|
||||||
|
/// titles' install directories (Playnite does) should send them: it is what lets a game launched
|
||||||
|
/// through the provider's own client still end its session when the player quits.
|
||||||
|
#[serde(default)]
|
||||||
|
pub detect: DetectHint,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<CustomEntry> for GameEntry {
|
impl From<CustomEntry> for GameEntry {
|
||||||
fn from(c: CustomEntry) -> Self {
|
fn from(c: CustomEntry) -> Self {
|
||||||
// A custom/provider entry is spawned by the host itself, so its own child process is the
|
// A custom/provider entry is spawned by the host itself, so its own child process is the
|
||||||
// primary lifetime signal; this is the fallback for a command that hands off and exits (a
|
// primary lifetime signal; the spec is the fallback for a command that hands off and exits (a
|
||||||
// launcher script, a `flatpak run`). An absolute exe in the command line is the only thing
|
// launcher script, a `flatpak run`). An absolute exe in the command line is all that can be
|
||||||
// safe to infer — providers can supply richer signals explicitly (design §9, phase 4).
|
// *inferred*; anything sharper has to be stated, which is what `detect` is for (design §9).
|
||||||
|
// The inferred exe wins where both exist — it is derived from the very command being run.
|
||||||
let detect = c
|
let detect = c
|
||||||
.launch
|
.launch
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|l| l.kind == "command")
|
.filter(|l| l.kind == "command")
|
||||||
.map(|l| crate::library::spec_from_command(&l.value))
|
.map(|l| crate::library::spec_from_command(&l.value))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default()
|
||||||
|
.or_hint(&c.detect);
|
||||||
GameEntry {
|
GameEntry {
|
||||||
id: format!("custom:{}", c.id),
|
id: format!("custom:{}", c.id),
|
||||||
store: "custom".into(),
|
store: "custom".into(),
|
||||||
@@ -168,6 +187,7 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
|||||||
prep: input.prep,
|
prep: input.prep,
|
||||||
provider: None,
|
provider: None,
|
||||||
external_id: None,
|
external_id: None,
|
||||||
|
detect: input.detect,
|
||||||
};
|
};
|
||||||
entries.push(entry.clone());
|
entries.push(entry.clone());
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
@@ -189,6 +209,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
|
|||||||
slot.art = input.art;
|
slot.art = input.art;
|
||||||
slot.launch = input.launch;
|
slot.launch = input.launch;
|
||||||
slot.prep = input.prep;
|
slot.prep = input.prep;
|
||||||
|
slot.detect = input.detect;
|
||||||
let updated = slot.clone();
|
let updated = slot.clone();
|
||||||
save_custom(&entries)?;
|
save_custom(&entries)?;
|
||||||
emit_changed("manual");
|
emit_changed("manual");
|
||||||
@@ -283,6 +304,7 @@ fn reconcile_entries(
|
|||||||
prep: input.prep,
|
prep: input.prep,
|
||||||
provider: Some(provider.to_string()),
|
provider: Some(provider.to_string()),
|
||||||
external_id: Some(input.external_id),
|
external_id: Some(input.external_id),
|
||||||
|
detect: input.detect,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
|
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
|
||||||
@@ -360,6 +382,7 @@ mod tests {
|
|||||||
prep: Vec::new(),
|
prep: Vec::new(),
|
||||||
provider: None,
|
provider: None,
|
||||||
external_id: None,
|
external_id: None,
|
||||||
|
detect: DetectHint::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +393,7 @@ mod tests {
|
|||||||
art: Artwork::default(),
|
art: Artwork::default(),
|
||||||
launch: None,
|
launch: None,
|
||||||
prep: Vec::new(),
|
prep: Vec::new(),
|
||||||
|
detect: DetectHint::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ pub struct DetectSpec {
|
|||||||
/// The game's install directory — the universal recipe. A process whose image path (or, for
|
/// The game's install directory — the universal recipe. A process whose image path (or, for
|
||||||
/// Proton/Wine, whose command line) sits under this directory is part of the game.
|
/// Proton/Wine, whose command line) sits under this directory is part of the game.
|
||||||
pub install_dir: Option<PathBuf>,
|
pub install_dir: Option<PathBuf>,
|
||||||
|
/// The game's executable **file name** (`Hades.exe`, `retroarch`), matched case-insensitively
|
||||||
|
/// against a process's image name and nothing else.
|
||||||
|
///
|
||||||
|
/// The weakest signal here, and the only one an operator supplies by hand
|
||||||
|
/// ([`super::DetectHint`]): a bare name says nothing about *which* copy is running. It is offered
|
||||||
|
/// because the entries that need it — an emulator launched through a front-end, a game whose
|
||||||
|
/// launcher relocates it — often expose nothing sharper, and because "started after this launch"
|
||||||
|
/// still bounds it: a copy the player already had open is never adopted.
|
||||||
|
pub process_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DetectSpec {
|
impl DetectSpec {
|
||||||
@@ -54,6 +63,7 @@ impl DetectSpec {
|
|||||||
&& self.env_marker.is_none()
|
&& self.env_marker.is_none()
|
||||||
&& self.exe.is_none()
|
&& self.exe.is_none()
|
||||||
&& self.install_dir.is_none()
|
&& self.install_dir.is_none()
|
||||||
|
&& self.process_name.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Just a Steam appid (the manifest path; art/shortcut scanning fills the rest).
|
/// Just a Steam appid (the manifest path; art/shortcut scanning fills the rest).
|
||||||
@@ -95,6 +105,77 @@ impl DetectSpec {
|
|||||||
});
|
});
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fill in whatever this spec doesn't already know from an operator/provider hint.
|
||||||
|
///
|
||||||
|
/// The host's own findings win: a hint is a fallback for a title the scanners couldn't pin down,
|
||||||
|
/// never a way to redirect the matcher away from what the store actually reported.
|
||||||
|
pub fn or_hint(mut self, hint: &DetectHint) -> Self {
|
||||||
|
let from = Self::from(hint);
|
||||||
|
self.install_dir = self.install_dir.or(from.install_dir);
|
||||||
|
self.exe = self.exe.or(from.exe);
|
||||||
|
self.process_name = self.process_name.or(from.process_name);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an operator (or a provider plugin) can tell the host about recognizing a title — the wire
|
||||||
|
/// half of [`DetectSpec`], and the only part of it that is ever accepted from outside.
|
||||||
|
///
|
||||||
|
/// Deliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment
|
||||||
|
/// marker) are things the host discovers for itself and would be meaningless — or dangerous — to take
|
||||||
|
/// on someone's word. What is left is what a provider genuinely knows and the host cannot guess: where
|
||||||
|
/// the title is installed, which executable is the game, what the process is called. All three are
|
||||||
|
/// optional; supplying none is the same as supplying no hint at all.
|
||||||
|
///
|
||||||
|
/// Never returned by the catalog API — see the module docs on why detect data does not cross the wire
|
||||||
|
/// outbound.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct DetectHint {
|
||||||
|
/// Where the title is installed. Any process running from under this directory is part of the
|
||||||
|
/// game — the universal recipe, and the one worth supplying if you supply only one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub install_dir: Option<String>,
|
||||||
|
/// The game's own executable, as an absolute path.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub exe: Option<String>,
|
||||||
|
/// The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three
|
||||||
|
/// — see [`DetectSpec::process_name`].
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub process_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetectHint {
|
||||||
|
/// Whether the hint says anything at all (all-empty is treated as absent).
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.trimmed().is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hint with blank fields dropped, or `None` if nothing is left. Console text inputs and
|
||||||
|
/// hand-written plugin payloads both produce `""` for "not set", and an empty install dir would
|
||||||
|
/// otherwise match *every* process on the box.
|
||||||
|
fn trimmed(&self) -> Option<(Option<&str>, Option<&str>, Option<&str>)> {
|
||||||
|
fn f(s: &Option<String>) -> Option<&str> {
|
||||||
|
s.as_deref().map(str::trim).filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
let (dir, exe, name) = (f(&self.install_dir), f(&self.exe), f(&self.process_name));
|
||||||
|
(dir.is_some() || exe.is_some() || name.is_some()).then_some((dir, exe, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A provider's hint becomes a spec — the one inbound path into [`DetectSpec`].
|
||||||
|
impl From<&DetectHint> for DetectSpec {
|
||||||
|
fn from(h: &DetectHint) -> Self {
|
||||||
|
let Some((install_dir, exe, process_name)) = h.trimmed() else {
|
||||||
|
return Self::default();
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
install_dir: install_dir.map(PathBuf::from),
|
||||||
|
exe: exe.map(PathBuf::from),
|
||||||
|
process_name: process_name.map(str::to_string),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive a detect spec from an operator-typed shell command (the custom store's `command` kind, and
|
/// Derive a detect spec from an operator-typed shell command (the custom store's `command` kind, and
|
||||||
@@ -181,6 +262,61 @@ mod tests {
|
|||||||
assert!(spec_from_command(" ").is_empty());
|
assert!(spec_from_command(" ").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A hint is operator/plugin input, so the blank-field case is the norm, not an edge: a console
|
||||||
|
/// form and a hand-written plugin payload both send `""` for "not set". An empty install dir that
|
||||||
|
/// reached the matcher would prefix-match every process on the box — and this feature can end
|
||||||
|
/// processes.
|
||||||
|
#[test]
|
||||||
|
fn a_blank_hint_says_nothing() {
|
||||||
|
assert!(DetectHint::default().is_empty());
|
||||||
|
let blank = DetectHint {
|
||||||
|
install_dir: Some("".into()),
|
||||||
|
exe: Some(" ".into()),
|
||||||
|
process_name: Some("\t".into()),
|
||||||
|
};
|
||||||
|
assert!(blank.is_empty());
|
||||||
|
assert!(DetectSpec::from(&blank).is_empty(), "nothing to match on");
|
||||||
|
|
||||||
|
let hint = DetectHint {
|
||||||
|
install_dir: Some(" /games/quail ".into()),
|
||||||
|
exe: None,
|
||||||
|
process_name: Some("quail".into()),
|
||||||
|
};
|
||||||
|
assert!(!hint.is_empty());
|
||||||
|
let spec = DetectSpec::from(&hint);
|
||||||
|
assert_eq!(spec.install_dir.as_deref(), Some(Path::new("/games/quail")));
|
||||||
|
assert_eq!(spec.process_name.as_deref(), Some("quail"));
|
||||||
|
assert_eq!(spec.exe, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host's own findings outrank a hint. A provider that guessed wrong (or a stale export)
|
||||||
|
/// must not be able to point the matcher — and therefore the termination ladder — at something
|
||||||
|
/// other than what the store itself reported.
|
||||||
|
#[test]
|
||||||
|
fn a_hint_only_fills_gaps() {
|
||||||
|
let found = DetectSpec::dir("/games/real");
|
||||||
|
let hint = DetectHint {
|
||||||
|
install_dir: Some("/games/wrong".into()),
|
||||||
|
exe: Some("/games/real/run".into()),
|
||||||
|
process_name: None,
|
||||||
|
};
|
||||||
|
let merged = found.or_hint(&hint);
|
||||||
|
assert_eq!(
|
||||||
|
merged.install_dir.as_deref(),
|
||||||
|
Some(Path::new("/games/real")),
|
||||||
|
"the store's own answer stands"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
merged.exe.as_deref(),
|
||||||
|
Some(Path::new("/games/real/run")),
|
||||||
|
"but a field the store had nothing for is filled in"
|
||||||
|
);
|
||||||
|
// Nothing found + nothing hinted stays untrackable.
|
||||||
|
assert!(DetectSpec::default()
|
||||||
|
.or_hint(&DetectHint::default())
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn first_token_handles_quotes_and_spaces() {
|
fn first_token_handles_quotes_and_spaces() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -207,6 +207,13 @@ pub(crate) struct LocalSummary {
|
|||||||
/// the tray/console surface them so the clash is visible before pairing silently fails.
|
/// the tray/console surface them so the clash is visible before pairing silently fails.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
conflicts: Vec<String>,
|
conflicts: Vec<String>,
|
||||||
|
/// Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).
|
||||||
|
///
|
||||||
|
/// The countdown form is the one that matters: it means the game's client is gone and the host
|
||||||
|
/// will end the game when the window closes — something a user at the machine should be able to
|
||||||
|
/// see (and stop) without opening the console. Empty when nothing was launched.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
games: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Liveness probe
|
/// Liveness probe
|
||||||
@@ -489,5 +496,12 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
|
|||||||
// Cached at `serve` startup (empty when nothing was detected / never scanned) — no per-poll
|
// Cached at `serve` startup (empty when nothing was detected / never scanned) — no per-poll
|
||||||
// process enumeration.
|
// process enumeration.
|
||||||
conflicts: crate::detect::summary_labels(crate::detect::snapshot()),
|
conflicts: crate::detect::summary_labels(crate::detect::snapshot()),
|
||||||
|
games: crate::session_status::games()
|
||||||
|
.into_iter()
|
||||||
|
.map(|g| match g.grace_remaining_s {
|
||||||
|
Some(left) => format!("{} (closing in {}:{:02})", g.title, left / 60, left % 60),
|
||||||
|
None => g.title,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,19 @@ impl Scanner {
|
|||||||
return true;
|
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*
|
// 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
|
// (`…/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]
|
#[test]
|
||||||
fn install_dir_does_not_match_a_sibling_with_the_same_prefix() {
|
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
|
// `/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
|
/// 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).
|
/// `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> {
|
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
|
// Only the image-based signals exist on Windows: no reaper argv, no readable environment. A
|
||||||
// carrying neither must match *nothing* — falling through would scan on an empty predicate.
|
// spec carrying none must match *nothing* — falling through would scan on an empty predicate.
|
||||||
if spec.exe.is_none() && spec.install_dir.is_none() {
|
if spec.exe.is_none() && spec.install_dir.is_none() && spec.process_name.is_none() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let exe = spec
|
let exe = spec
|
||||||
@@ -91,7 +91,13 @@ impl Scanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let hit = exe.as_deref().is_some_and(|w| same_path(&image, w))
|
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 {
|
if hit {
|
||||||
out.push(ProcRef { pid, start });
|
out.push(ProcRef { pid, start });
|
||||||
}
|
}
|
||||||
@@ -234,6 +240,16 @@ fn under_dir(image: &Path, dir: &Path) -> bool {
|
|||||||
rest.starts_with('\\') || rest.starts_with('/')
|
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 {
|
fn eq_ignore_case(a: &Path, b: &Path) -> bool {
|
||||||
wide_lower(a) == wide_lower(b)
|
wide_lower(a) == wide_lower(b)
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-1
@@ -45,7 +45,7 @@ export default definePluginKit({
|
|||||||
| `HostClient`, `PluginInfo` | the `pf` facade as services (`request` = the skew-safe untyped seam) |
|
| `HostClient`, `PluginInfo` | the `pf` facade as services (`request` = the skew-safe untyped seam) |
|
||||||
| `makeConfigService` | Schema-driven config: raw shape on disk, defaults ONLY in the Schema (`withDecodingDefaultKey` + `encodingStrategy: "omit"`), atomic writes, world-writable refusal, `changes` stream |
|
| `makeConfigService` | Schema-driven config: raw shape on disk, defaults ONLY in the Schema (`withDecodingDefaultKey` + `encodingStrategy: "omit"`), atomic writes, world-writable refusal, `changes` stream |
|
||||||
| `makeCacheStore` | disposable derived state (corrupt/absent → empty, write-through) |
|
| `makeCacheStore` | disposable derived state (corrupt/absent → empty, write-through) |
|
||||||
| `ProviderClient` + wire schemas | typed library-provider reconcile over the untyped wire |
|
| `ProviderClient` + wire schemas | typed library-provider reconcile over the untyped wire — including the optional `detect` hint (see below) |
|
||||||
| `makeSyncEngine` | poll + fs-watch + debounce + single-flight coalescing + fingerprint skip + status feed |
|
| `makeSyncEngine` | poll + fs-watch + debounce + single-flight coalescing + fingerprint skip + status feed |
|
||||||
| `serveUi` / `httpApiEnv` | an `effect/unstable/httpapi` HttpApi behind the SDK's `servePluginUi`, core-only layers |
|
| `serveUi` / `httpApiEnv` | an `effect/unstable/httpapi` HttpApi behind the SDK's `servePluginUi`, core-only layers |
|
||||||
| `sseRoute` | the status SSE endpoint (httpapi has no event-stream media type) |
|
| `sseRoute` | the status SSE endpoint (httpapi has no event-stream media type) |
|
||||||
@@ -54,6 +54,27 @@ export default definePluginKit({
|
|||||||
| `@punktfunk/plugin-kit/react` | browser glue: `createPluginRouter` (path→hash→fallback deep-link restore + `pf-ui:navigate`), `resolvePluginBase`, `useIsEmbedded`, `ResultGate`, `sseAtom` |
|
| `@punktfunk/plugin-kit/react` | browser glue: `createPluginRouter` (path→hash→fallback deep-link restore + `pf-ui:navigate`), `resolvePluginBase`, `useIsEmbedded`, `ResultGate`, `sseAtom` |
|
||||||
| `@punktfunk/plugin-kit/theme.css` | the console's violet identity for plugin UIs (import first in your Tailwind entry) |
|
| `@punktfunk/plugin-kit/theme.css` | the console's violet identity for plugin UIs (import first in your Tailwind entry) |
|
||||||
|
|
||||||
|
## Telling the host how to recognize a running title (`detect`)
|
||||||
|
|
||||||
|
A `ProviderEntry` may carry an optional `detect` hint:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ external_id: "playnite:9f2…", title: "Hades",
|
||||||
|
launch: { kind: "command", value: "playnite://playnite/start/9f2…" },
|
||||||
|
detect: { install_dir: "D:\\Games\\Hades" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
It is what lets the host tell that the *game* has exited — which ends the streaming session, so the
|
||||||
|
player's client returns to its library instead of showing a bare desktop — and what lets an operator
|
||||||
|
who opted into it end the game when the session ends.
|
||||||
|
|
||||||
|
Omit it and nothing breaks: the host tracks the process it spawns for your launch command. It matters
|
||||||
|
when that command **hands off and exits** — a launcher client, `flatpak run`, a front-end that starts
|
||||||
|
an emulator — because then there is nothing left for the host to watch, and both behaviors go quiet
|
||||||
|
for that title. Send whatever you genuinely know; `install_dir` is the one to send if you send only
|
||||||
|
one, since any process running from under it counts as the game. The host never lets a hint override
|
||||||
|
what it worked out itself, and never adopts a process that was already running before the launch.
|
||||||
|
|
||||||
## Publishing
|
## Publishing
|
||||||
|
|
||||||
Tag `plugin-kit-vX.Y.Z` (matching `package.json`) — `.gitea/workflows/plugin-kit-publish.yml`
|
Tag `plugin-kit-vX.Y.Z` (matching `package.json`) — `.gitea/workflows/plugin-kit-publish.yml`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@punktfunk/plugin-kit",
|
"name": "@punktfunk/plugin-kit",
|
||||||
"version": "0.1.4",
|
"version": "0.2.0",
|
||||||
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export { type ConfigService, makeConfigService } from "./config.js";
|
|||||||
export { type CacheStore, makeCacheStore } from "./cache-store.js";
|
export { type CacheStore, makeCacheStore } from "./cache-store.js";
|
||||||
export {
|
export {
|
||||||
Artwork,
|
Artwork,
|
||||||
|
DetectHint,
|
||||||
LaunchSpec,
|
LaunchSpec,
|
||||||
PrepStep,
|
PrepStep,
|
||||||
ProviderClient,
|
ProviderClient,
|
||||||
|
|||||||
@@ -24,11 +24,34 @@ export const PrepStep = Schema.Struct({
|
|||||||
});
|
});
|
||||||
export type PrepStep = typeof PrepStep.Type;
|
export type PrepStep = typeof PrepStep.Type;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the host should recognize a title's process once it is running.
|
||||||
|
*
|
||||||
|
* Every field is optional, and omitting the whole thing is fine: the host tracks the process it
|
||||||
|
* spawns for the entry anyway. It matters when your launch command hands off and exits — a launcher
|
||||||
|
* client, a `flatpak run`, a front-end that starts an emulator — because then the host has nothing
|
||||||
|
* left to watch, and the two behaviors this feeds ("end the session when the game exits" and "end the
|
||||||
|
* game when the session ends") go quiet for that title.
|
||||||
|
*
|
||||||
|
* Send whatever you actually know. `install_dir` is the one worth sending if you send only one: any
|
||||||
|
* process running from under it counts as the game.
|
||||||
|
*/
|
||||||
|
export const DetectHint = Schema.Struct({
|
||||||
|
/** Where the title is installed (absolute path on the host). */
|
||||||
|
install_dir: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||||
|
/** The game's own executable (absolute path on the host). */
|
||||||
|
exe: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||||
|
/** The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest signal. */
|
||||||
|
process_name: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||||
|
});
|
||||||
|
export type DetectHint = typeof DetectHint.Type;
|
||||||
|
|
||||||
export const ProviderEntry = Schema.Struct({
|
export const ProviderEntry = Schema.Struct({
|
||||||
external_id: Schema.String,
|
external_id: Schema.String,
|
||||||
title: Schema.String,
|
title: Schema.String,
|
||||||
art: Schema.optionalKey(Artwork),
|
art: Schema.optionalKey(Artwork),
|
||||||
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
|
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
|
||||||
prep: Schema.optionalKey(Schema.Array(PrepStep)),
|
prep: Schema.optionalKey(Schema.Array(PrepStep)),
|
||||||
|
detect: Schema.optionalKey(DetectHint),
|
||||||
});
|
});
|
||||||
export type ProviderEntry = typeof ProviderEntry.Type;
|
export type ProviderEntry = typeof ProviderEntry.Type;
|
||||||
|
|||||||
+97
-12
File diff suppressed because one or more lines are too long
@@ -43,6 +43,22 @@ export const DeviceRef = S.Struct({
|
|||||||
});
|
});
|
||||||
export type DeviceRef = S.Schema.Type<typeof DeviceRef>;
|
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. */
|
/** The `{seq, ts_ms, schema}` envelope every event carries. */
|
||||||
const envelope = {
|
const envelope = {
|
||||||
seq: S.Number,
|
seq: S.Number,
|
||||||
@@ -81,6 +97,26 @@ export const StreamStopped = S.Struct({
|
|||||||
kind: S.Literal("stream.stopped"),
|
kind: S.Literal("stream.stopped"),
|
||||||
stream: StreamRef,
|
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({
|
export const PairingPending = S.Struct({
|
||||||
...envelope,
|
...envelope,
|
||||||
kind: S.Literal("pairing.pending"),
|
kind: S.Literal("pairing.pending"),
|
||||||
@@ -131,6 +167,8 @@ export const HostEvent = S.Union([
|
|||||||
SessionEnded,
|
SessionEnded,
|
||||||
StreamStarted,
|
StreamStarted,
|
||||||
StreamStopped,
|
StreamStopped,
|
||||||
|
GameRunning,
|
||||||
|
GameExited,
|
||||||
PairingPending,
|
PairingPending,
|
||||||
PairingCompleted,
|
PairingCompleted,
|
||||||
PairingDenied,
|
PairingDenied,
|
||||||
|
|||||||
Reference in New Issue
Block a user