Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b8627e70 | ||
|
|
91fa32fbb6 | ||
|
|
ce8f3e9eaf | ||
|
|
bd383f1820 | ||
|
|
8728d90e01 | ||
|
|
3d4a659959 | ||
|
|
a418d2852a | ||
|
|
110ac9b663 | ||
|
|
1d6f4760f3 | ||
|
|
9dfbc2f895 | ||
|
|
52a9d02355 | ||
|
|
e5ca213339 | ||
|
|
e5416646f9 | ||
|
|
b79d90b463 |
+157
-9
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.23.0"
|
||||
"version": "0.24.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1052,7 +1052,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Fetch one cover-art image for a library entry",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
|
||||
"operationId": "getLibraryArt",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1307,7 +1307,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Replace a provider's library entries (declarative reconcile)",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
|
||||
"operationId": "reconcileProviderEntries",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1318,6 +1318,15 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "store",
|
||||
"in": "query",
|
||||
"description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@@ -1348,7 +1357,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"description": "Invalid provider id, store id, or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1367,6 +1376,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "That store is already claimed by another provider",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the catalog",
|
||||
"content": {
|
||||
@@ -4159,7 +4178,8 @@
|
||||
"tier",
|
||||
"platforms",
|
||||
"compatible",
|
||||
"update_available"
|
||||
"update_available",
|
||||
"categories"
|
||||
],
|
||||
"properties": {
|
||||
"author": {
|
||||
@@ -4172,6 +4192,13 @@
|
||||
],
|
||||
"description": "A revocation covering the catalogued version — do not offer this without shouting."
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
|
||||
},
|
||||
"compatible": {
|
||||
"type": "boolean",
|
||||
"description": "Can this host install it?"
|
||||
@@ -4179,6 +4206,13 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"detected": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
|
||||
},
|
||||
"homepage": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4365,6 +4399,17 @@
|
||||
],
|
||||
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
|
||||
},
|
||||
"store": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4409,6 +4454,10 @@
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4467,6 +4516,17 @@
|
||||
"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": {
|
||||
"env_marker": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EnvMarker",
|
||||
"description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
|
||||
}
|
||||
]
|
||||
},
|
||||
"exe": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4487,6 +4547,15 @@
|
||||
"null"
|
||||
],
|
||||
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
|
||||
},
|
||||
"steam_appid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4715,6 +4784,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EnvMarker": {
|
||||
"type": "object",
|
||||
"description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The variable name (e.g. `HEROIC_GAME_ID`).",
|
||||
"example": "HEROIC_APP_NAME"
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
|
||||
}
|
||||
}
|
||||
},
|
||||
"EventKind": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -5165,6 +5255,10 @@
|
||||
],
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
|
||||
},
|
||||
"store": {
|
||||
"type": "string",
|
||||
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
|
||||
@@ -5296,6 +5390,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GameRole": {
|
||||
"type": "string",
|
||||
"description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
|
||||
"enum": [
|
||||
"game",
|
||||
"launcher"
|
||||
]
|
||||
},
|
||||
"GameSession": {
|
||||
"type": "string",
|
||||
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
|
||||
@@ -6334,6 +6436,13 @@
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)."
|
||||
@@ -6366,6 +6475,13 @@
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The plugin's kind — see [`PluginRegistration::category`]."
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6604,6 +6720,10 @@
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -6780,26 +6900,46 @@
|
||||
},
|
||||
"ScannerInfo": {
|
||||
"type": "object",
|
||||
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
|
||||
"description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"enabled"
|
||||
"enabled",
|
||||
"origin"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this host runs the scanner (default true)."
|
||||
"description": "Whether this host runs the source (default true)."
|
||||
},
|
||||
"entries": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
|
||||
"minimum": 0
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
|
||||
"description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
|
||||
"example": "steam"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Human-facing name for the console toggle.",
|
||||
"example": "Steam"
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/components/schemas/SourceOrigin",
|
||||
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The provider id backing a `plugin` source — absent for a built-in scanner."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6962,6 +7102,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SourceOrigin": {
|
||||
"type": "string",
|
||||
"description": "Where a [`ScannerInfo`] comes from.",
|
||||
"enum": [
|
||||
"builtin",
|
||||
"plugin"
|
||||
]
|
||||
},
|
||||
"SourceView": {
|
||||
"type": "object",
|
||||
"description": "A configured catalog source and how its last refresh went.",
|
||||
|
||||
@@ -773,6 +773,7 @@ fn mock_library() -> (
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
role: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
|
||||
@@ -66,6 +66,20 @@ pub struct GameEntry {
|
||||
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
|
||||
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. A UI may group these
|
||||
/// separately; one that doesn't renders them as ordinary tiles, which is the intended
|
||||
/// degradation (design D4). Kept a plain string: the host owns the vocabulary, and an unknown
|
||||
/// future value must never fail the whole library decode.
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
impl GameEntry {
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
pub fn is_launcher(&self) -> bool {
|
||||
self.role.as_deref() == Some("launcher")
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
@@ -2465,11 +2465,18 @@ pub fn ei_socket_file() -> std::path::PathBuf {
|
||||
crate::with_env_lock(pf_paths::gamescope_ei_socket_file)
|
||||
}
|
||||
|
||||
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
|
||||
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
|
||||
/// Does this resolved launch command start the Steam **client**? Such a launch needs Steam's single
|
||||
/// instance free before a dedicated spawn (B1), and wants gamescope's `--steam` integration on.
|
||||
/// Pure + unit-tested.
|
||||
///
|
||||
/// The test is the first token, NOT the presence of a `steam://` URI. A `steam_ui` launcher entry
|
||||
/// (design D4) resolves to a bare `steam -gamepadui` / `steam` with no URI at all, and it is *more*
|
||||
/// exposed to the single-instance problem than a game launch is, not less: on a box that autologged
|
||||
/// into game mode, the nested second Steam would see the first and exit, taking the spawn down with
|
||||
/// it. A URI-gated check would silently skip both the instance free and `--steam` for exactly the
|
||||
/// launch that most needs them.
|
||||
fn is_steam_launch(cmd: &str) -> bool {
|
||||
let mut it = cmd.split_whitespace();
|
||||
it.next() == Some("steam") && cmd.contains("steam://")
|
||||
cmd.split_whitespace().next() == Some("steam")
|
||||
}
|
||||
|
||||
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
|
||||
@@ -2865,7 +2872,13 @@ mod tests {
|
||||
assert!(is_steam_launch("steam -silent steam://rungameid/570"));
|
||||
assert!(!is_steam_launch("vkcube"));
|
||||
assert!(!is_steam_launch("lutris lutris:rungameid/42"));
|
||||
assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch
|
||||
// A `steam_ui` LAUNCHER entry (design D4) carries no URI, and must still count: it needs the
|
||||
// single instance freed (B1) and gamescope's `--steam` mode on. Gating on `steam://` would
|
||||
// have skipped both for the one launch that is Big Picture itself.
|
||||
assert!(is_steam_launch("steam -gamepadui"));
|
||||
assert!(is_steam_launch("steam"));
|
||||
// A command that merely mentions steam elsewhere is not a Steam client launch.
|
||||
assert!(!is_steam_launch("mygame --steam-overlay"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2891,6 +2904,13 @@ mod tests {
|
||||
shape_dedicated_command("steam -bigpicture"),
|
||||
"steam -bigpicture"
|
||||
);
|
||||
// The `steam_ui` launcher entries (design D4) pass through untouched — the shaping only ever
|
||||
// fires on a `steam://` game launch, so there is no way to end up with `-gamepadui` twice.
|
||||
assert_eq!(
|
||||
shape_dedicated_command("steam -gamepadui"),
|
||||
"steam -gamepadui"
|
||||
);
|
||||
assert_eq!(shape_dedicated_command("steam"), "steam");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -159,6 +159,17 @@ const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
|
||||
/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the
|
||||
/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction).
|
||||
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
|
||||
/// A deciding window that DELIVERED under `current / STARVED_DELIVERY_DIV` is STARVED: the
|
||||
/// stream barely flowed (a host-side capture stall, an outage, a mid-window pause), so whatever
|
||||
/// distress the window carries — a flush, a keyframe-ask burst — is starvation-shaped, not
|
||||
/// rate-shaped, and the decoder decoded almost nothing at the nominal rate. Such a window may
|
||||
/// still back off (real damage deserves the safe response) but must never be a decode-knee
|
||||
/// sample: latching `current_kbps` off a starved window teaches a phantom decoder cap at
|
||||
/// whatever rate the stall interrupted (the periodic-capture-stall field case: every 5 s cycle
|
||||
/// offers another pair of "backoffs" at the same rate — a bogus latch that then fights the
|
||||
/// re-probe ladder for minutes). Deliberately far below the ×¾ utilization bar climbs require:
|
||||
/// the band between them is ambiguous and keeps today's behavior.
|
||||
const STARVED_DELIVERY_DIV: u32 = 4;
|
||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
const BASELINE_WINDOWS: usize = 40;
|
||||
@@ -697,6 +708,10 @@ impl BitrateController {
|
||||
|| self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE
|
||||
|| (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM)
|
||||
|| (flushed && (decode_bad || decode_mean_us.is_none()));
|
||||
// Starved deciding window (see [`STARVED_DELIVERY_DIV`]): the stream barely flowed,
|
||||
// so the window says nothing about what the decoder can hold at this rate.
|
||||
let starved =
|
||||
(actual_kbps as u64) * (STARVED_DELIVERY_DIV as u64) < self.current_kbps as u64;
|
||||
if !self.climb_since_backoff {
|
||||
// Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms,
|
||||
// so this window's rate is one the decoder never choked at while keeping up —
|
||||
@@ -708,6 +723,17 @@ impl BitrateController {
|
||||
"adaptive bitrate: backoff without an intervening climb — draining the \
|
||||
previous choke, not a knee sample"
|
||||
);
|
||||
} else if starved {
|
||||
// Same "not a knee sample either way" treatment as the draining arm: neither
|
||||
// latch against a starved window nor let it erase the reference a real knee
|
||||
// set — the next genuine choke at that rate must still find its pair.
|
||||
tracing::debug!(
|
||||
at_kbps = self.current_kbps,
|
||||
actual_kbps,
|
||||
reference_kbps = self.decode_backoff_kbps,
|
||||
"adaptive bitrate: backoff in a starved window (delivery a fraction of \
|
||||
the target) — starvation-shaped distress, not a knee sample"
|
||||
);
|
||||
} else if decode_evidence {
|
||||
let rate = self.current_kbps;
|
||||
let similar = self.decode_backoff_kbps > 0
|
||||
@@ -2084,6 +2110,100 @@ mod tests {
|
||||
rate - rate / 16
|
||||
}
|
||||
|
||||
/// One capture-stall-shaped window at the current rate: almost nothing delivered
|
||||
/// (current/10), nothing decoded, no loss — but a jump-to-live flush and a keyframe-ask
|
||||
/// storm (the stall edge's damage signature). SEVERE, so it backs off; STARVED, so it must
|
||||
/// never be a knee sample.
|
||||
fn stall_choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
|
||||
*tick += 2;
|
||||
let r = c.on_window(
|
||||
ticks(start, *tick),
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
c.current_kbps / 10,
|
||||
true,
|
||||
RECOVERY_KF_SEVERE,
|
||||
);
|
||||
*tick += 1;
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_stall_windows_never_latch_a_decode_cap() {
|
||||
// The periodic-capture-stall field case (RDNA4 standby-sink, 5 s cycle): every stall
|
||||
// edge offers another flush + kf-storm "backoff" at the SAME rate — without the starved
|
||||
// guard that pair latches a phantom decoder knee at whatever rate the display driver
|
||||
// happened to interrupt, and the session then fights the re-probe ladder for minutes.
|
||||
let mut c = BitrateController::new(240_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
climb_to(&mut c, start, &mut t, 400_000);
|
||||
let at = c.current_kbps;
|
||||
let r1 = stall_choke(&mut c, start, &mut t).expect("stall damage still backs off");
|
||||
assert!(
|
||||
c.decode_cap_kbps.is_none(),
|
||||
"one starved window must not latch"
|
||||
);
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, 0,
|
||||
"a starved window is not a knee sample — no reference recorded"
|
||||
);
|
||||
c.on_ack(r1);
|
||||
climb_to(&mut c, start, &mut t, at - at / DECODE_CAP_SIMILAR_DIV);
|
||||
let r2 = stall_choke(&mut c, start, &mut t).expect("second stall edge backs off too");
|
||||
c.on_ack(r2);
|
||||
assert!(
|
||||
c.decode_cap_kbps.is_none(),
|
||||
"a starved pair at the same rate must not latch a phantom knee"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starved_window_preserves_the_knee_reference() {
|
||||
// A REAL knee sample, then a stall edge, then the genuine re-climb choke: the starved
|
||||
// window in the middle must neither latch nor ERASE the reference the real choke set —
|
||||
// the genuine pair must still find each other around it.
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
let knee = c.current_kbps;
|
||||
let r1 = choke(&mut c, start, &mut t).expect("real choke backs off");
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, knee,
|
||||
"real choke records the reference"
|
||||
);
|
||||
c.on_ack(r1);
|
||||
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||||
let r2 = stall_choke(&mut c, start, &mut t).expect("stall edge backs off");
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, knee,
|
||||
"the starved window must not erase the real reference"
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none(), "and must not latch against it");
|
||||
c.on_ack(r2);
|
||||
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||||
let rate = c.current_kbps;
|
||||
choke(&mut c, start, &mut t).expect("genuine re-climb choke backs off");
|
||||
assert_eq!(
|
||||
c.decode_cap_kbps,
|
||||
Some(rate - rate / 16),
|
||||
"the genuine pair still latches around the starved interruption"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() {
|
||||
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
|
||||
|
||||
@@ -245,6 +245,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The migration invariant D2 exists to protect. Moonlight caches app ids (and users pin them),
|
||||
/// and the id is derived from the LIBRARY ID alone — so a title moving from the in-host scanner
|
||||
/// to a claimed plugin entry keeps its GameStream id iff the library id is byte-identical. This
|
||||
/// pins that the claimed shape is that shape, and that an unclaimed one would NOT have been.
|
||||
#[test]
|
||||
fn a_claimed_plugin_entry_keeps_the_scanners_gamestream_id() {
|
||||
// What the built-in scanner produced, and what the steam plugin produces once it claims.
|
||||
assert_eq!(stable_app_id("steam:440"), stable_app_id("steam:440"));
|
||||
// The same title reconciled WITHOUT a claim gets an opaque `custom:` id — a different app
|
||||
// id, i.e. exactly the breakage the claim prevents.
|
||||
assert_ne!(stable_app_id("steam:440"), stable_app_id("custom:9f2c1a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_library_dedups_against_base_ids() {
|
||||
// A base app whose id happens to fall in the library range must not be clobbered by a library
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
pub(crate) use anyhow::{Context, Result};
|
||||
pub(crate) use serde::{Deserialize, Serialize};
|
||||
pub(crate) use sha2::{Digest, Sha256};
|
||||
pub(crate) use std::collections::HashSet;
|
||||
pub(crate) use std::collections::{BTreeMap, HashSet};
|
||||
pub(crate) use std::path::{Path, PathBuf};
|
||||
pub(crate) use std::time::{SystemTime, UNIX_EPOCH};
|
||||
pub(crate) use utoipa::ToSchema;
|
||||
@@ -136,6 +136,29 @@ impl GameMeta {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big
|
||||
/// Picture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,
|
||||
/// leases and lists exactly like a game (design D4), and clients that don't know the field render it
|
||||
/// as a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged
|
||||
/// for every entry that doesn't opt in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum GameRole {
|
||||
/// An ordinary title.
|
||||
#[default]
|
||||
Game,
|
||||
/// The launcher application itself.
|
||||
Launcher,
|
||||
}
|
||||
|
||||
impl GameRole {
|
||||
/// Whether this is the serde default (`game`) — the `skip_serializing_if` predicate that keeps
|
||||
/// the field off the wire for the overwhelming majority of entries.
|
||||
pub(crate) fn is_game(&self) -> bool {
|
||||
matches!(self, Self::Game)
|
||||
}
|
||||
}
|
||||
|
||||
/// One title in the unified library, regardless of which store it came from.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
pub struct GameEntry {
|
||||
@@ -147,6 +170,9 @@ pub struct GameEntry {
|
||||
pub store: String,
|
||||
pub title: String,
|
||||
pub art: Artwork,
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
|
||||
#[serde(default, skip_serializing_if = "GameRole::is_game")]
|
||||
pub role: GameRole,
|
||||
/// How the host would launch it, when known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub launch: Option<LaunchSpec>,
|
||||
@@ -228,12 +254,26 @@ impl ArtKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The full library: every *enabled* store's titles merged + the custom entries, sorted by title.
|
||||
/// The operator's scanner toggles (`scanners.rs`) gate each installed-store provider; the custom
|
||||
/// store is not a scanner and always contributes.
|
||||
/// The full library: every *enabled* source's titles merged + the custom entries, sorted by title.
|
||||
///
|
||||
/// Two independent gates run here, both at READ time so neither ever mutates stored state:
|
||||
///
|
||||
/// * **The operator's source toggles** (`scanners.rs`, persisted as a disabled-set in
|
||||
/// `library-scanners.json`) hide a source's titles from every surface — this grid, native clients,
|
||||
/// `/applist`, and launch resolution. They apply to built-in scanners *and* to plugin sources,
|
||||
/// which is what lets one toggle keep working verbatim across the whole migration: the ids match
|
||||
/// (provider id = claimed store id = old scanner id).
|
||||
/// * **Store claims** (D2): while a library plugin holds a store's claim, the matching built-in
|
||||
/// scanner is skipped so the two never double-list the same titles during the bridge releases.
|
||||
/// Removing the plugin releases the claim and the built-in comes straight back.
|
||||
///
|
||||
/// The user-curated custom store is not a source and always contributes.
|
||||
pub fn all_games() -> Vec<GameEntry> {
|
||||
let off = disabled_scanners();
|
||||
let on = |id: &str| !off.contains(id);
|
||||
let claimed = claimed_stores();
|
||||
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
|
||||
// store out from under it.
|
||||
let on = |id: &str| !off.contains(id) && !claimed.contains_key(id);
|
||||
let mut games = Vec::new();
|
||||
if on("steam") {
|
||||
games.extend(SteamProvider.list());
|
||||
@@ -262,7 +302,15 @@ pub fn all_games() -> Vec<GameEntry> {
|
||||
games.extend(XboxProvider.list());
|
||||
}
|
||||
}
|
||||
games.extend(load_custom().into_iter().map(GameEntry::from));
|
||||
// Stored entries: manual ones always contribute; a provider's are subject to the same source
|
||||
// toggle a built-in scanner is (WP2.6). The plugin may keep reconciling while it is off — the
|
||||
// entries stay stored and simply aren't surfaced, exactly like a disabled scanner's titles.
|
||||
games.extend(
|
||||
load_custom()
|
||||
.into_iter()
|
||||
.filter(|e| !source_id_for(e).is_some_and(|src| off.contains(src)))
|
||||
.map(GameEntry::from),
|
||||
);
|
||||
games.sort_by_key(|g| g.title.to_lowercase());
|
||||
games
|
||||
}
|
||||
|
||||
@@ -147,24 +147,91 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
||||
|
||||
/// A stored [`Artwork`] value that is a **local filesystem path** to an image on the host — as
|
||||
/// opposed to an `http(s)`/`data:` URL or an already-relative host proxy path. Provider plugins that
|
||||
/// run on the host (e.g. the Playnite sync plugin) set these: the reconcile payload stays tiny
|
||||
/// (paths, not inlined bytes, so it scales to thousands of titles) and the host serves the bytes
|
||||
/// through the art proxy, exactly like Steam's cache art. Windows-shaped only (`C:\…`, `C:/…`, or a
|
||||
/// `\\server\share` UNC) — Playnite, the only local-art provider, is Windows-only, and this keeps the
|
||||
/// check from ever mistaking the `/api/…` proxy path (or a POSIX abs path) for a local file.
|
||||
/// run on the host (the Playnite sync plugin, and every library scanner plugin) set these: the
|
||||
/// reconcile payload stays tiny (paths, not inlined bytes, so it scales to thousands of titles) and
|
||||
/// the host serves the bytes through the art proxy, exactly like Steam's cache art.
|
||||
///
|
||||
/// Four accepted shapes:
|
||||
/// * `file://…` — the **documented plugin contract** ([`file_url_to_path`]), unambiguous on every
|
||||
/// platform, and what `@punktfunk/plugin-kit/library` emits.
|
||||
/// * `C:\…` / `C:/…` drive-absolute and `\\server\share` UNC — Windows bare paths, kept for
|
||||
/// Playnite back-compat (it predates the `file://` contract).
|
||||
/// * POSIX absolute (`/home/u/covers/x.jpg`) — Lutris covers and Steam's `librarycache`.
|
||||
///
|
||||
/// The POSIX widening is why the two `/`-leading shapes the **host itself emits** must be excluded
|
||||
/// explicitly: its own art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and
|
||||
/// which must survive a second pass unchanged) and a protocol-relative URL (`//cdn/…`, what GOG's and
|
||||
/// Microsoft's catalogs return — see [`abs_url`]). Mistaking either for a file would break the proxy
|
||||
/// round-trip or silently drop CDN art.
|
||||
pub fn is_local_art_path(v: &str) -> bool {
|
||||
if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("data:") {
|
||||
return false;
|
||||
}
|
||||
if v.starts_with("file://") {
|
||||
return true;
|
||||
}
|
||||
let b = v.as_bytes();
|
||||
(b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\")
|
||||
// Windows drive-absolute (`C:\…`, `C:/…`) or UNC (`\\server\share`).
|
||||
if (b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\") {
|
||||
return true;
|
||||
}
|
||||
// POSIX absolute, minus the host's own `/`-leading shapes (see the doc comment).
|
||||
v.starts_with('/') && !v.starts_with("//") && !v.starts_with("/api/")
|
||||
}
|
||||
|
||||
/// Turn a `file://` art value into a plain filesystem path, percent-decoding it. The kit emits
|
||||
/// properly encoded URLs (`file:///home/u/My%20Cover.jpg`); a raw path that happens to contain no
|
||||
/// `%` round-trips either way, which keeps hand-written plugin payloads working.
|
||||
///
|
||||
/// `file:///home/u/c.jpg` → `/home/u/c.jpg`; `file:///C:/covers/c.jpg` → `C:/covers/c.jpg` (Windows
|
||||
/// drive letters arrive after the empty authority's slash); a NON-empty authority
|
||||
/// (`file://nas/share/c.jpg`) is a UNC reference → `\\nas\share\c.jpg`. Anything without the prefix
|
||||
/// is returned untouched.
|
||||
fn file_url_to_path(v: &str) -> std::borrow::Cow<'_, str> {
|
||||
use std::borrow::Cow;
|
||||
let Some(rest) = v.strip_prefix("file://") else {
|
||||
return Cow::Borrowed(v);
|
||||
};
|
||||
let decoded = percent_decode(rest);
|
||||
match decoded.strip_prefix('/') {
|
||||
// `file:///…` — the empty-authority form. A Windows drive letter (`/C:/…`) loses the slash;
|
||||
// a POSIX path keeps it.
|
||||
Some(after) if after.as_bytes().get(1) == Some(&b':') => Cow::Owned(after.to_string()),
|
||||
Some(_) => Cow::Owned(decoded),
|
||||
// `file://server/share/…` — a UNC path in URL clothing.
|
||||
None => Cow::Owned(format!("\\\\{}", decoded.replace('/', "\\"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-decode `%XX` escapes. Invalid escapes are left verbatim (a bare `%` in a real path is far
|
||||
/// likelier than a malformed URL from our own kit), and the result is only ever used as a path that
|
||||
/// must then exist as a regular file — so a wrong decode degrades to "no art", never to a wrong read.
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'%' && i + 2 < b.len() {
|
||||
let hex = |c: u8| (c as char).to_digit(16);
|
||||
if let (Some(hi), Some(lo)) = (hex(b[i + 1]), hex(b[i + 2])) {
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(b[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
|
||||
}
|
||||
|
||||
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
|
||||
/// existing regular file, is empty, or exceeds 16 MiB (a cover never approaches that; the cap bounds
|
||||
/// host memory). Content-type is guessed from the extension.
|
||||
/// host memory). Content-type is guessed from the extension. Accepts every shape
|
||||
/// [`is_local_art_path`] does — a `file://` value is converted to a path first.
|
||||
pub fn local_art_bytes(path: &str) -> Option<(Vec<u8>, String)> {
|
||||
let p = std::path::Path::new(path);
|
||||
let path = file_url_to_path(path);
|
||||
let p = std::path::Path::new(&*path);
|
||||
let meta = std::fs::metadata(p).ok()?;
|
||||
if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 {
|
||||
return None;
|
||||
@@ -221,9 +288,22 @@ pub fn proxy_local_art(id: &str, art: &mut Artwork) {
|
||||
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
|
||||
/// async runtime (e.g. `spawn_blocking`).
|
||||
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
|
||||
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
|
||||
// those kinds directly instead of going through the URL fields.
|
||||
// Same resolution order as the management art proxy (WP1.2): the stored catalog first, for ANY
|
||||
// id, so a library plugin's entries resolve without the warmer knowing its store.
|
||||
if let Some(entry) = entry_for_library_id(id) {
|
||||
return [
|
||||
ArtKind::Portrait,
|
||||
ArtKind::Header,
|
||||
ArtKind::Hero,
|
||||
ArtKind::Logo,
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|kind| art_field(&entry.art, kind))
|
||||
.find_map(|v| resolve_art_bytes(&v));
|
||||
}
|
||||
// Legacy in-host Steam scanner: its `Artwork` fields are relative proxy paths (see `steam_art`)
|
||||
// the *client* resolves against the host — meaningless to `fetch_image`, which expects an
|
||||
// absolute URL. Resolve those kinds directly instead of going through the URL fields.
|
||||
if let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
@@ -237,6 +317,7 @@ pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||
.into_iter()
|
||||
.find_map(|kind| steam_art_bytes(appid, kind));
|
||||
}
|
||||
// The remaining in-host scanners (heroic/lutris/epic/gog/xbox) carry absolute CDN URLs.
|
||||
let g = all_games().into_iter().find(|g| g.id == id)?;
|
||||
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
|
||||
.into_iter()
|
||||
@@ -335,19 +416,60 @@ mod tests {
|
||||
assert!(fetch_image("data:image/png;base64,").is_none());
|
||||
}
|
||||
|
||||
/// The full accept/exclude table (WP1.2). The exclusions are the load-bearing half: two of the
|
||||
/// three `/`-leading shapes here are emitted by the host ITSELF, so a POSIX rule that swallowed
|
||||
/// them would break the proxy round-trip and silently drop CDN art.
|
||||
#[test]
|
||||
fn local_art_path_detection() {
|
||||
// Windows-shaped local paths a provider (Playnite) would store.
|
||||
assert!(is_local_art_path(r"C:\Users\me\cover.jpg"));
|
||||
assert!(is_local_art_path("C:/Users/me/cover.png"));
|
||||
assert!(is_local_art_path(r"\\nas\share\art.jpg"));
|
||||
// URLs and the host proxy path are NOT local files.
|
||||
// The `file://` plugin contract, on both platform shapes.
|
||||
assert!(is_local_art_path("file:///home/u/covers/x.jpg"));
|
||||
assert!(is_local_art_path("file:///C:/covers/x.jpg"));
|
||||
// POSIX absolute — lutris covers, steam librarycache.
|
||||
assert!(is_local_art_path("/home/u/.cache/lutris/coverart/x.jpg"));
|
||||
assert!(is_local_art_path("/var/lib/steam/librarycache/570/h.jpg"));
|
||||
// URLs are NOT local files.
|
||||
assert!(!is_local_art_path("https://cdn/x.jpg"));
|
||||
assert!(!is_local_art_path("http://host/x.jpg"));
|
||||
assert!(!is_local_art_path("data:image/png;base64,AAAA"));
|
||||
// …nor is the host's OWN art-proxy path (it must survive a second `proxy_local_art` pass).
|
||||
assert!(!is_local_art_path(
|
||||
"/api/v1/library/art/custom:abc/portrait"
|
||||
));
|
||||
assert!(!is_local_art_path("/api/v1/library/art/steam:570/hero"));
|
||||
// …nor a protocol-relative CDN URL (what GOG / the MS catalog return — see `abs_url`).
|
||||
assert!(!is_local_art_path("//images.gog.com/abc_vertical.jpg"));
|
||||
// A relative path is not absolute — nothing to serve.
|
||||
assert!(!is_local_art_path("covers/x.jpg"));
|
||||
assert!(!is_local_art_path(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_url_converts_to_a_path_and_percent_decodes() {
|
||||
assert_eq!(file_url_to_path("file:///home/u/c.jpg"), "/home/u/c.jpg");
|
||||
// Percent-encoded spaces — what a correct URL encoder emits for a real-world cover path.
|
||||
assert_eq!(
|
||||
file_url_to_path("file:///home/u/My%20Games/c%2Bx.jpg"),
|
||||
"/home/u/My Games/c+x.jpg"
|
||||
);
|
||||
// Windows drive letters arrive after the empty authority's slash and lose it.
|
||||
assert_eq!(
|
||||
file_url_to_path("file:///C:/covers/c.jpg"),
|
||||
"C:/covers/c.jpg"
|
||||
);
|
||||
// A non-empty authority is a UNC reference.
|
||||
assert_eq!(
|
||||
file_url_to_path("file://nas/share/c.jpg"),
|
||||
r"\\nas\share\c.jpg"
|
||||
);
|
||||
// Non-`file://` values are returned untouched (bare paths still work).
|
||||
assert_eq!(file_url_to_path("/home/u/c.jpg"), "/home/u/c.jpg");
|
||||
assert_eq!(file_url_to_path(r"C:\c.jpg"), r"C:\c.jpg");
|
||||
// A lone `%` (a legal path character) is not mangled into a decode failure.
|
||||
assert_eq!(file_url_to_path("file:///home/100%.jpg"), "/home/100%.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -371,6 +493,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A POSIX local cover — the shape the lutris pilot and the steam plugin emit — makes the whole
|
||||
/// round trip: detected as local, rewritten to the proxy path, and read back as bytes. This is
|
||||
/// the case G4 blocked (Lutris art was inlined as `data:` URLs and blew the 2 MB body limit).
|
||||
#[test]
|
||||
fn posix_local_art_round_trips_through_the_proxy() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-posix-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("cover.jpg");
|
||||
std::fs::write(&f, [9u8, 9, 9]).unwrap();
|
||||
let path = f.to_str().unwrap().to_string();
|
||||
|
||||
let mut art = Artwork {
|
||||
portrait: Some(path.clone()),
|
||||
hero: Some(format!("file://{path}")),
|
||||
logo: Some("https://cdn/l.png".into()),
|
||||
header: None,
|
||||
};
|
||||
// Only on non-Windows is a temp path POSIX-absolute; on Windows it is drive-absolute, which
|
||||
// the pre-existing rule already accepted — either way both fields are local.
|
||||
assert!(is_local_art_path(&path));
|
||||
proxy_local_art("lutris:42", &mut art);
|
||||
assert_eq!(
|
||||
art.portrait.as_deref(),
|
||||
Some("/api/v1/library/art/lutris:42/portrait")
|
||||
);
|
||||
assert_eq!(
|
||||
art.hero.as_deref(),
|
||||
Some("/api/v1/library/art/lutris:42/hero"),
|
||||
"a file:// value is local art too"
|
||||
);
|
||||
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
|
||||
|
||||
// Re-running the rewrite is a no-op — the emitted proxy path must not be mistaken for a file.
|
||||
let before = art.portrait.clone();
|
||||
proxy_local_art("lutris:42", &mut art);
|
||||
assert_eq!(art.portrait, before);
|
||||
|
||||
// Both spellings read back to the same bytes.
|
||||
assert_eq!(local_art_bytes(&path).expect("bare path").0, vec![9, 9, 9]);
|
||||
assert_eq!(
|
||||
local_art_bytes(&format!("file://{path}"))
|
||||
.expect("file url")
|
||||
.0,
|
||||
vec![9, 9, 9]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_art_bytes_reads_a_real_file() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
|
||||
@@ -381,6 +551,15 @@ mod tests {
|
||||
assert_eq!(bytes, vec![1, 2, 3, 4]);
|
||||
assert_eq!(ctype, "image/png");
|
||||
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
|
||||
// A directory is not a servable cover, and neither is a traversal that lands on one — the
|
||||
// "existing REGULAR file" check is the confinement, since a plugin's art values are
|
||||
// operator-trusted paths but must still never turn the proxy into a directory reader.
|
||||
assert!(local_art_bytes(dir.to_str().unwrap()).is_none());
|
||||
let up = dir.join("..").join(dir.file_name().unwrap());
|
||||
assert!(
|
||||
local_art_bytes(up.to_str().unwrap()).is_none(),
|
||||
"dir via .."
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ pub struct CustomEntry {
|
||||
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub external_id: Option<String>,
|
||||
/// The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.
|
||||
/// `None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.
|
||||
///
|
||||
/// Materialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an
|
||||
/// entry is self-describing: its id and its `store` badge derive from the entry alone, and stay
|
||||
/// correct even while the claim map is being rewritten.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub store: Option<String>,
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
|
||||
#[serde(default, skip_serializing_if = "GameRole::is_game")]
|
||||
pub role: GameRole,
|
||||
/// 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.
|
||||
///
|
||||
@@ -53,6 +64,10 @@ pub struct CustomInput {
|
||||
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
||||
#[serde(default)]
|
||||
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher
|
||||
/// entry is legal (an operator may want a "Steam" tile without installing the steam plugin).
|
||||
#[serde(default)]
|
||||
pub role: GameRole,
|
||||
/// How to recognize this title's process — see [`CustomEntry::detect`].
|
||||
#[serde(default)]
|
||||
pub detect: DetectHint,
|
||||
@@ -76,6 +91,10 @@ pub struct ProviderEntryInput {
|
||||
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
|
||||
#[serde(default)]
|
||||
pub prep: Vec<crate::hooks::PrepCmd>,
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin
|
||||
/// emits its `launchers(cfg)` entries with `role: "launcher"`.
|
||||
#[serde(default)]
|
||||
pub role: GameRole,
|
||||
/// 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.
|
||||
@@ -101,10 +120,13 @@ impl From<CustomEntry> for GameEntry {
|
||||
.unwrap_or_default()
|
||||
.or_hint(&c.detect);
|
||||
GameEntry {
|
||||
id: format!("custom:{}", c.id),
|
||||
store: "custom".into(),
|
||||
id: library_id_for(&c),
|
||||
// A claimed entry wears its store's badge; everything else is `custom`. `provider` rides
|
||||
// along either way, so attribution ("synced by the steam plugin") survives the claim.
|
||||
store: c.store.clone().unwrap_or_else(|| "custom".into()),
|
||||
title: c.title,
|
||||
art: c.art,
|
||||
role: c.role,
|
||||
launch: c.launch,
|
||||
provider: c.provider,
|
||||
detect,
|
||||
@@ -122,42 +144,123 @@ fn custom_path() -> PathBuf {
|
||||
pf_paths::config_dir().join("library.json")
|
||||
}
|
||||
|
||||
/// Load the custom entries (empty + non-fatal if the file is absent or malformed).
|
||||
pub fn load_custom() -> Vec<CustomEntry> {
|
||||
/// The persisted catalog (`library.json` **v2**): the entries plus the store-claim map (D2).
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Catalog {
|
||||
#[serde(default)]
|
||||
pub entries: Vec<CustomEntry>,
|
||||
/// `store id → provider id`. One provider per store; a second claimant is refused (409).
|
||||
///
|
||||
/// The map — not the entries — is the authority for a claim, which is exactly why it survives an
|
||||
/// **empty reconcile**: a store the plugin legitimately owns can have zero installed titles, and
|
||||
/// the built-in scanner it suppresses must stay suppressed anyway. Releasing is explicit
|
||||
/// (`DELETE /library/provider/{p}`, or the plugin claiming a different store).
|
||||
#[serde(default)]
|
||||
pub claims: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// What `library.json` may contain on disk. v1 was a bare array of entries; v2 is the [`Catalog`]
|
||||
/// object. Untagged, so an existing v1 file loads unchanged — and the host always WRITES v2, so the
|
||||
/// first mutation after an upgrade migrates the file in place with no separate migration step.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum LibraryFile {
|
||||
V2(Catalog),
|
||||
Legacy(Vec<CustomEntry>),
|
||||
}
|
||||
|
||||
/// Load the whole catalog (default + non-fatal if the file is absent or malformed).
|
||||
pub fn load_catalog() -> Catalog {
|
||||
match std::fs::read_to_string(custom_path()) {
|
||||
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
|
||||
Vec::new()
|
||||
}),
|
||||
Err(_) => Vec::new(),
|
||||
Ok(raw) => match serde_json::from_str::<LibraryFile>(&raw) {
|
||||
Ok(LibraryFile::V2(c)) => c,
|
||||
Ok(LibraryFile::Legacy(entries)) => Catalog {
|
||||
entries,
|
||||
claims: BTreeMap::new(),
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
|
||||
Catalog::default()
|
||||
}
|
||||
},
|
||||
Err(_) => Catalog::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a custom/provider entry's stored **local** art file for one [`ArtKind`] — the non-Steam
|
||||
/// branch of the art proxy (`GET /library/art/custom:<id>/<kind>`). `id` is the bare custom id (the
|
||||
/// `custom:` prefix already stripped by the handler). `None` if the entry is unknown, has no art of
|
||||
/// that kind, or that art value isn't a servable local file (e.g. an `http` URL the client fetches
|
||||
/// itself). Blocking IO — call off the async runtime.
|
||||
pub fn custom_local_art_bytes(id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
let entry = load_custom().into_iter().find(|e| e.id == id)?;
|
||||
let field = match kind {
|
||||
ArtKind::Portrait => entry.art.portrait,
|
||||
ArtKind::Hero => entry.art.hero,
|
||||
ArtKind::Logo => entry.art.logo,
|
||||
ArtKind::Header => entry.art.header,
|
||||
}?;
|
||||
/// Load just the entries — the read path every library surface uses.
|
||||
pub fn load_custom() -> Vec<CustomEntry> {
|
||||
load_catalog().entries
|
||||
}
|
||||
|
||||
/// The active store claims (`store → provider`). Read per library scan to suppress the built-in
|
||||
/// scanner a plugin has taken over (D2).
|
||||
pub fn claimed_stores() -> BTreeMap<String, String> {
|
||||
load_catalog().claims
|
||||
}
|
||||
|
||||
/// The library id a stored entry surfaces as. **The single source of truth for the mapping** —
|
||||
/// [`From<CustomEntry> for GameEntry`] and every id→entry lookup go through it, so the id scheme
|
||||
/// can't drift between the catalog, the art proxy and the launch resolver.
|
||||
///
|
||||
/// A **claimed** entry (D2) gets the deterministic `<store>:<external_id>` its built-in scanner used
|
||||
/// to produce — `steam:440`, `heroic:legendary:Quail` — so entry ids, GameStream FNV-1a app ids,
|
||||
/// client art caches and Moonlight pins all survive the migration to a plugin untouched. That is the
|
||||
/// whole point of the claim: extraction must be invisible to everything downstream. An unclaimed
|
||||
/// entry keeps the opaque host-assigned `custom:<id>`.
|
||||
pub(crate) fn library_id_for(e: &CustomEntry) -> String {
|
||||
match (e.store.as_deref(), e.external_id.as_deref()) {
|
||||
(Some(store), Some(external)) => format!("{store}:{external}"),
|
||||
_ => format!("custom:{}", e.id),
|
||||
}
|
||||
}
|
||||
|
||||
/// The **source id** an entry is toggled by (WP2.6): its claimed store when it has one, else its
|
||||
/// provider id. `None` for a manual entry — the custom store is not a source and can never be
|
||||
/// switched off. Since the claimed store id, the provider id and the old scanner id are all the same
|
||||
/// string by construction, a user's existing disabled state carries over verbatim.
|
||||
pub(crate) fn source_id_for(e: &CustomEntry) -> Option<&str> {
|
||||
e.store.as_deref().or(e.provider.as_deref())
|
||||
}
|
||||
|
||||
/// The stored entry a full **library id** refers to, or `None`. The art proxy resolves *any* id this
|
||||
/// way before falling back to the legacy per-store branches (WP1.2), which is what lets a plugin's
|
||||
/// entries be served regardless of what their ids look like.
|
||||
pub fn entry_for_library_id(library_id: &str) -> Option<CustomEntry> {
|
||||
load_custom()
|
||||
.into_iter()
|
||||
.find(|e| library_id_for(e) == library_id)
|
||||
}
|
||||
|
||||
/// Serve a stored entry's **local** art file for one [`ArtKind`] — the `library.json` branch of the
|
||||
/// art proxy (`GET /library/art/<library id>/<kind>`). `None` if the id names no stored entry, it has
|
||||
/// no art of that kind, or that art value isn't a servable local file (e.g. an `http` URL the client
|
||||
/// fetches itself). Blocking IO — call off the async runtime.
|
||||
pub fn library_local_art_bytes(library_id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
let field = art_field(&entry_for_library_id(library_id)?.art, kind)?;
|
||||
is_local_art_path(&field)
|
||||
.then(|| local_art_bytes(&field))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
|
||||
/// One [`Artwork`] field by kind — the tiny mapping the proxy and the box-art ladder share.
|
||||
pub(crate) fn art_field(art: &Artwork, kind: ArtKind) -> Option<String> {
|
||||
match kind {
|
||||
ArtKind::Portrait => art.portrait.clone(),
|
||||
ArtKind::Hero => art.hero.clone(),
|
||||
ArtKind::Logo => art.logo.clone(),
|
||||
ArtKind::Header => art.header.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the catalog in the **v2** shape (write-then-rename, restrictive perms). Every mutation
|
||||
/// path funnels through here, so a v1 file is upgraded by the first write.
|
||||
fn save_catalog(catalog: &Catalog) -> Result<()> {
|
||||
let dir = pf_paths::config_dir();
|
||||
// Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a
|
||||
// library.json whose `prep`/`launch` commands the host would later execute — the same trust
|
||||
// boundary hooks.json and the mgmt token already use.
|
||||
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
let json = serde_json::to_string_pretty(entries)?;
|
||||
let json = serde_json::to_string_pretty(catalog)?;
|
||||
// Write-then-rename so a crash mid-write never truncates the catalog; `write_secret_file` gives
|
||||
// the temp file its restrictive perms (0600 / SYSTEM+Admins DACL) before the rename carries them
|
||||
// to the final path.
|
||||
@@ -177,19 +280,26 @@ fn new_id(title: &str) -> String {
|
||||
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
|
||||
}
|
||||
|
||||
/// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists,
|
||||
/// but a provider owns it" (the mgmt layer maps the latter to 409, not 404).
|
||||
/// Outcome of a mutation — distinguishes "no such entry" from the two conflict cases the mgmt
|
||||
/// layer maps to 409 rather than 404.
|
||||
pub enum MutateOutcome<T> {
|
||||
Done(T),
|
||||
NotFound,
|
||||
/// The entry belongs to this provider — mutate it through the provider reconcile API
|
||||
/// (or remove the whole provider set); manual edits would be clobbered at the next sync.
|
||||
ProviderOwned(String),
|
||||
/// The requested store claim is already held by a DIFFERENT provider (D2: one provider per
|
||||
/// store). Refusing is the point — two plugins both emitting `steam:440` would collide on entry
|
||||
/// ids, so the second claimant is told who holds it instead of silently taking over.
|
||||
StoreClaimed {
|
||||
store: String,
|
||||
provider: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Create a custom (manual) entry, returning it with its assigned id.
|
||||
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||
let mut entries = load_custom();
|
||||
let mut catalog = load_catalog();
|
||||
let entry = CustomEntry {
|
||||
id: new_id(&input.title),
|
||||
title: input.title,
|
||||
@@ -198,11 +308,13 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||
prep: input.prep,
|
||||
provider: None,
|
||||
external_id: None,
|
||||
store: None,
|
||||
role: input.role,
|
||||
detect: input.detect,
|
||||
meta: input.meta,
|
||||
};
|
||||
entries.push(entry.clone());
|
||||
save_custom(&entries)?;
|
||||
catalog.entries.push(entry.clone());
|
||||
save_catalog(&catalog)?;
|
||||
emit_changed("manual");
|
||||
Ok(entry)
|
||||
}
|
||||
@@ -210,8 +322,8 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||
/// Replace a manual entry's fields (id preserved). Provider-owned entries are refused —
|
||||
/// their state belongs to the provider's reconcile (RFC §8 ownership rule).
|
||||
pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<CustomEntry>> {
|
||||
let mut entries = load_custom();
|
||||
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
|
||||
let mut catalog = load_catalog();
|
||||
let Some(slot) = catalog.entries.iter_mut().find(|e| e.id == id) else {
|
||||
return Ok(MutateOutcome::NotFound);
|
||||
};
|
||||
if let Some(provider) = &slot.provider {
|
||||
@@ -221,25 +333,26 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
|
||||
slot.art = input.art;
|
||||
slot.launch = input.launch;
|
||||
slot.prep = input.prep;
|
||||
slot.role = input.role;
|
||||
slot.detect = input.detect;
|
||||
slot.meta = input.meta;
|
||||
let updated = slot.clone();
|
||||
save_custom(&entries)?;
|
||||
save_catalog(&catalog)?;
|
||||
emit_changed("manual");
|
||||
Ok(MutateOutcome::Done(updated))
|
||||
}
|
||||
|
||||
/// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]).
|
||||
pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
|
||||
let mut entries = load_custom();
|
||||
let Some(entry) = entries.iter().find(|e| e.id == id) else {
|
||||
let mut catalog = load_catalog();
|
||||
let Some(entry) = catalog.entries.iter().find(|e| e.id == id) else {
|
||||
return Ok(MutateOutcome::NotFound);
|
||||
};
|
||||
if let Some(provider) = &entry.provider {
|
||||
return Ok(MutateOutcome::ProviderOwned(provider.clone()));
|
||||
}
|
||||
entries.retain(|e| e.id != id);
|
||||
save_custom(&entries)?;
|
||||
catalog.entries.retain(|e| e.id != id);
|
||||
save_catalog(&catalog)?;
|
||||
emit_changed("manual");
|
||||
Ok(MutateOutcome::Done(()))
|
||||
}
|
||||
@@ -265,6 +378,26 @@ pub fn validate_provider_name(provider: &str) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Store claims become the **prefix of every claimed entry's library id**, so they are far more
|
||||
/// constrained than a provider name: no dots (an id is split on the first `:`, and a dotted store
|
||||
/// would read as a hostname in logs), and the two host-owned namespaces are off-limits — `custom` is
|
||||
/// the unclaimed-entry namespace and `manual` is the no-provider sentinel in `library.changed`.
|
||||
pub fn validate_store_claim(store: &str) -> Result<(), String> {
|
||||
if store == "custom" || store == "manual" {
|
||||
return Err(format!("store id `{store}` is reserved"));
|
||||
}
|
||||
let ok = !store.is_empty()
|
||||
&& store.len() <= 32
|
||||
&& store
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_'));
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("store id must be 1–32 chars of [a-z0-9_-]".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
|
||||
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
|
||||
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
|
||||
@@ -282,6 +415,31 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
||||
e.external_id
|
||||
));
|
||||
}
|
||||
// Closed-vocabulary launch kinds are checked on the way IN as well as at launch time, so a
|
||||
// plugin gets a 400 it can act on rather than a tile that silently refuses to start.
|
||||
if let Some(launch) = &e.launch {
|
||||
if launch.kind == "steam_ui" && !valid_steam_ui(&launch.value) {
|
||||
return Err(format!(
|
||||
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(marker) = &e.detect.env_marker {
|
||||
if !valid_env_key(&marker.key) {
|
||||
return Err(format!(
|
||||
"entries[{i}]: `detect.env_marker.key` must be 1–64 chars of [A-Za-z0-9_]"
|
||||
));
|
||||
}
|
||||
if marker
|
||||
.value
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.len() > MAX_ENV_VALUE)
|
||||
{
|
||||
return Err(format!(
|
||||
"entries[{i}]: `detect.env_marker.value` must be at most {MAX_ENV_VALUE} chars"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -293,6 +451,7 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
||||
fn reconcile_entries(
|
||||
entries: &mut Vec<CustomEntry>,
|
||||
provider: &str,
|
||||
store: Option<&str>,
|
||||
inputs: Vec<ProviderEntryInput>,
|
||||
) -> Vec<CustomEntry> {
|
||||
// The provider's current entries, keyed by its own stable id.
|
||||
@@ -317,6 +476,10 @@ fn reconcile_entries(
|
||||
prep: input.prep,
|
||||
provider: Some(provider.to_string()),
|
||||
external_id: Some(input.external_id),
|
||||
// Stamping the claim per entry is what makes the surfaced id deterministic
|
||||
// (`<store>:<external_id>`) — see `library_id_for`.
|
||||
store: store.map(str::to_string),
|
||||
role: input.role,
|
||||
detect: input.detect,
|
||||
meta: input.meta,
|
||||
});
|
||||
@@ -326,43 +489,86 @@ fn reconcile_entries(
|
||||
result
|
||||
}
|
||||
|
||||
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`).
|
||||
/// The caller validates the name and payload first. Emits `library.changed` with the provider
|
||||
/// as the source.
|
||||
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`), optionally
|
||||
/// under a **store claim** (D2: `?store=steam`). The caller validates the name and payload first.
|
||||
/// Emits `library.changed` with the provider as the source.
|
||||
///
|
||||
/// Claiming is idempotent for the holder and refused for anyone else. A provider holds at most one
|
||||
/// store, so claiming a new one releases whatever it held before — otherwise an abandoned claim would
|
||||
/// go on suppressing a built-in scanner with nothing to replace it.
|
||||
pub fn reconcile_provider(
|
||||
provider: &str,
|
||||
store: Option<&str>,
|
||||
inputs: Vec<ProviderEntryInput>,
|
||||
) -> Result<Vec<CustomEntry>> {
|
||||
let mut entries = load_custom();
|
||||
let result = reconcile_entries(&mut entries, provider, inputs);
|
||||
save_custom(&entries)?;
|
||||
) -> Result<MutateOutcome<Vec<CustomEntry>>> {
|
||||
let mut catalog = load_catalog();
|
||||
if let Some(store) = store {
|
||||
if let Some(holder) = catalog.claims.get(store) {
|
||||
if holder != provider {
|
||||
return Ok(MutateOutcome::StoreClaimed {
|
||||
store: store.to_string(),
|
||||
provider: holder.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let previous: Vec<String> = catalog
|
||||
.claims
|
||||
.iter()
|
||||
.filter(|(s, p)| p.as_str() == provider && s.as_str() != store)
|
||||
.map(|(s, _)| s.clone())
|
||||
.collect();
|
||||
for stale in previous {
|
||||
tracing::info!(provider, released = %stale, claimed = store, "library: provider moved its store claim");
|
||||
catalog.claims.remove(&stale);
|
||||
}
|
||||
if catalog
|
||||
.claims
|
||||
.insert(store.to_string(), provider.to_string())
|
||||
.is_none()
|
||||
{
|
||||
tracing::info!(provider, store, "library: store claimed by a provider");
|
||||
}
|
||||
}
|
||||
let result = reconcile_entries(&mut catalog.entries, provider, store, inputs);
|
||||
save_catalog(&catalog)?;
|
||||
emit_changed(provider);
|
||||
Ok(result)
|
||||
Ok(MutateOutcome::Done(result))
|
||||
}
|
||||
|
||||
/// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the
|
||||
/// clean-uninstall path). Returns how many were removed; no event when nothing was.
|
||||
/// Remove every entry of `provider` **and release its store claim** (RFC §8:
|
||||
/// `DELETE /library/provider/{provider}` — the clean-uninstall path). Returns how many entries were
|
||||
/// removed; no event when nothing changed at all.
|
||||
///
|
||||
/// Releasing here — and only here — is what makes uninstalling a library plugin bring its built-in
|
||||
/// scanner straight back, with no restart and nothing to undo by hand.
|
||||
pub fn delete_provider(provider: &str) -> Result<usize> {
|
||||
let mut entries = load_custom();
|
||||
let before = entries.len();
|
||||
entries.retain(|e| e.provider.as_deref() != Some(provider));
|
||||
let removed = before - entries.len();
|
||||
if removed > 0 {
|
||||
save_custom(&entries)?;
|
||||
let mut catalog = load_catalog();
|
||||
let before = catalog.entries.len();
|
||||
catalog
|
||||
.entries
|
||||
.retain(|e| e.provider.as_deref() != Some(provider));
|
||||
let removed = before - catalog.entries.len();
|
||||
let claims_before = catalog.claims.len();
|
||||
catalog.claims.retain(|_, p| p != provider);
|
||||
let released = claims_before - catalog.claims.len();
|
||||
if removed > 0 || released > 0 {
|
||||
if released > 0 {
|
||||
tracing::info!(provider, released, "library: store claim released");
|
||||
}
|
||||
save_catalog(&catalog)?;
|
||||
emit_changed(provider);
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// The prep/undo steps for a library id — `custom:<id>` entries only (the other stores have no
|
||||
/// The prep/undo steps for a library id — any **stored** entry (the in-host scanners have no
|
||||
/// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead).
|
||||
///
|
||||
/// Resolved through [`entry_for_library_id`] rather than by stripping a `custom:` prefix, so a
|
||||
/// claimed entry's prep still runs: after extraction a `steam:440` entry is a stored one, and
|
||||
/// per-title prep is exactly the kind of thing an operator sets on a game they play.
|
||||
pub fn prep_for(library_id: &str) -> Vec<crate::hooks::PrepCmd> {
|
||||
let Some(id) = library_id.strip_prefix("custom:") else {
|
||||
return Vec::new();
|
||||
};
|
||||
load_custom()
|
||||
.into_iter()
|
||||
.find(|e| e.id == id)
|
||||
entry_for_library_id(library_id)
|
||||
.map(|e| e.prep)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -375,13 +581,7 @@ fn emit_changed(source: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
|
||||
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
|
||||
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
|
||||
/// ([`windows_launch_for`]).
|
||||
pub(crate) fn valid_steam_appid(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
// `valid_steam_appid` moved to `launch.rs` (WP1.1) — it validates a launch value, not a store entry.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -396,6 +596,8 @@ mod tests {
|
||||
prep: Vec::new(),
|
||||
provider: None,
|
||||
external_id: None,
|
||||
store: None,
|
||||
role: GameRole::Game,
|
||||
detect: DetectHint::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
@@ -408,6 +610,7 @@ mod tests {
|
||||
art: Artwork::default(),
|
||||
launch: None,
|
||||
prep: Vec::new(),
|
||||
role: GameRole::Game,
|
||||
detect: DetectHint::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
@@ -429,6 +632,79 @@ mod tests {
|
||||
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
|
||||
}
|
||||
|
||||
/// D2's core promise: a **claimed** entry is indistinguishable from what the built-in scanner
|
||||
/// produced. Same id, same store badge — plus the provider attribution the scanner never had.
|
||||
#[test]
|
||||
fn a_claimed_entry_reproduces_the_scanner_identity() {
|
||||
let mut e = manual("host-assigned", "Portal 2");
|
||||
e.provider = Some("steam".into());
|
||||
e.external_id = Some("620".into());
|
||||
e.store = Some("steam".into());
|
||||
assert_eq!(library_id_for(&e), "steam:620");
|
||||
let g: GameEntry = e.clone().into();
|
||||
assert_eq!(g.id, "steam:620", "exactly what the scanner emitted");
|
||||
assert_eq!(g.store, "steam", "the store badge, not `custom`");
|
||||
assert_eq!(
|
||||
g.provider.as_deref(),
|
||||
Some("steam"),
|
||||
"attribution rides along too"
|
||||
);
|
||||
|
||||
// Unclaimed provider entries are untouched by any of this — rom-manager/playnite keep the
|
||||
// opaque host id they have always had.
|
||||
let mut u = manual("abc", "Chrono Trigger");
|
||||
u.provider = Some("romm".into());
|
||||
u.external_id = Some("rom-1".into());
|
||||
assert_eq!(library_id_for(&u), "custom:abc");
|
||||
assert_eq!(GameEntry::from(u).store, "custom");
|
||||
|
||||
// The source a toggle addresses: the claimed store when there is one, else the provider.
|
||||
assert_eq!(source_id_for(&e), Some("steam"));
|
||||
let mut r = manual("z", "T");
|
||||
r.provider = Some("romm".into());
|
||||
assert_eq!(source_id_for(&r), Some("romm"));
|
||||
assert_eq!(
|
||||
source_id_for(&manual("m", "Manual")),
|
||||
None,
|
||||
"never hideable"
|
||||
);
|
||||
}
|
||||
|
||||
/// A claimed entry keeps its `<store>:<external_id>` id across reconciles no matter what the
|
||||
/// host-assigned id does — which is what keeps GameStream's FNV-1a app ids, client art caches
|
||||
/// and Moonlight pins valid through the migration (the whole point of D2).
|
||||
#[test]
|
||||
fn claimed_ids_are_deterministic_across_reconciles() {
|
||||
let mut entries = Vec::new();
|
||||
let r1 = reconcile_entries(
|
||||
&mut entries,
|
||||
"steam",
|
||||
Some("steam"),
|
||||
vec![input("440", "Team Fortress 2"), input("620", "Portal 2")],
|
||||
);
|
||||
let ids: Vec<String> = r1.iter().map(library_id_for).collect();
|
||||
assert_eq!(ids, ["steam:440", "steam:620"]);
|
||||
|
||||
// Re-sync with a renamed title and a new entry: the surfaced ids for surviving titles are
|
||||
// byte-identical, and a brand-new title's id is derived, not random.
|
||||
let r2 = reconcile_entries(
|
||||
&mut entries,
|
||||
"steam",
|
||||
Some("steam"),
|
||||
vec![
|
||||
input("440", "Team Fortress 2 (2026)"),
|
||||
input("70", "Half-Life"),
|
||||
],
|
||||
);
|
||||
let ids2: Vec<String> = r2.iter().map(library_id_for).collect();
|
||||
assert_eq!(ids2, ["steam:440", "steam:70"]);
|
||||
|
||||
// Dropping the claim on a later reconcile reverts them to opaque custom ids — the entries
|
||||
// are the same rows, so this is exactly the "plugin stopped claiming" degradation.
|
||||
let r3 = reconcile_entries(&mut entries, "steam", None, vec![input("440", "TF2")]);
|
||||
assert!(library_id_for(&r3[0]).starts_with("custom:"));
|
||||
}
|
||||
|
||||
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
|
||||
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
|
||||
/// pre-metadata `library.json` / payload still parses (all-optional).
|
||||
@@ -477,6 +753,7 @@ mod tests {
|
||||
let r1 = reconcile_entries(
|
||||
&mut entries,
|
||||
"romm",
|
||||
None,
|
||||
vec![input("rom-a", "Game A"), input("rom-b", "Game B")],
|
||||
);
|
||||
assert_eq!(r1.len(), 2);
|
||||
@@ -488,6 +765,7 @@ mod tests {
|
||||
let r2 = reconcile_entries(
|
||||
&mut entries,
|
||||
"romm",
|
||||
None,
|
||||
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
|
||||
);
|
||||
assert_eq!(r2.len(), 2);
|
||||
@@ -506,6 +784,7 @@ mod tests {
|
||||
let r3 = reconcile_entries(
|
||||
&mut entries,
|
||||
"romm",
|
||||
None,
|
||||
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -526,7 +805,7 @@ mod tests {
|
||||
.any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch")));
|
||||
|
||||
// Empty payload = remove everything the provider owns (same as DELETE).
|
||||
let r4 = reconcile_entries(&mut entries, "romm", Vec::new());
|
||||
let r4 = reconcile_entries(&mut entries, "romm", None, Vec::new());
|
||||
assert!(r4.is_empty());
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
@@ -535,6 +814,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `library.json` v1 (a bare array) must keep loading, and v2 (the claims object) must round
|
||||
/// trip. This is the only migration in the whole program — get it wrong and an existing host
|
||||
/// silently loses its manual entries on upgrade.
|
||||
#[test]
|
||||
fn v1_and_v2_library_files_both_load() {
|
||||
// v1: exactly what a shipped host has on disk today.
|
||||
let v1 = r#"[{"id":"abc","title":"Old Manual"}]"#;
|
||||
let c = match serde_json::from_str::<LibraryFile>(v1).unwrap() {
|
||||
LibraryFile::Legacy(entries) => Catalog {
|
||||
entries,
|
||||
claims: BTreeMap::new(),
|
||||
},
|
||||
LibraryFile::V2(_) => panic!("an array must not parse as v2"),
|
||||
};
|
||||
assert_eq!(c.entries.len(), 1);
|
||||
assert_eq!(c.entries[0].title, "Old Manual");
|
||||
assert!(c.claims.is_empty());
|
||||
|
||||
// v2, including a claim.
|
||||
let v2 = r#"{"entries":[{"id":"abc","title":"New"}],"claims":{"steam":"steam"}}"#;
|
||||
let c = match serde_json::from_str::<LibraryFile>(v2).unwrap() {
|
||||
LibraryFile::V2(c) => c,
|
||||
LibraryFile::Legacy(_) => panic!("an object must not parse as v1"),
|
||||
};
|
||||
assert_eq!(c.entries.len(), 1);
|
||||
assert_eq!(c.claims.get("steam").map(String::as_str), Some("steam"));
|
||||
|
||||
// A v2 file with no claims key at all (what the first write after upgrade produces before
|
||||
// anything is claimed) still loads.
|
||||
let bare = r#"{"entries":[]}"#;
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<LibraryFile>(bare).unwrap(),
|
||||
LibraryFile::V2(_)
|
||||
));
|
||||
|
||||
// And what we WRITE is v2, so one mutation upgrades the file in place.
|
||||
let written = serde_json::to_string(&Catalog::default()).unwrap();
|
||||
assert!(written.contains("\"entries\""));
|
||||
assert!(written.contains("\"claims\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_claim_validation() {
|
||||
assert!(validate_store_claim("steam").is_ok());
|
||||
assert!(validate_store_claim("epic-games").is_ok());
|
||||
assert!(validate_store_claim("xbox_pc").is_ok());
|
||||
// The two host-owned namespaces are off-limits.
|
||||
assert!(validate_store_claim("custom").is_err());
|
||||
assert!(validate_store_claim("manual").is_err());
|
||||
assert!(validate_store_claim("").is_err());
|
||||
assert!(validate_store_claim("Steam").is_err()); // no uppercase
|
||||
// A dot would read as a hostname in a log line and muddies the `store:id` split.
|
||||
assert!(validate_store_claim("my.store").is_err());
|
||||
assert!(validate_store_claim(&"s".repeat(33)).is_err());
|
||||
}
|
||||
|
||||
/// The closed-vocabulary fields are rejected at the door, so a plugin gets a 400 rather than a
|
||||
/// tile that silently refuses to launch.
|
||||
#[test]
|
||||
fn payload_validation_covers_the_new_closed_vocabularies() {
|
||||
let with_launch = |kind: &str, value: &str| {
|
||||
let mut i = input("a", "A");
|
||||
i.launch = Some(LaunchSpec {
|
||||
kind: kind.into(),
|
||||
value: value.into(),
|
||||
});
|
||||
i
|
||||
};
|
||||
assert!(validate_provider_payload(&[with_launch("steam_ui", "bigpicture")]).is_ok());
|
||||
assert!(validate_provider_payload(&[with_launch("steam_ui", "desktop")]).is_ok());
|
||||
assert!(validate_provider_payload(&[with_launch("steam_ui", "gamepad")]).is_err());
|
||||
assert!(validate_provider_payload(&[with_launch("steam_ui", "")]).is_err());
|
||||
// Other kinds are unconstrained here (the host validates them per-kind at launch).
|
||||
assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok());
|
||||
|
||||
let with_env = |key: &str, value: Option<&str>| {
|
||||
let mut i = input("a", "A");
|
||||
i.detect.env_marker = Some(EnvMarker {
|
||||
key: key.into(),
|
||||
value: value.map(str::to_string),
|
||||
});
|
||||
i
|
||||
};
|
||||
assert!(validate_provider_payload(&[with_env("HEROIC_APP_NAME", Some("Quail"))]).is_ok());
|
||||
assert!(validate_provider_payload(&[with_env("BAD-KEY", None)]).is_err());
|
||||
assert!(validate_provider_payload(&[with_env("", None)]).is_err());
|
||||
assert!(
|
||||
validate_provider_payload(&[with_env("K", Some(&"x".repeat(MAX_ENV_VALUE + 1)))])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_name_and_payload_validation() {
|
||||
assert!(validate_provider_name("romm").is_ok());
|
||||
|
||||
@@ -19,15 +19,34 @@
|
||||
use super::*;
|
||||
|
||||
/// An environment variable a launcher stamps onto the game's process, identifying it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
///
|
||||
/// Serializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin
|
||||
/// that knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to
|
||||
/// be able to say so, since after extraction the host no longer reads that launcher's files itself.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct EnvMarker {
|
||||
/// The variable name (e.g. `HEROIC_GAME_ID`).
|
||||
#[schema(example = "HEROIC_APP_NAME")]
|
||||
pub key: String,
|
||||
/// The exact value to require, when the launcher's value identifies *this* title. `None` matches
|
||||
/// the key's mere presence — only safe for launchers that run one game at a time.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
/// The env-var name charset a hint may carry: `[A-Za-z0-9_]{1,64}`, POSIX-shaped. An out-of-charset
|
||||
/// key is not a real environment variable, so accepting one could only ever produce a matcher rule
|
||||
/// that never fires (or, with an absurd length, a needless per-process comparison cost).
|
||||
pub(crate) fn valid_env_key(key: &str) -> bool {
|
||||
!key.is_empty()
|
||||
&& key.len() <= 64
|
||||
&& key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
}
|
||||
|
||||
/// Longest env-var VALUE a hint may pin. Values are compared against every candidate process's
|
||||
/// environment, so an unbounded one is a (small) DoS lever and never a legitimate game id.
|
||||
pub(crate) const MAX_ENV_VALUE: usize = 256;
|
||||
|
||||
/// The signals that identify a launched title's process(es). Every field is optional and
|
||||
/// independent; an all-`None` spec means "this title can't be tracked" (the lease degrades to
|
||||
/// [`crate::gamelease::LeaseKind::Untracked`] and both lifetime behaviors stay inert for it).
|
||||
@@ -115,6 +134,11 @@ impl DetectSpec {
|
||||
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);
|
||||
// D3: the two store-derived signals are fillable from a hint now that the store may live in
|
||||
// a plugin. Same rule as the other three — the host's own finding wins where it has one,
|
||||
// which for a provider entry is moot (the host scanned nothing for it).
|
||||
self.steam_appid = self.steam_appid.or(from.steam_appid);
|
||||
self.env_marker = self.env_marker.or(from.env_marker);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -143,12 +167,31 @@ pub struct DetectHint {
|
||||
/// — see [`DetectSpec::process_name`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub process_name: Option<String>,
|
||||
/// The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**
|
||||
/// signal that exists — Steam wraps every launch, native or Proton, in
|
||||
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a
|
||||
/// steam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub steam_appid: Option<u32>,
|
||||
/// A launcher-stamped environment marker (D3) — see [`EnvMarker`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env_marker: Option<EnvMarker>,
|
||||
}
|
||||
|
||||
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()
|
||||
self.trimmed().is_none() && self.steam_appid.is_none() && self.env_marker().is_none()
|
||||
}
|
||||
|
||||
/// The env marker, if it is well-formed. A malformed one is dropped rather than rejected, for
|
||||
/// the same reason a blank `install_dir` is: hint fields are hand-writable plugin input, and the
|
||||
/// matcher must never be handed a rule it can't honour.
|
||||
fn env_marker(&self) -> Option<&EnvMarker> {
|
||||
self.env_marker
|
||||
.as_ref()
|
||||
.filter(|m| valid_env_key(&m.key))
|
||||
.filter(|m| m.value.as_ref().is_none_or(|v| v.len() <= MAX_ENV_VALUE))
|
||||
}
|
||||
|
||||
/// The hint with blank fields dropped, or `None` if nothing is left. Console text inputs and
|
||||
@@ -166,14 +209,13 @@ impl DetectHint {
|
||||
/// 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();
|
||||
};
|
||||
let (install_dir, exe, process_name) = h.trimmed().unwrap_or((None, None, None));
|
||||
Self {
|
||||
install_dir: install_dir.map(PathBuf::from),
|
||||
exe: exe.map(PathBuf::from),
|
||||
process_name: process_name.map(str::to_string),
|
||||
..Default::default()
|
||||
steam_appid: h.steam_appid,
|
||||
env_marker: h.env_marker().cloned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,6 +315,7 @@ mod tests {
|
||||
install_dir: Some("".into()),
|
||||
exe: Some(" ".into()),
|
||||
process_name: Some("\t".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(blank.is_empty());
|
||||
assert!(DetectSpec::from(&blank).is_empty(), "nothing to match on");
|
||||
@@ -281,6 +324,7 @@ mod tests {
|
||||
install_dir: Some(" /games/quail ".into()),
|
||||
exe: None,
|
||||
process_name: Some("quail".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!hint.is_empty());
|
||||
let spec = DetectSpec::from(&hint);
|
||||
@@ -299,6 +343,7 @@ mod tests {
|
||||
install_dir: Some("/games/wrong".into()),
|
||||
exe: Some("/games/real/run".into()),
|
||||
process_name: None,
|
||||
..Default::default()
|
||||
};
|
||||
let merged = found.or_hint(&hint);
|
||||
assert_eq!(
|
||||
@@ -317,6 +362,74 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// D3: the two store-derived signals now ride the hint, because after extraction the host no
|
||||
/// longer reads Steam's or Heroic's files itself. Without them a plugin's lease tracking would
|
||||
/// silently degrade — reaper-exact to dir-prefix on Linux Steam, and gone entirely for Heroic
|
||||
/// under Proton, where the env marker is the only thing that works.
|
||||
#[test]
|
||||
fn a_hint_can_carry_the_store_derived_signals() {
|
||||
let hint = DetectHint {
|
||||
steam_appid: Some(440),
|
||||
env_marker: Some(EnvMarker {
|
||||
key: "HEROIC_APP_NAME".into(),
|
||||
value: Some("Quail".into()),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!hint.is_empty(), "either field alone is a real hint");
|
||||
let spec = DetectSpec::from(&hint);
|
||||
assert_eq!(spec.steam_appid, Some(440));
|
||||
assert_eq!(spec.env_marker.as_ref().unwrap().key, "HEROIC_APP_NAME");
|
||||
|
||||
// A steam_appid on its own is enough to be trackable.
|
||||
let only_appid = DetectHint {
|
||||
steam_appid: Some(620),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!only_appid.is_empty());
|
||||
assert!(!DetectSpec::from(&only_appid).is_empty());
|
||||
|
||||
// The host's own finding still wins where it has one (unchanged rule).
|
||||
let found = DetectSpec::steam(70);
|
||||
assert_eq!(found.or_hint(&hint).steam_appid, Some(70));
|
||||
// …but a field the host had nothing for is filled in.
|
||||
assert_eq!(
|
||||
DetectSpec::dir("/games/x")
|
||||
.or_hint(&hint)
|
||||
.env_marker
|
||||
.unwrap()
|
||||
.key,
|
||||
"HEROIC_APP_NAME"
|
||||
);
|
||||
}
|
||||
|
||||
/// A malformed marker is DROPPED, not honoured — same posture as a blank `install_dir`. The
|
||||
/// matcher must never be handed a rule it cannot evaluate, and these values reach a code path
|
||||
/// that can end processes.
|
||||
#[test]
|
||||
fn a_malformed_env_marker_says_nothing() {
|
||||
let bad = |key: &str, value: Option<String>| DetectHint {
|
||||
env_marker: Some(EnvMarker {
|
||||
key: key.into(),
|
||||
value,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(bad("", None).is_empty());
|
||||
assert!(bad("HAS-DASH", None).is_empty(), "not a POSIX env name");
|
||||
assert!(bad("HAS SPACE", None).is_empty());
|
||||
assert!(bad(&"K".repeat(65), None).is_empty(), "over the key cap");
|
||||
assert!(
|
||||
bad("K", Some("v".repeat(MAX_ENV_VALUE + 1))).is_empty(),
|
||||
"over the value cap"
|
||||
);
|
||||
// …and a well-formed one at exactly the caps is kept.
|
||||
assert!(!bad(&"K".repeat(64), Some("v".repeat(MAX_ENV_VALUE))).is_empty());
|
||||
assert!(DetectSpec::from(&bad("HAS-DASH", None))
|
||||
.env_marker
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_token_handles_quotes_and_spaces() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -100,6 +100,7 @@ fn epic_entry(
|
||||
};
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("epic:{app_name}"),
|
||||
store: "epic".into(),
|
||||
@@ -186,25 +187,8 @@ fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork>
|
||||
map
|
||||
}
|
||||
|
||||
/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple
|
||||
/// `<namespace>:<catalogItemId>:<appName>` (colons URL-encoded), or a bare `<appName>` fallback.
|
||||
/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
|
||||
let ok = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
};
|
||||
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
|
||||
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
|
||||
[app] if ok(app) => (*app).to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
|
||||
))
|
||||
}
|
||||
// The `epic` launch mapping (`epic_launch_uri`) lives in `launch.rs` (WP1.1) — this module
|
||||
// enumerates, it does not launch.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -236,19 +220,4 @@ mod tests {
|
||||
assert!(epic_entry(&gone, &empty).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn epic_launch_uri_triple_bare_and_guard() {
|
||||
assert_eq!(
|
||||
epic_launch_uri("fn:abc:Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
|
||||
);
|
||||
assert_eq!(
|
||||
epic_launch_uri("Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
|
||||
);
|
||||
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
|
||||
assert!(epic_launch_uri("").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ fn gog_games() -> Vec<GameEntry> {
|
||||
let detect = DetectSpec::exe(&exe).with_dir(&path);
|
||||
out.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "gog".into(),
|
||||
@@ -133,38 +134,13 @@ fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the spawn `(command line, working dir)` for a `gog` launch value (`exe \t args \t workdir`,
|
||||
/// all host-resolved from the operator's own disk). Direct exe — no shell, no Galaxy.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
||||
let mut parts = value.split('\t');
|
||||
let exe = parts.next().filter(|s| !s.is_empty())?;
|
||||
let args = parts.next().unwrap_or("");
|
||||
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
|
||||
let cmdline = if args.trim().is_empty() {
|
||||
format!("\"{exe}\"")
|
||||
} else {
|
||||
format!("\"{exe}\" {args}")
|
||||
};
|
||||
Some((cmdline, workdir))
|
||||
}
|
||||
// The `gog` launch mapping (`gog_spawn`) lives in `launch.rs` (WP1.1) — this module enumerates and
|
||||
// resolves the spawn triple off disk, but turning that triple into a command line is launch-side.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_spawn_parses_and_guards() {
|
||||
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
|
||||
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
|
||||
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
|
||||
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
|
||||
assert_eq!(cmd2, "\"C:\\g.exe\"");
|
||||
assert!(wd2.is_none());
|
||||
assert!(gog_spawn("").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_play_task_picks_primary_filetask() {
|
||||
|
||||
@@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
};
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("heroic:{runner}:{app_name}"),
|
||||
store: "heroic".into(),
|
||||
@@ -128,48 +129,8 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
||||
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
||||
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
|
||||
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
|
||||
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn heroic_command(value: &str) -> Option<String> {
|
||||
let (runner, app) = value.split_once(':')?;
|
||||
if !matches!(runner, "legendary" | "gog" | "nile") {
|
||||
return None;
|
||||
}
|
||||
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
|
||||
if app.is_empty()
|
||||
|| !app
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let prefix = heroic_launch_prefix()?;
|
||||
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
|
||||
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
|
||||
Some(format!(
|
||||
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
|
||||
))
|
||||
}
|
||||
|
||||
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
|
||||
/// root is present. `None` ⇒ Heroic not found, so no launch command.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_launch_prefix() -> Option<String> {
|
||||
let on_path = std::env::var_os("PATH")
|
||||
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
|
||||
if on_path {
|
||||
return Some("heroic".into());
|
||||
}
|
||||
let flatpak = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
|
||||
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
|
||||
}
|
||||
// The `heroic` launch mapping (`heroic_command` + its launcher-prefix probe) lives in `launch.rs`
|
||||
// (WP1.1) — this module enumerates, it does not launch.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! Title launch: resolve a library id / raw command into an executable command line (per-store +
|
||||
//! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5).
|
||||
//!
|
||||
//! This module owns the **whole launch side** of the library: the `kind` vocabulary, its per-kind
|
||||
//! charset validators, and the per-OS resolvers. That split is deliberate and load-bearing — the
|
||||
//! scanner modules beside it do *enumeration only*, so they can be lifted out into library plugins
|
||||
//! without taking any launch logic with them (design/library-scanner-plugins.md D1: a client sends
|
||||
//! only an entry id and the host resolves the [`LaunchSpec`] it holds, which stays true whether the
|
||||
//! entry was enumerated in-process or reconciled in by a plugin).
|
||||
|
||||
use super::custom::valid_steam_appid;
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::heroic::heroic_command;
|
||||
use super::*;
|
||||
#[cfg(windows)]
|
||||
use super::{epic::epic_launch_uri, gog::gog_spawn};
|
||||
|
||||
/// Everything a session needs about the title it is launching, resolved in **one** library scan:
|
||||
/// what to run, what to call it, and how to recognize it once it is running.
|
||||
@@ -84,6 +86,13 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
|
||||
// Heroic: `<runner>:<appName>` → the validated heroic://launch command (see heroic_command).
|
||||
#[cfg(target_os = "linux")]
|
||||
"heroic" => heroic_command(&spec.value),
|
||||
// A launcher entry (D4): open the Steam client itself, in Big Picture or on the desktop.
|
||||
// Nested in gamescope this is the SteamOS game-mode shape.
|
||||
"steam_ui" => match spec.value.as_str() {
|
||||
"bigpicture" => Some("steam -gamepadui".into()),
|
||||
"desktop" => Some("steam".into()),
|
||||
_ => None,
|
||||
},
|
||||
// Trusted: the command comes from the host's own custom store, never the client.
|
||||
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
|
||||
_ => None,
|
||||
@@ -138,6 +147,21 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
|
||||
};
|
||||
Some((cmdline, None))
|
||||
}
|
||||
// A launcher entry (D4): open the Steam client's own UI. Same Steam.exe-then-explorer ladder
|
||||
// as `steam_appid`, and the URI is one of exactly two host-owned literals — nothing from the
|
||||
// entry is interpolated at all.
|
||||
"steam_ui" => {
|
||||
let uri = match spec.value.as_str() {
|
||||
"bigpicture" => "steam://open/bigpicture",
|
||||
"desktop" => "steam://open/main",
|
||||
_ => return None,
|
||||
};
|
||||
let cmdline = match steam_exe() {
|
||||
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
|
||||
None => format!("explorer.exe \"{uri}\""),
|
||||
};
|
||||
Some((cmdline, None))
|
||||
}
|
||||
// Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a
|
||||
// concrete EXE that resolves the registered protocol handler as the user; the URI is a single
|
||||
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
|
||||
@@ -191,6 +215,119 @@ fn steam_exe() -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- per-kind launch values (host-owned ABI)
|
||||
//
|
||||
// Each helper below turns a store's launch VALUE — the only part a scanner (or, after extraction, a
|
||||
// library plugin) supplies — into the URI/command line the host actually runs. They live here rather
|
||||
// than beside the enumeration that produces the value because the host keeps owning URI construction
|
||||
// and spawning no matter where the enumeration came from (D1). Every one of them is total and
|
||||
// validating: an unparseable or hostile value yields `None`, never a partially-interpolated command.
|
||||
|
||||
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
|
||||
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
|
||||
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
|
||||
/// ([`windows_launch_for`]).
|
||||
///
|
||||
/// Also accepts the 64-bit non-Steam-shortcut game id ([`shortcut_gameid`]), which is likewise
|
||||
/// digits — the two share the `steam_appid` kind precisely because `rungameid` takes either.
|
||||
pub(crate) fn valid_steam_appid(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
|
||||
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
|
||||
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
|
||||
pub(crate) fn shortcut_gameid(appid: u32) -> u64 {
|
||||
((appid as u64) << 32) | 0x0200_0000
|
||||
}
|
||||
|
||||
/// The `steam_ui` launch values (D4) — which Steam UI a launcher entry opens. A closed two-value
|
||||
/// enum, validated on the way IN (the reconcile payload) as well as on the way out, so an entry can
|
||||
/// never carry a third value that silently resolves to nothing at launch time.
|
||||
pub(crate) fn valid_steam_ui(value: &str) -> bool {
|
||||
matches!(value, "bigpicture" | "desktop")
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
||||
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
||||
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
|
||||
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
|
||||
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn heroic_command(value: &str) -> Option<String> {
|
||||
let (runner, app) = value.split_once(':')?;
|
||||
if !matches!(runner, "legendary" | "gog" | "nile") {
|
||||
return None;
|
||||
}
|
||||
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
|
||||
if app.is_empty()
|
||||
|| !app
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let prefix = heroic_launch_prefix()?;
|
||||
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
|
||||
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
|
||||
Some(format!(
|
||||
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
|
||||
))
|
||||
}
|
||||
|
||||
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
|
||||
/// root is present. `None` ⇒ Heroic not found, so no launch command.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_launch_prefix() -> Option<String> {
|
||||
let on_path = std::env::var_os("PATH")
|
||||
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
|
||||
if on_path {
|
||||
return Some("heroic".into());
|
||||
}
|
||||
let flatpak = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
|
||||
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
|
||||
}
|
||||
|
||||
/// Map an `epic` LaunchSpec value to the Epic Games Launcher URI. The value is either the full
|
||||
/// `<namespace>:<catalogItemId>:<appName>` triple (what the manifests carry) or a bare `appName`;
|
||||
/// every part is charset-checked so the URI stays one safe argv token.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
|
||||
let ok = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
};
|
||||
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
|
||||
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
|
||||
[app] if ok(app) => (*app).to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
|
||||
))
|
||||
}
|
||||
|
||||
/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple the scanner
|
||||
/// derived from `goggame-<id>.info` — to a `(command line, working dir)`. GOG games are spawned
|
||||
/// directly (no Galaxy), so the exe is quoted and the arguments ride verbatim.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
||||
let mut parts = value.split('\t');
|
||||
let exe = parts.next().filter(|s| !s.is_empty())?;
|
||||
let args = parts.next().unwrap_or("");
|
||||
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
|
||||
let cmdline = if args.trim().is_empty() {
|
||||
format!("\"{exe}\"")
|
||||
} else {
|
||||
format!("\"{exe}\" {args}")
|
||||
};
|
||||
Some((cmdline, workdir))
|
||||
}
|
||||
|
||||
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
|
||||
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
|
||||
/// through the compositor-aware [`launch_session_command`] instead.
|
||||
@@ -360,6 +497,101 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `steam_ui` launcher kind (D4): a closed two-value enum, mapped to the Steam client's own
|
||||
/// UI on each OS. Nothing from the entry is interpolated — the value only SELECTS between two
|
||||
/// host-owned literals — so there is no injection surface at all here.
|
||||
#[test]
|
||||
fn steam_ui_is_a_closed_two_value_enum() {
|
||||
assert!(valid_steam_ui("bigpicture"));
|
||||
assert!(valid_steam_ui("desktop"));
|
||||
assert!(!valid_steam_ui("gamepadui"));
|
||||
assert!(!valid_steam_ui(""));
|
||||
assert!(!valid_steam_ui("bigpicture; rm -rf ~"));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn steam_ui_resolves_to_the_client_ui_on_linux() {
|
||||
let ui = |v: &str| {
|
||||
command_for(&LaunchSpec {
|
||||
kind: "steam_ui".into(),
|
||||
value: v.into(),
|
||||
})
|
||||
};
|
||||
// Big Picture is the SteamOS game-mode shape; nested in gamescope this is what `--steam`
|
||||
// integration is built around.
|
||||
assert_eq!(ui("bigpicture").as_deref(), Some("steam -gamepadui"));
|
||||
assert_eq!(ui("desktop").as_deref(), Some("steam"));
|
||||
assert_eq!(ui("nonsense"), None);
|
||||
assert_eq!(ui(""), None);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn steam_ui_resolves_to_the_client_ui_on_windows() {
|
||||
let ui = |v: &str| {
|
||||
windows_launch_for(&LaunchSpec {
|
||||
kind: "steam_ui".into(),
|
||||
value: v.into(),
|
||||
})
|
||||
};
|
||||
let (bp, wd) = ui("bigpicture").expect("bigpicture recipe");
|
||||
assert!(bp.contains("steam://open/bigpicture"), "line was {bp:?}");
|
||||
assert!(wd.is_none());
|
||||
let (desk, _) = ui("desktop").expect("desktop recipe");
|
||||
assert!(desk.contains("steam://open/main"), "line was {desk:?}");
|
||||
assert!(ui("nonsense").is_none());
|
||||
assert!(ui("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_appid_validation_accepts_appids_and_shortcut_gameids() {
|
||||
assert!(valid_steam_appid("570"));
|
||||
// The 64-bit shortcut game id shares the `steam_appid` kind — `rungameid` takes either.
|
||||
assert!(valid_steam_appid(
|
||||
&shortcut_gameid(2_456_789_012).to_string()
|
||||
));
|
||||
assert!(!valid_steam_appid(""));
|
||||
assert!(!valid_steam_appid("570; rm -rf ~"));
|
||||
assert!(!valid_steam_appid("-1"));
|
||||
}
|
||||
|
||||
/// Moved here with `shortcut_gameid` (WP1.1): the composed id is launch vocabulary, not
|
||||
/// enumeration — the scanner only supplies the 32-bit appid it read out of `shortcuts.vdf`.
|
||||
#[test]
|
||||
fn shortcut_gameid_composes_appid_and_marker() {
|
||||
let id = shortcut_gameid(0x8000_0000);
|
||||
assert_eq!(id >> 32, 0x8000_0000, "high dword is the shortcut appid");
|
||||
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000, "low dword is the marker");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn epic_launch_uri_triple_bare_and_guard() {
|
||||
assert_eq!(
|
||||
epic_launch_uri("fn:abc:Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
|
||||
);
|
||||
assert_eq!(
|
||||
epic_launch_uri("Fortnite").as_deref(),
|
||||
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
|
||||
);
|
||||
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
|
||||
assert!(epic_launch_uri("").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_spawn_parses_and_guards() {
|
||||
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
|
||||
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
|
||||
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
|
||||
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
|
||||
assert_eq!(cmd2, "\"C:\\g.exe\"");
|
||||
assert!(wd2.is_none());
|
||||
assert!(gog_spawn("").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_launch_for_maps_and_guards() {
|
||||
|
||||
@@ -84,6 +84,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
||||
for (id, slug, name, directory) in rows.flatten() {
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("lutris:{id}"),
|
||||
store: "lutris".into(),
|
||||
|
||||
@@ -12,19 +12,41 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// One installed-store scanner this host build supports, with its enable state — the unit the
|
||||
/// console renders a toggle for. The list is platform-gated at compile time (the scanners are),
|
||||
/// so the console never shows a toggle that cannot do anything on this host.
|
||||
/// One **game source** on this host, with its enable state — the unit the console renders a toggle
|
||||
/// for. A source is either a scanner compiled into this build or a plugin that reconciles entries in
|
||||
/// (WP2.6); the console treats them identically, which is what makes the extraction invisible.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
pub struct ScannerInfo {
|
||||
/// Stable scanner id — the same string the scanner's entries carry in their `store` field.
|
||||
/// Stable source id — the same string this source's entries carry in their `store` field. For a
|
||||
/// plugin source it is also its provider id and its store claim: one string, by construction, so
|
||||
/// a user's disabled state survives a built-in scanner being replaced by its plugin.
|
||||
#[schema(example = "steam")]
|
||||
pub id: String,
|
||||
/// Human-facing name for the console toggle.
|
||||
#[schema(example = "Steam")]
|
||||
pub label: String,
|
||||
/// Whether this host runs the scanner (default true).
|
||||
/// Whether this host runs the source (default true).
|
||||
pub enabled: bool,
|
||||
/// Where the source comes from: `builtin` (a scanner in this host build) or `plugin`.
|
||||
#[schema(example = "builtin")]
|
||||
pub origin: SourceOrigin,
|
||||
/// The provider id backing a `plugin` source — absent for a built-in scanner.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<String>,
|
||||
/// How many entries this source currently contributes. `None` for a built-in scanner, whose
|
||||
/// count would mean walking every launcher's files just to render a toggle.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub entries: Option<usize>,
|
||||
}
|
||||
|
||||
/// Where a [`ScannerInfo`] comes from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SourceOrigin {
|
||||
/// A scanner compiled into this host build.
|
||||
Builtin,
|
||||
/// A plugin reconciling entries over the provider API.
|
||||
Plugin,
|
||||
}
|
||||
|
||||
/// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are
|
||||
@@ -87,26 +109,93 @@ pub(crate) fn disabled_scanners() -> HashSet<String> {
|
||||
load_settings().disabled.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The scanners available on this platform with their current enable state, in the fixed
|
||||
/// definition order (stable for the console).
|
||||
/// Every game source on this host with its current enable state (WP2.6):
|
||||
///
|
||||
/// 1. the built-in scanners this build compiled in, **minus** any whose store a plugin has claimed
|
||||
/// (the plugin replaces it, so showing both would offer two toggles for one thing);
|
||||
/// 2. the claimed stores themselves, as plugin sources;
|
||||
/// 3. any other provider that has entries — the *emergent* case (rom-manager, playnite), which has
|
||||
/// never had a toggle before and gets one for free here.
|
||||
///
|
||||
/// Built-ins keep their fixed definition order (stable for the console); plugin sources follow,
|
||||
/// sorted by id.
|
||||
pub fn list_scanners() -> Vec<ScannerInfo> {
|
||||
let off = disabled_scanners();
|
||||
scanner_defs()
|
||||
let claims = crate::library::claimed_stores();
|
||||
let entries = crate::library::load_custom();
|
||||
|
||||
let mut out: Vec<ScannerInfo> = scanner_defs()
|
||||
.into_iter()
|
||||
.filter(|(id, _)| !claims.contains_key(*id))
|
||||
.map(|(id, label)| ScannerInfo {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
enabled: !off.contains(id),
|
||||
origin: SourceOrigin::Builtin,
|
||||
provider: None,
|
||||
entries: None,
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
|
||||
// A claimed store shows under the SCANNER's label where we know one, so the row a user has been
|
||||
// toggling for releases doesn't rename itself out from under them mid-migration.
|
||||
let label_for = |id: &str| {
|
||||
scanner_defs()
|
||||
.into_iter()
|
||||
.find(|(sid, _)| *sid == id)
|
||||
.map(|(_, label)| label.to_string())
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
};
|
||||
|
||||
let mut plugin_ids: Vec<(String, String)> = claims
|
||||
.iter()
|
||||
.map(|(store, provider)| (store.clone(), provider.clone()))
|
||||
.collect();
|
||||
// Emergent providers: any provider with entries that isn't already listed via a claim.
|
||||
for e in &entries {
|
||||
let Some(provider) = e.provider.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if e.store.is_none() && !plugin_ids.iter().any(|(id, _)| id == provider) {
|
||||
plugin_ids.push((provider.to_string(), provider.to_string()));
|
||||
}
|
||||
}
|
||||
plugin_ids.sort();
|
||||
plugin_ids.dedup();
|
||||
|
||||
out.extend(plugin_ids.into_iter().map(|(id, provider)| {
|
||||
let count = entries
|
||||
.iter()
|
||||
.filter(|e| crate::library::source_id_for(e) == Some(id.as_str()))
|
||||
.count();
|
||||
ScannerInfo {
|
||||
label: label_for(&id),
|
||||
enabled: !off.contains(&id),
|
||||
origin: SourceOrigin::Plugin,
|
||||
provider: Some(provider),
|
||||
entries: Some(count),
|
||||
id,
|
||||
}
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
/// Enable/disable one scanner. `None` when `id` names no scanner available on this platform (the
|
||||
/// mgmt layer maps that to 404 — the console only ever sees this host's own list). Persists and
|
||||
/// emits `library.changed` (source = the scanner id) only when the state actually changed, so a
|
||||
/// repeated PUT is a cheap no-op.
|
||||
/// Whether `id` names a source that exists on this host right now — a compiled-in scanner, a claimed
|
||||
/// store, or a provider with entries. The toggle accepts exactly these (an unknown id still 404s).
|
||||
fn is_known_source(id: &str) -> bool {
|
||||
scanner_defs().iter().any(|(sid, _)| *sid == id) || list_scanners().iter().any(|s| s.id == id)
|
||||
}
|
||||
|
||||
/// Enable/disable one source. `None` when `id` names no source on this host (the mgmt layer maps
|
||||
/// that to 404 — the console only ever sees this host's own list). Persists and emits
|
||||
/// `library.changed` (source = the id) only when the state actually changed, so a repeated PUT is a
|
||||
/// cheap no-op.
|
||||
///
|
||||
/// The **same** `library-scanners.json` disabled-set backs built-in and plugin sources alike, and
|
||||
/// the ids match by construction — so a user who disabled `steam` before the migration still has it
|
||||
/// disabled after the steam plugin claims the store, with nothing to carry over.
|
||||
pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result<Option<Vec<ScannerInfo>>> {
|
||||
if !scanner_defs().iter().any(|(sid, _)| *sid == id) {
|
||||
if !is_known_source(id) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut settings = load_settings();
|
||||
|
||||
@@ -29,6 +29,7 @@ impl LibraryProvider for SteamProvider {
|
||||
.filter(|app| !is_steam_tool(app.appid, &app.name))
|
||||
.map(|app| GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", app.appid),
|
||||
store: "steam".into(),
|
||||
@@ -383,6 +384,7 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
|
||||
}
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", sc.appid),
|
||||
store: "steam".into(),
|
||||
@@ -426,12 +428,8 @@ fn shortcuts_files() -> Vec<PathBuf> {
|
||||
files
|
||||
}
|
||||
|
||||
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
|
||||
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
|
||||
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
|
||||
fn shortcut_gameid(appid: u32) -> u64 {
|
||||
((appid as u64) << 32) | 0x0200_0000
|
||||
}
|
||||
// `shortcut_gameid` (the 64-bit `rungameid` composition) moved to `launch.rs` (WP1.1) — it is launch
|
||||
// vocabulary; this module only reads the 32-bit appid out of `shortcuts.vdf`.
|
||||
|
||||
/// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
|
||||
/// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
|
||||
@@ -762,12 +760,7 @@ mod tests {
|
||||
assert!(launch.value.bytes().all(|b| b.is_ascii_digit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_gameid_composes_appid_and_marker() {
|
||||
let id = shortcut_gameid(0x8000_0000);
|
||||
assert_eq!(id >> 32, 0x8000_0000); // high dword is the appid
|
||||
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000); // low dword is the shortcut marker
|
||||
}
|
||||
// `shortcut_gameid_composes_appid_and_marker` moved with the function to `launch.rs` (WP1.1).
|
||||
|
||||
#[test]
|
||||
fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() {
|
||||
|
||||
@@ -70,6 +70,7 @@ fn xbox_games() -> Vec<GameEntry> {
|
||||
let art = cached_art(&id).unwrap_or_default();
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "xbox".into(),
|
||||
|
||||
@@ -185,6 +185,11 @@ pub(crate) async fn update_custom_game(
|
||||
StatusCode::CONFLICT,
|
||||
&format!("entry is owned by provider `{p}` — update it through its reconcile"),
|
||||
),
|
||||
// Store claims are a reconcile-only concern — the manual CRUD never requests one.
|
||||
Ok(MutateOutcome::StoreClaimed { .. }) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"unexpected claim outcome",
|
||||
),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -216,6 +221,11 @@ pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
|
||||
"entry is owned by provider `{p}` — remove it there, or DELETE the provider set"
|
||||
),
|
||||
),
|
||||
// Store claims are a reconcile-only concern — the manual CRUD never requests one.
|
||||
Ok(MutateOutcome::StoreClaimed { .. }) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"unexpected claim outcome",
|
||||
),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -227,6 +237,13 @@ pub(crate) struct ProviderRemoved {
|
||||
removed: usize,
|
||||
}
|
||||
|
||||
/// Query for `reconcileProviderEntries` — the optional store claim (D2).
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ReconcileQuery {
|
||||
/// Claim this store for the provider, so its entries take the store's own identity.
|
||||
store: Option<String>,
|
||||
}
|
||||
|
||||
/// Replace a provider's library entries (declarative reconcile)
|
||||
///
|
||||
/// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
|
||||
@@ -234,39 +251,67 @@ pub(crate) struct ProviderRemoved {
|
||||
/// surviving title's host id stable across reconciles, drops orphans, and never touches manual
|
||||
/// entries or other providers'. An empty array removes everything the provider owns. Emits
|
||||
/// `library.changed` with the provider as `source`.
|
||||
///
|
||||
/// `?store=` additionally **claims** that store for the provider: its entries then surface with
|
||||
/// deterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque
|
||||
/// `custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner
|
||||
/// used to produce, right down to the GameStream app ids and client-side art caches. One provider
|
||||
/// per store; a second claimant gets 409. While a claim is held the matching built-in scanner is
|
||||
/// suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty
|
||||
/// reconcile (a store can legitimately have zero installed titles).
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/library/provider/{provider}",
|
||||
tag = "library",
|
||||
operation_id = "reconcileProviderEntries",
|
||||
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
|
||||
params(
|
||||
("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)"),
|
||||
("store" = Option<String>, Query, description = "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)"),
|
||||
),
|
||||
request_body = Vec<crate::library::ProviderEntryInput>,
|
||||
responses(
|
||||
(status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]),
|
||||
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
|
||||
(status = BAD_REQUEST, description = "Invalid provider id, store id, or payload", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = CONFLICT, description = "That store is already claimed by another provider", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn reconcile_provider_entries(
|
||||
Path(provider): Path<String>,
|
||||
Query(q): Query<ReconcileQuery>,
|
||||
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
|
||||
) -> Response {
|
||||
if let Err(e) = crate::library::validate_provider_name(&provider) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||
}
|
||||
let store = q.store.filter(|s| !s.is_empty());
|
||||
if let Some(store) = &store {
|
||||
if let Err(e) = crate::library::validate_store_claim(store) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||
}
|
||||
}
|
||||
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||
}
|
||||
match crate::library::reconcile_provider(&provider, inputs) {
|
||||
Ok(entries) => {
|
||||
match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) {
|
||||
Ok(crate::library::MutateOutcome::Done(entries)) => {
|
||||
tracing::info!(
|
||||
provider,
|
||||
store = store.as_deref().unwrap_or("-"),
|
||||
count = entries.len(),
|
||||
"library provider reconciled"
|
||||
);
|
||||
Json(entries).into_response()
|
||||
}
|
||||
Ok(crate::library::MutateOutcome::StoreClaimed { store, provider }) => api_error(
|
||||
StatusCode::CONFLICT,
|
||||
&format!("store `{store}` is already claimed by provider `{provider}`"),
|
||||
),
|
||||
Ok(_) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"unexpected reconcile outcome",
|
||||
),
|
||||
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -306,11 +351,12 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
|
||||
/// Fetch one cover-art image for a library entry
|
||||
///
|
||||
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
||||
/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
|
||||
/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
|
||||
/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
|
||||
/// can't predict, in which case this 404s and the client falls through to its next art candidate).
|
||||
/// Only Steam ids are backed today; any other store 404s.
|
||||
/// the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,
|
||||
/// and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back
|
||||
/// to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the
|
||||
/// user's Steam client already shows for it), the public Steam CDN's flat URL convention second
|
||||
/// (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case
|
||||
/// this 404s and the client falls through to its next art candidate).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/library/art/{id}/{kind}",
|
||||
@@ -330,7 +376,20 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
|
||||
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
|
||||
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
|
||||
};
|
||||
// Steam: CDN / local-cache proxy (id `steam:<appid>`).
|
||||
// `library.json` FIRST, for ANY id (WP1.2). Stored entries — manual, provider-synced, and (once
|
||||
// store claims land) a scanner plugin's `steam:570` — all serve their local art file from here,
|
||||
// so the proxy never has to know which store an id belongs to. Steam ids aren't stored today, so
|
||||
// this misses and the legacy branch below still answers them.
|
||||
let stored = {
|
||||
let id = id.clone();
|
||||
tokio::task::spawn_blocking(move || crate::library::library_local_art_bytes(&id, kind))
|
||||
.await
|
||||
};
|
||||
if let Ok(Some((bytes, ctype))) = stored {
|
||||
return ([(header::CONTENT_TYPE, ctype)], bytes).into_response();
|
||||
}
|
||||
// Legacy in-host Steam scanner: local Steam cache, then the flat CDN URL. Retired with the
|
||||
// scanner itself once the steam plugin claims the store (M6).
|
||||
if let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
@@ -344,17 +403,5 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
};
|
||||
}
|
||||
// Custom/provider entry (id `custom:<id>`): serve its stored LOCAL art file — e.g. the Playnite
|
||||
// plugin's covers, reconciled as on-host paths rather than inlined bytes.
|
||||
if let Some(cid) = id.strip_prefix("custom:").map(str::to_owned) {
|
||||
return match tokio::task::spawn_blocking(move || {
|
||||
crate::library::custom_local_art_bytes(&cid, kind)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
};
|
||||
}
|
||||
api_error(StatusCode::NOT_FOUND, "no art proxy for this store")
|
||||
api_error(StatusCode::NOT_FOUND, "no art of that kind for this title")
|
||||
}
|
||||
|
||||
@@ -64,6 +64,14 @@ pub(crate) struct PluginRegistration {
|
||||
/// entry only (e.g. a future runner-management listing) and grows no nav entry.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ui: Option<PluginUi>,
|
||||
/// What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`
|
||||
/// because it describes the plugin, not its surface. The console knows one value today —
|
||||
/// `library` — which it filters **out of the nav**: six installed scanner plugins would otherwise
|
||||
/// flood the sidebar, and their real entry point is the Game sources surface (design D5). A
|
||||
/// library plugin that genuinely wants its own page (rom-manager, which is much more than a
|
||||
/// scanner) simply omits the category.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).
|
||||
@@ -104,6 +112,9 @@ pub(crate) struct PluginSummary {
|
||||
pub version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui: Option<PluginUiPublic>,
|
||||
/// The plugin's kind — see [`PluginRegistration::category`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).
|
||||
@@ -129,14 +140,19 @@ struct Stored {
|
||||
title: String,
|
||||
version: Option<String>,
|
||||
ui: Option<StoredUi>,
|
||||
category: Option<String>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
impl Stored {
|
||||
/// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves
|
||||
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does.
|
||||
fn public_eq(&self, title: &str, version: &Option<String>, ui: &Option<StoredUi>) -> bool {
|
||||
self.title == title && self.version == *version && self.ui == *ui
|
||||
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon/
|
||||
/// category) does.
|
||||
fn public_eq(&self, v: &Valid) -> bool {
|
||||
self.title == v.title
|
||||
&& self.version == v.version
|
||||
&& self.ui == v.ui
|
||||
&& self.category == v.category
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +166,7 @@ struct Valid {
|
||||
title: String,
|
||||
version: Option<String>,
|
||||
ui: Option<StoredUi>,
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
@@ -167,7 +184,7 @@ impl PluginRegistry {
|
||||
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
|
||||
let changed = match map.get(id) {
|
||||
// An *expired* prior entry counts as a change (it had stopped listing).
|
||||
Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui),
|
||||
Some(prev) => !prev.is_live() || !prev.public_eq(&v),
|
||||
None => true,
|
||||
};
|
||||
map.insert(
|
||||
@@ -176,6 +193,7 @@ impl PluginRegistry {
|
||||
title: v.title,
|
||||
version: v.version,
|
||||
ui: v.ui,
|
||||
category: v.category,
|
||||
expires_at,
|
||||
},
|
||||
);
|
||||
@@ -207,6 +225,7 @@ impl PluginRegistry {
|
||||
port: u.port,
|
||||
icon: u.icon.clone(),
|
||||
}),
|
||||
category: s.category.clone(),
|
||||
})
|
||||
.collect();
|
||||
live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)));
|
||||
@@ -333,7 +352,31 @@ fn validate(reg: PluginRegistration) -> Result<Valid, String> {
|
||||
Some(u) => Some(validate_ui(u)?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Valid { title, version, ui })
|
||||
// Categories are grouping keys the console switches on — a closed charset, but deliberately not
|
||||
// a closed VOCABULARY: an unknown category is stored and simply matches no console rule, so a
|
||||
// newer plugin registering against an older host degrades to "shows in the nav", never to a
|
||||
// failed registration.
|
||||
let category = match reg.category {
|
||||
Some(c) => {
|
||||
let ok = (1..=32).contains(&c.len())
|
||||
&& c.starts_with(|ch: char| ch.is_ascii_lowercase())
|
||||
&& c.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
|
||||
if !ok {
|
||||
return Err(
|
||||
"category must be 1–32 chars of [a-z0-9-], starting with a letter".into(),
|
||||
);
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(Valid {
|
||||
title,
|
||||
version,
|
||||
ui,
|
||||
category,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_ui(u: PluginUi) -> Result<StoredUi, String> {
|
||||
@@ -558,6 +601,7 @@ mod tests {
|
||||
secret: secret.into(),
|
||||
icon: Some("gamepad-2".into()),
|
||||
}),
|
||||
category: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,10 +628,30 @@ mod tests {
|
||||
title: "Ro\u{7}m\n".into(),
|
||||
version: None,
|
||||
ui: None,
|
||||
category: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v.title, "Rom");
|
||||
// privileged port rejected
|
||||
// Category charset (WP2.7): the console's one known value passes; the shapes that would
|
||||
// break a grouping key don't. An UNKNOWN-but-well-formed category is accepted on purpose —
|
||||
// a newer plugin must not fail to register against an older host.
|
||||
let lib = |c: &str| PluginRegistration {
|
||||
title: "X".into(),
|
||||
version: None,
|
||||
ui: None,
|
||||
category: Some(c.into()),
|
||||
};
|
||||
assert_eq!(
|
||||
validate(lib("library")).unwrap().category.as_deref(),
|
||||
Some("library")
|
||||
);
|
||||
assert!(validate(lib("some-future-kind")).is_ok());
|
||||
assert!(validate(lib("")).is_err());
|
||||
assert!(validate(lib("Library")).is_err()); // no uppercase
|
||||
assert!(validate(lib("9lives")).is_err()); // must start with a letter
|
||||
assert!(validate(lib("lib_rary")).is_err()); // no underscore
|
||||
assert!(validate(lib(&"a".repeat(33))).is_err()); // too long
|
||||
// privileged port rejected
|
||||
assert!(validate(reg("x", 80, SECRET)).is_err());
|
||||
// short secret rejected
|
||||
assert!(validate(reg("x", 49321, "tooshort")).is_err());
|
||||
@@ -641,6 +705,7 @@ mod tests {
|
||||
title: "Headless".into(),
|
||||
version: None,
|
||||
ui: None,
|
||||
category: None,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
@@ -108,6 +108,14 @@ pub(crate) struct CatalogEntry {
|
||||
/// A revocation covering the catalogued version — do not offer this without shouting.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blocked: Option<String>,
|
||||
/// What kind of plugin this is — the console filters Browse by these, and the Game sources
|
||||
/// surface's "Add a source" rail shows exactly the `library` ones (design D5/D6).
|
||||
pub categories: Vec<String>,
|
||||
/// Whether the launcher this plugin scans looks **installed on this host** (design D8), from the
|
||||
/// index's own existence probes. `null` = the entry declares no probes for this platform, which
|
||||
/// the console renders as "unknown" rather than "not installed".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detected: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -277,6 +285,8 @@ fn build_catalog(force: bool) -> CatalogResponse {
|
||||
update_available: installed_version.as_deref().is_some_and(|v| v != e.version),
|
||||
installed_version,
|
||||
blocked: store::advisory_for(&e.pkg, Some(&e.version)).map(|a| a.reason),
|
||||
categories: e.categories.clone(),
|
||||
detected: e.detected(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,26 +441,27 @@ fn idd_adaptive_enabled() -> bool {
|
||||
/// Seal one access unit and send it with MICROBURST pacing (the shared
|
||||
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
|
||||
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
|
||||
/// only the OVERFLOW beyond that is spread across `min(~90% of the time to deadline, the time
|
||||
/// the overflow needs at pace_rate_bps)` in ADAPTIVE chunks — 16 packets at today's rates,
|
||||
/// coarsening to at most 64 (the GSO-segment cap) once the rate would otherwise skip every
|
||||
/// sub-floor sleep, so ≥1 Gbps frames still pace instead of collapsing into an unpaced blast
|
||||
/// (plan Phase 1.2). `burst_cap` `None` = auto: `max(128 KB, this AU's wire bytes / 4)`, so
|
||||
/// the burst stays a bounded fraction of a high-rate frame instead of swallowing it whole
|
||||
/// (plan Phase 1.3); `Some` = PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a
|
||||
/// normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added latency, while a
|
||||
/// genuine IDR / sustained-high-bitrate frame (≫ cap) still spreads — keeping the freeze fix
|
||||
/// exactly where it's needed (an unpaced line-rate burst overruns the kernel tx buffer →
|
||||
/// EAGAIN drop → under infinite GOP, a freeze until the next keyframe). With no slack
|
||||
/// (encode ≈ interval) the budget collapses to 0 and even the overflow goes out immediately,
|
||||
/// so this is never slower than unpaced.
|
||||
/// only the OVERFLOW beyond that is spread across the time it needs at `pace_rate_bps` in
|
||||
/// ADAPTIVE chunks — 16 packets at today's rates, coarsening to at most 64 (the GSO-segment
|
||||
/// cap) once the rate would otherwise skip every sub-floor sleep, so ≥1 Gbps frames still pace
|
||||
/// instead of collapsing into an unpaced blast (plan Phase 1.2). `burst_cap` `None` = auto:
|
||||
/// `max(128 KB, this AU's wire bytes / 4)`, so the burst stays a bounded fraction of a
|
||||
/// high-rate frame instead of swallowing it whole (plan Phase 1.3); `Some` =
|
||||
/// PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a normal-bitrate frame (≤ cap) leaves in
|
||||
/// one immediate burst at ~0 added latency, while a genuine IDR / sustained-high-bitrate frame
|
||||
/// (≫ cap) still spreads — keeping the freeze fix exactly where it's needed (an unpaced
|
||||
/// line-rate burst overruns the kernel tx buffer → EAGAIN drop → under infinite GOP, a freeze
|
||||
/// until the next keyframe).
|
||||
///
|
||||
/// `pace_rate_bps` (latency plan T1.2) bounds the spread from above: the deadline term alone
|
||||
/// smears a big frame's tail across the whole remaining interval (~15 ms at 60 fps) even when
|
||||
/// the link could drain it in 2–3 ms. The caller passes ~3× the live encoder bitrate — a rate
|
||||
/// the link is proven to carry sustained, so the bounded excursion keeps the anti-freeze
|
||||
/// property while the tail leaves as soon as the link plausibly allows. `0` = uncapped
|
||||
/// (legacy smoothness-only spread, and the fallback when the bitrate isn't known yet).
|
||||
/// `pace_rate_bps` (latency plan T1.2; resume-safe form, stall program T2): the caller passes
|
||||
/// ~3× the live encoder bitrate — a rate the link is proven to carry sustained — and the
|
||||
/// overflow's wire time at that rate IS the pace budget ([`crate::send_pacing::native_budget`],
|
||||
/// [`crate::send_pacing::MAX_PACE_SPREAD`]-bounded). The frame deadline no longer under-cuts
|
||||
/// the spread: for a steady-state frame the rate term was the smaller one anyway (tail gone in
|
||||
/// a fraction of the interval), and for an oversized frame (stall-resume scene delta, cold
|
||||
/// IDR) the old deadline clamp was exactly the line-rate blast → tx-overrun → freeze path this
|
||||
/// module exists to prevent. `0` = uncapped legacy deadline-only spread
|
||||
/// (PUNKTFUNK_PACE_FACTOR=0, and the fallback when the bitrate isn't known yet).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paced_submit(
|
||||
session: &mut Session,
|
||||
@@ -498,34 +499,22 @@ fn pace_sealed(
|
||||
chunk: crate::send_pacing::ChunkPolicy::Adaptive { base: 16, max: 64 },
|
||||
sleep_floor: std::time::Duration::from_micros(500),
|
||||
};
|
||||
// T1.2 rate cap: the overflow's wire time at `pace_rate_bps`. Only the bytes past the
|
||||
// burst pace at all, so only they bound the budget.
|
||||
// T1.2 rate cap, resume-safe form (stall program T2): the overflow's wire time at
|
||||
// `pace_rate_bps` IS the budget — the deadline no longer under-cuts it, so an oversized
|
||||
// frame (a stall-resume scene delta, a cold IDR) paces at the proven 3× rate instead of
|
||||
// collapsing into a line-rate blast that overruns the socket buffer and loses the very
|
||||
// frame that ends a freeze. See `send_pacing::native_budget` for the full argument.
|
||||
let overflow_bytes = wire_bytes.saturating_sub(burst_bytes) as u64;
|
||||
let cap = if pace_rate_bps > 0 && overflow_bytes > 0 {
|
||||
std::time::Duration::from_nanos(
|
||||
(overflow_bytes * 8).saturating_mul(1_000_000_000) / pace_rate_bps,
|
||||
)
|
||||
} else {
|
||||
std::time::Duration::MAX
|
||||
};
|
||||
let budget = crate::send_pacing::native_budget(deadline, pace_rate_bps, overflow_bytes);
|
||||
// Time the socket handoff per chunk and fold it into the session's SealPerf split — the
|
||||
// sleeps between chunks stay excluded, so sock_ns is pure send_gso/sendmmsg time.
|
||||
let mut sock_ns = 0u64;
|
||||
let result = crate::send_pacing::pace_frame(
|
||||
&refs,
|
||||
crate::send_pacing::PaceBudget::UntilDeadline {
|
||||
deadline,
|
||||
fraction: 0.9,
|
||||
cap,
|
||||
},
|
||||
&cfg,
|
||||
|chunk| {
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = session.send_sealed(chunk).map(|_| ());
|
||||
sock_ns += t0.elapsed().as_nanos() as u64;
|
||||
r
|
||||
},
|
||||
);
|
||||
let result = crate::send_pacing::pace_frame(&refs, budget, &cfg, |chunk| {
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = session.send_sealed(chunk).map(|_| ());
|
||||
sock_ns += t0.elapsed().as_nanos() as u64;
|
||||
r
|
||||
});
|
||||
drop(refs); // release the borrow of `wires` so it can return to the seal pool
|
||||
session.reclaim_wires(wires);
|
||||
session.note_sock_ns(sock_ns);
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(crate) enum ChunkPolicy {
|
||||
}
|
||||
|
||||
/// The time the paced (post-burst) packets spread across.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum PaceBudget {
|
||||
/// `min((deadline − now-after-burst) × fraction, cap)`, collapsing to 0 with no slack
|
||||
/// (native: fraction 0.9). `cap` bounds the spread to the time the overflow actually needs
|
||||
@@ -68,10 +68,53 @@ pub(crate) enum PaceBudget {
|
||||
fraction: f32,
|
||||
cap: Duration,
|
||||
},
|
||||
/// A precomputed fixed budget (GameStream: ¾ of the frame interval).
|
||||
/// A precomputed fixed budget (GameStream: ¾ of the frame interval; native: the rate-cap
|
||||
/// spread from [`native_budget`]).
|
||||
Fixed(Duration),
|
||||
}
|
||||
|
||||
/// Absolute ceiling on one frame's paced spread (native plane): a pathological frame must not
|
||||
/// park the send thread for longer than this, whatever the rate math says. At the ceiling the
|
||||
/// tail is late but delivered whole — still strictly better than the blast-loss → freeze →
|
||||
/// recovery-IDR round trip it replaces.
|
||||
pub(crate) const MAX_PACE_SPREAD: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The native plane's pace budget for one frame (pure — unit-tested): with the T1.2 rate cap
|
||||
/// active, the paced overflow spreads across exactly the time it needs at the pace rate
|
||||
/// (`cap`, bounded by [`MAX_PACE_SPREAD`]) and is NEVER under-cut by the frame deadline.
|
||||
///
|
||||
/// The old schedule took `min(0.9 × time-to-deadline, cap)`. For a steady-state frame the cap
|
||||
/// is the smaller term and nothing changes. But for an OVERSIZED frame — a stall-resume scene
|
||||
/// delta after seconds of frozen composition, a cold IDR — the overflow needs SEVERAL frame
|
||||
/// intervals at the pace rate, and the deadline term clamped that into the remainder of ONE:
|
||||
/// an instantaneous many-×-stream-rate blast that overruns the socket tx-buffer and loses the
|
||||
/// very frame that would have ended the freeze (field fingerprint: WSAENOBUFS 10055 +
|
||||
/// `loss_ppm` spikes at capture-stall edges, then a recovery-IDR round trip per retry). The
|
||||
/// pace rate is ~3× a rate the link demonstrably carries, so holding it past the deadline is
|
||||
/// safe by the same argument that introduced the cap — the deadline stays a *target*, not a
|
||||
/// license to blast.
|
||||
///
|
||||
/// `pace_rate_bps == 0` (PUNKTFUNK_PACE_FACTOR=0) or an overflow-free frame keeps the legacy
|
||||
/// deadline-only spread.
|
||||
pub(crate) fn native_budget(
|
||||
deadline: Instant,
|
||||
pace_rate_bps: u64,
|
||||
overflow_bytes: u64,
|
||||
) -> PaceBudget {
|
||||
if pace_rate_bps > 0 && overflow_bytes > 0 {
|
||||
let cap = Duration::from_nanos(
|
||||
(overflow_bytes * 8).saturating_mul(1_000_000_000) / pace_rate_bps,
|
||||
);
|
||||
PaceBudget::Fixed(cap.min(MAX_PACE_SPREAD))
|
||||
} else {
|
||||
PaceBudget::UntilDeadline {
|
||||
deadline,
|
||||
fraction: 0.9,
|
||||
cap: Duration::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-plane pacing parameters. See the module doc for the two canonical values.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct PaceCfg {
|
||||
@@ -598,6 +641,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// [`native_budget`]: with the rate cap active the budget is the overflow's wire time at
|
||||
/// the pace rate — a FIXED spread the deadline can no longer under-cut — bounded by
|
||||
/// [`MAX_PACE_SPREAD`]; rate 0 / no overflow keep the legacy deadline-only schedule.
|
||||
#[test]
|
||||
fn native_budget_is_rate_bound_never_deadline_cut() {
|
||||
// The stall-resume case the fix exists for: a 3 MB overflow at 3×240 Mbps needs
|
||||
// ~33 ms — an IMMINENT deadline (the old min() made this a blast) must not shrink it.
|
||||
let deadline = Instant::now() + Duration::from_millis(4); // 240 fps interval
|
||||
let b = native_budget(deadline, 720_000_000, 3_000_000);
|
||||
assert_eq!(b, PaceBudget::Fixed(Duration::from_nanos(33_333_333)));
|
||||
|
||||
// A steady-state frame: overflow 90 KB at 3×240 Mbps = 1 ms — identical to what the
|
||||
// old min(slack, cap) chose (cap was the smaller term), so nothing regresses.
|
||||
let b = native_budget(deadline, 720_000_000, 90_000);
|
||||
assert_eq!(b, PaceBudget::Fixed(Duration::from_micros(1_000)));
|
||||
|
||||
// A crater-rate resume (ABR backed off to 20 Mbps, pace 60 Mbps): the raw rate math
|
||||
// says 400 ms for 3 MB — the absolute ceiling bounds the send thread's stall.
|
||||
let b = native_budget(deadline, 60_000_000, 3_000_000);
|
||||
assert_eq!(b, PaceBudget::Fixed(MAX_PACE_SPREAD));
|
||||
|
||||
// Rate cap off (PUNKTFUNK_PACE_FACTOR=0): the legacy deadline-only spread, uncapped.
|
||||
let b = native_budget(deadline, 0, 3_000_000);
|
||||
assert!(matches!(
|
||||
b,
|
||||
PaceBudget::UntilDeadline {
|
||||
fraction,
|
||||
cap: Duration::MAX,
|
||||
..
|
||||
} if fraction == 0.9
|
||||
));
|
||||
|
||||
// No overflow (the whole frame bursts): budget is never consulted — legacy shape.
|
||||
let b = native_budget(deadline, 720_000_000, 0);
|
||||
assert!(matches!(b, PaceBudget::UntilDeadline { .. }));
|
||||
}
|
||||
|
||||
/// `inject_video_drop` is a no-op when the knob is off (the default test env).
|
||||
#[test]
|
||||
fn drop_injection_off_by_default() {
|
||||
|
||||
@@ -97,6 +97,31 @@ pub(crate) struct Entry {
|
||||
/// Host platforms this plugin works on (`linux`/`windows`/`macos`). Empty ⇒ all.
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
/// What kinds of plugin this is (`[a-z][a-z0-9-]{0,31}`, ≤4). The console filters Browse by
|
||||
/// these, and the Game sources surface's "Add a source" rail lists exactly the entries carrying
|
||||
/// `library` (design D5/D6). Additive: an older host ignores the field, a newer one just sees no
|
||||
/// categories on an older index.
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
/// Optional per-platform "is this launcher installed here?" probes (design D8).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detect: Option<DetectProbes>,
|
||||
}
|
||||
|
||||
/// Existence probes that let the console badge a catalog row "detected on this host" **without the
|
||||
/// host re-growing per-store knowledge** — the whole point of extracting the scanners. Store
|
||||
/// knowledge lives in the updatable, signed index; the host stays generic and only evaluates.
|
||||
///
|
||||
/// Deliberately anaemic: a probe is a path or an `HKLM\…` registry key, checked for EXISTENCE only.
|
||||
/// No reads, no content matching, no globbing beyond a single `*` segment. The index is
|
||||
/// operator-trusted but remotely updatable, so a probe must never be able to exfiltrate anything or
|
||||
/// cost more than a stat.
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub(crate) struct DetectProbes {
|
||||
#[serde(default)]
|
||||
pub linux: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub windows: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -228,9 +253,40 @@ impl Entry {
|
||||
self.platforms
|
||||
.retain(|p| matches!(p.as_str(), "linux" | "windows" | "macos"));
|
||||
self.platforms.truncate(4);
|
||||
// Categories and probes are cosmetic/advisory: a malformed one is dropped, never fatal to
|
||||
// the entry — a plugin must stay installable even if a future index writes a category this
|
||||
// host build has never heard of.
|
||||
self.categories.retain(|c| valid_category(c));
|
||||
self.categories.truncate(4);
|
||||
if let Some(d) = &mut self.detect {
|
||||
d.linux.retain(|p| valid_probe(p));
|
||||
d.windows.retain(|p| valid_probe(p));
|
||||
d.linux.truncate(MAX_PROBES);
|
||||
d.windows.truncate(MAX_PROBES);
|
||||
if d.linux.is_empty() && d.windows.is_empty() {
|
||||
self.detect = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does this entry's platform probe match on the running host? `None` = the entry declares no
|
||||
/// probes for this platform, i.e. "unknown", which the console renders differently from "no".
|
||||
pub(crate) fn detected(&self) -> Option<bool> {
|
||||
let probes = self.detect.as_ref()?;
|
||||
let list = if cfg!(windows) {
|
||||
&probes.windows
|
||||
} else if cfg!(target_os = "linux") {
|
||||
&probes.linux
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if list.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(list.iter().any(|p| probe_matches(p)))
|
||||
}
|
||||
|
||||
/// Is this entry installable on the running host? Returns the operator-facing reason when not.
|
||||
pub(crate) fn incompatible_reason(&self) -> Option<String> {
|
||||
if !self.platforms.is_empty() && !self.platforms.iter().any(|p| p == HOST_PLATFORM) {
|
||||
@@ -372,6 +428,94 @@ fn is_https(url: &str) -> bool {
|
||||
url.starts_with("https://") && url.len() > "https://".len()
|
||||
}
|
||||
|
||||
/// A plugin category (design D5): same shape the registration API accepts, so a plugin's declared
|
||||
/// category and its catalog row can never disagree about spelling.
|
||||
fn valid_category(c: &str) -> bool {
|
||||
(1..=32).contains(&c.len())
|
||||
&& c.starts_with(|ch: char| ch.is_ascii_lowercase())
|
||||
&& c.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
}
|
||||
|
||||
/// How many probes one platform may declare — a handful of well-chosen paths covers any launcher,
|
||||
/// and the cap bounds the stat cost of rendering the catalog.
|
||||
const MAX_PROBES: usize = 8;
|
||||
|
||||
/// Is this a probe the host will evaluate? An **absolute** filesystem path with at most one `*`
|
||||
/// segment, or an `HKLM\…` registry key. Everything else is dropped.
|
||||
///
|
||||
/// The restrictions are the security model (D8). Absolute: a relative path would resolve against
|
||||
/// whatever the host's cwd happens to be. One `*` segment: bounded fan-out, so a probe can't walk a
|
||||
/// tree. `HKLM` only: `HKCU` is unreadable as LocalService anyway, and pointing the host at an
|
||||
/// arbitrary hive is not something a remote index should be able to ask for.
|
||||
fn valid_probe(p: &str) -> bool {
|
||||
if p.is_empty() || p.len() > 260 {
|
||||
return false;
|
||||
}
|
||||
if let Some(key) = p.strip_prefix("HKLM\\") {
|
||||
return !key.is_empty()
|
||||
&& !key.contains("..")
|
||||
&& key.bytes().all(|b| {
|
||||
b.is_ascii_alphanumeric() || matches!(b, b'\\' | b' ' | b'-' | b'_' | b'.')
|
||||
});
|
||||
}
|
||||
let b = p.as_bytes();
|
||||
let absolute = p.starts_with('/') || (b.len() >= 3 && b[1] == b':' && b[2] == b'\\');
|
||||
// No traversal, and at most ONE wildcard segment (`~` is not expanded — the host runs as a
|
||||
// service account whose home means nothing to a user's launcher install).
|
||||
absolute && !p.contains("..") && p.matches('*').count() <= 1
|
||||
}
|
||||
|
||||
/// Evaluate one probe: does the path (or registry key) exist? Existence only — never a read.
|
||||
fn probe_matches(p: &str) -> bool {
|
||||
#[cfg(windows)]
|
||||
if let Some(key) = p.strip_prefix("HKLM\\") {
|
||||
use std::os::windows::process::CommandExt;
|
||||
// `reg.exe query` rather than a registry crate: dependency-free, and it is exactly what a
|
||||
// library plugin will use for the same job under LocalService.
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
return std::process::Command::new("reg.exe")
|
||||
.args(["query", &format!("HKLM\\{key}")])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
if p.starts_with("HKLM\\") {
|
||||
return false; // a Windows probe on a POSIX host is simply not a match
|
||||
}
|
||||
match p.split_once('*') {
|
||||
None => std::path::Path::new(p).exists(),
|
||||
// One wildcard: list the parent of the wildcard segment and match the fixed prefix/suffix
|
||||
// around it. Bounded to a single directory read.
|
||||
Some((before, after)) => {
|
||||
let (dir, prefix) = match before.rfind(['/', '\\']) {
|
||||
Some(i) => (&before[..=i], &before[i + 1..]),
|
||||
None => return false, // a wildcard with no directory to anchor it
|
||||
};
|
||||
let (suffix, rest) = match after.find(['/', '\\']) {
|
||||
Some(i) => (&after[..i], &after[i..]),
|
||||
None => (after, ""),
|
||||
};
|
||||
let Ok(read) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
read.flatten().any(|e| {
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
name.starts_with(prefix)
|
||||
&& name.ends_with(suffix)
|
||||
&& name.len() >= prefix.len() + suffix.len()
|
||||
&& (rest.is_empty()
|
||||
|| e.path().join(rest.trim_start_matches(['/', '\\'])).exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -401,6 +545,73 @@ mod tests {
|
||||
assert!(Index::parse(b"not json").is_err());
|
||||
}
|
||||
|
||||
/// WP2.8 is additive on purpose — SCHEMA stays 1. An index written by a newer curator must load
|
||||
/// on an older host (unknown fields ignored) and vice versa (absent fields default), or the
|
||||
/// signed-index rollout would need a flag day.
|
||||
#[test]
|
||||
fn categories_and_probes_are_additive_and_sanitized() {
|
||||
// An entry with NEITHER field — every index in the wild today.
|
||||
let e = &Index::parse(&doc(GOOD)).unwrap().plugins[0];
|
||||
assert!(e.categories.is_empty());
|
||||
assert!(e.detect.is_none());
|
||||
assert_eq!(e.detected(), None, "no probes ⇒ unknown, not `false`");
|
||||
|
||||
// With both, including rows that must be dropped rather than fail the entry.
|
||||
let rich = GOOD.trim_end_matches('}').to_string()
|
||||
+ r#","categories":["library","Bad Cat","x","y","z","w"],
|
||||
"detect":{"linux":["/usr/bin/steam","relative/path","/etc/../etc/passwd"],
|
||||
"windows":["HKLM\\SOFTWARE\\Valve\\Steam","HKCU\\SOFTWARE\\Valve"]}}"#;
|
||||
let e = &Index::parse(&doc(&rich)).unwrap().plugins[0];
|
||||
assert_eq!(
|
||||
e.categories,
|
||||
["library", "x", "y", "z"],
|
||||
"malformed dropped, capped at 4"
|
||||
);
|
||||
let d = e.detect.as_ref().expect("probes kept");
|
||||
assert_eq!(d.linux, ["/usr/bin/steam"], "relative + traversal dropped");
|
||||
assert_eq!(
|
||||
d.windows,
|
||||
["HKLM\\SOFTWARE\\Valve\\Steam"],
|
||||
"HKCU is not evaluable as LocalService — dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_shapes_are_bounded() {
|
||||
assert!(valid_probe("/usr/bin/steam"));
|
||||
assert!(
|
||||
valid_probe("/home/*/.steam"),
|
||||
"one wildcard segment is fine"
|
||||
);
|
||||
assert!(valid_probe(r"C:\Program Files (x86)\Steam\steam.exe"));
|
||||
assert!(valid_probe(r"HKLM\SOFTWARE\WOW6432Node\Valve\Steam"));
|
||||
// Rejected: relative, traversal, more than one wildcard, other hives, absurd length.
|
||||
assert!(!valid_probe("steam"));
|
||||
assert!(!valid_probe("/usr/../etc/passwd"));
|
||||
assert!(!valid_probe("/home/*/games/*/steam"));
|
||||
assert!(!valid_probe(r"HKCU\SOFTWARE\Valve"));
|
||||
assert!(!valid_probe(""));
|
||||
assert!(!valid_probe(&"/x".repeat(200)));
|
||||
}
|
||||
|
||||
/// The evaluator does existence checks only, against real paths, and never reads a byte.
|
||||
#[test]
|
||||
fn probes_evaluate_against_the_filesystem() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-probe-{}", std::process::id()));
|
||||
let nested = dir.join("SteamLibrary-42");
|
||||
std::fs::create_dir_all(nested.join("steamapps")).unwrap();
|
||||
let d = dir.to_string_lossy().into_owned();
|
||||
|
||||
assert!(probe_matches(&format!("{d}/SteamLibrary-42")));
|
||||
assert!(!probe_matches(&format!("{d}/nope")));
|
||||
// One wildcard segment, with and without a trailing fixed component.
|
||||
assert!(probe_matches(&format!("{d}/SteamLibrary-*")));
|
||||
assert!(probe_matches(&format!("{d}/SteamLibrary-*/steamapps")));
|
||||
assert!(!probe_matches(&format!("{d}/SteamLibrary-*/nope")));
|
||||
assert!(!probe_matches(&format!("{d}/Other-*")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_invalid_entries_but_keeps_the_rest() {
|
||||
let bad_unscoped = GOOD.replace("@punktfunk/plugin-rom-manager", "punktfunk-plugin-x");
|
||||
|
||||
@@ -108,10 +108,16 @@ the full path: `& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add p
|
||||
Open the [web console](/docs/web-console) and the plugin's page appears in the nav automatically —
|
||||
that's the whole install.
|
||||
|
||||
The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need
|
||||
`enable` once. The runner discovers plugins when it starts, so one installed later needs a restart
|
||||
to come up (`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting`
|
||||
task) — the console does that restart for you as part of installing.
|
||||
The runner is **on by default** on a new install — your game sources are plugins, so a host without
|
||||
it would show an empty library. (On a host that predates this, it stays however you left it; turn it
|
||||
on with `punktfunk-host plugins enable`, which you only need once.) The runner discovers plugins
|
||||
when it starts, so one installed later needs a restart to come up
|
||||
(`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting` task) — the
|
||||
console does that restart for you as part of installing.
|
||||
|
||||
Don't want it? It is a normal service you can switch off: `systemctl --user mask punktfunk-scripting`
|
||||
on Linux, or disable the `PunktfunkScripting` scheduled task on Windows. Your host keeps streaming;
|
||||
you just lose plugin-provided game sources and any automation.
|
||||
|
||||
A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows
|
||||
what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab
|
||||
@@ -301,8 +307,8 @@ host's, on one timeline, with the same search and download. Each is tagged `plug
|
||||
plugin's own name for lines it logged itself, `plugin:runner` for the supervisor's (starting a
|
||||
plugin, restarting a crashed one, refusing an unsafe file).
|
||||
|
||||
An empty Plugins view almost always means the runner isn't running — it is a separate service, and
|
||||
opt-in on Linux. Check with `punktfunk-host plugins status`.
|
||||
An empty Plugins view almost always means the runner isn't running — it is a separate service. Check
|
||||
with `punktfunk-host plugins status`.
|
||||
|
||||
<Callout>
|
||||
Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host
|
||||
|
||||
@@ -98,6 +98,23 @@ if [ -n "$GAMESCOPE" ]; then
|
||||
install -Dm0755 "$GAMESCOPE" "$STAGE/usr/bin/punktfunk-gamescope"
|
||||
fi
|
||||
|
||||
# Enable the plugin/script runner for every user, by baking its `[Install] WantedBy=default.target`
|
||||
# symlink straight into the image.
|
||||
#
|
||||
# A sysext carries only /usr, and RPM scriptlets never run from one — so the `systemctl --global
|
||||
# enable` the .rpm/.deb do at install time has no equivalent here, and without this the runner would
|
||||
# ship present-but-off on exactly the platform (Bazzite / Fedora Atomic) where an operator is least
|
||||
# likely to go hunting for it. The game-library scanners are plugins now (design D9), so an
|
||||
# unenabled runner means an empty library.
|
||||
#
|
||||
# Opt-out is unchanged and still wins: `systemctl --user mask punktfunk-scripting` in the user's own
|
||||
# ~/.config/systemd/user takes precedence over anything under /usr.
|
||||
if [ -f "$STAGE/usr/lib/systemd/user/punktfunk-scripting.service" ]; then
|
||||
install -d "$STAGE/usr/lib/systemd/user/default.target.wants"
|
||||
ln -sf ../punktfunk-scripting.service \
|
||||
"$STAGE/usr/lib/systemd/user/default.target.wants/punktfunk-scripting.service"
|
||||
fi
|
||||
|
||||
# Self-update: the helper rides inside the image.
|
||||
install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext"
|
||||
|
||||
|
||||
@@ -114,20 +114,36 @@ Description: punktfunk plugin/script runner (Effect SDK on bun)
|
||||
capped-jittered restart; SIGTERM shuts the whole tree down structurally so plugin finalizers run).
|
||||
Bundles its own bun runtime (no system nodejs/bun dependency).
|
||||
.
|
||||
OPT-IN: the systemd --user unit is installed but not auto-enabled (the runner is inert until you add
|
||||
scripts or plugins). A plugin auto-wires to the host's mgmt token + identity cert on the same box —
|
||||
no env editing. Enable it with: systemctl --user enable --now punktfunk-scripting
|
||||
ON BY DEFAULT: the systemd --user unit is enabled for every user (systemctl --global). The runner is
|
||||
inert until you add scripts or plugins, and the game-library scanners now ship AS plugins — so a
|
||||
host without the runner has an empty library and no obvious reason why. A plugin auto-wires to the
|
||||
host's mgmt token + identity cert on the same box — no env editing.
|
||||
Opt out per user with: systemctl --user mask punktfunk-scripting
|
||||
EOF
|
||||
|
||||
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ "$1" = "configure" ]; then
|
||||
echo "punktfunk-scripting installed. It runs your automation — add scripts to"
|
||||
# `--global`, not `--user`: a maintainer script has no user session to act on, and this is the
|
||||
# only mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into
|
||||
# /etc/systemd/user/…wants/). The library's scanners are plugins now, so the runner is a default
|
||||
# component rather than an add-on (design D9) — but installing it stays opt-OUT, and the opt-out
|
||||
# is `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a
|
||||
# global symlink.
|
||||
#
|
||||
# Only on FIRST configure ($2 empty): re-running it on every upgrade would silently undo the
|
||||
# mask of anyone who turned it off.
|
||||
if [ -z "$2" ] && command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
echo "punktfunk-scripting installed and enabled for all users."
|
||||
echo "It runs your automation — game-library sources, scripts in"
|
||||
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
|
||||
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
|
||||
echo "then enable the runner for your user:"
|
||||
echo " systemctl --user enable --now punktfunk-scripting"
|
||||
echo "and plugins under ~/.config/punktfunk/plugins/."
|
||||
echo "It starts with your next login; start it now with:"
|
||||
echo " systemctl --user start punktfunk-scripting"
|
||||
echo "Don't want it? systemctl --user mask punktfunk-scripting"
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
@@ -191,9 +191,10 @@ The plugin/script runner for a punktfunk streaming host: it discovers loose scri
|
||||
~/.config/punktfunk/scripts and installed punktfunk-plugin-* packages under ~/.config/punktfunk/
|
||||
plugins, and supervises each as an Effect fiber (capped-jittered restart; SIGTERM shuts the whole
|
||||
tree down structurally so plugin finalizers run). A plugin auto-wires to the host's mgmt token +
|
||||
identity cert on the same box — no env editing. Bundles its own bun runtime. OPT-IN: the systemd
|
||||
--user unit ships disabled (the runner is inert until you add scripts/plugins). Enable with
|
||||
`systemctl --user enable --now punktfunk-scripting`.
|
||||
identity cert on the same box — no env editing. Bundles its own bun runtime. ON BY DEFAULT: the
|
||||
systemd --user unit is enabled for every user (systemctl --global). The game-library scanners ship
|
||||
as plugins, so a host without the runner has an empty library. Opt out per user with
|
||||
`systemctl --user mask punktfunk-scripting`.
|
||||
%endif
|
||||
|
||||
%prep
|
||||
@@ -590,10 +591,22 @@ echo "Then open https://<host-ip>:47992"
|
||||
|
||||
%if %{with scripting}
|
||||
%post scripting
|
||||
echo "punktfunk-scripting installed. It runs your automation — add scripts to"
|
||||
# `--global`, not `--user`: a scriptlet has no user session to act on, and this is the only
|
||||
# mechanism that makes a `--user` unit on-by-default for everyone (it symlinks into
|
||||
# /etc/systemd/user/…wants/). The game-library scanners are plugins now, so the runner is a default
|
||||
# component rather than an add-on (design D9); it stays opt-OUT via
|
||||
# `systemctl --user mask punktfunk-scripting`, since a plain `--user disable` cannot remove a global
|
||||
# symlink. $1 == 1 is a first INSTALL — on an upgrade ($1 > 1) this must not undo an operator's mask.
|
||||
if [ "$1" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl --global enable punktfunk-scripting.service >/dev/null 2>&1 || :
|
||||
fi
|
||||
echo "punktfunk-scripting installed and enabled for all users."
|
||||
echo "It runs your automation — game-library sources, scripts in"
|
||||
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
|
||||
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
|
||||
echo "then enable the runner: systemctl --user enable --now punktfunk-scripting"
|
||||
echo "and plugins under ~/.config/punktfunk/plugins/."
|
||||
echo "It starts with your next login; start it now with:"
|
||||
echo " systemctl --user start punktfunk-scripting"
|
||||
echo "Don't want it? systemctl --user mask punktfunk-scripting"
|
||||
%endif
|
||||
|
||||
%changelog
|
||||
|
||||
@@ -329,9 +329,8 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
|
||||
; converges tasks an older installer registered as SYSTEM.
|
||||
; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces
|
||||
; in the command, so no Inno {{ }} escaping needed.
|
||||
Filename: "powershell.exe"; \
|
||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
|
||||
StatusMsg: "Registering the Punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
|
||||
Filename: "powershell.exe"; Parameters: "{code:ScriptingRegisterParams}"; \
|
||||
StatusMsg: "Registering the Punktfunk script runner..."; Flags: runhidden waituntilterminated
|
||||
#endif
|
||||
#if defined(WithWeb) || defined(WithScripting)
|
||||
; Put back what StopBunRuntimes disabled to unlock bun.exe. Deliberately the LAST [Run] entry that
|
||||
@@ -619,6 +618,12 @@ end;
|
||||
it disabled would switch it off for everyone who had it on. }
|
||||
var
|
||||
WebTaskWasEnabled, ScriptingTaskWasEnabled: Boolean;
|
||||
{ Did PunktfunkScripting exist AT ALL before this install (enabled or not)? That is what
|
||||
distinguishes a FRESH scripting install — where the runner is now registered enabled by default
|
||||
(design D9: the library moves into plugins, and a flagship surface cannot depend on an opt-in
|
||||
subsystem, or a fresh box would come up with an empty library) — from an UPGRADE, where the
|
||||
operator's own choice is the only thing that may decide it. }
|
||||
ScriptingTaskExisted: Boolean;
|
||||
|
||||
{ Escape a value for embedding in a single-quoted PowerShell literal ('' is PS's escaped quote).
|
||||
The install dir is user-chosen, so it can legitimately contain an apostrophe. }
|
||||
@@ -643,6 +648,22 @@ begin
|
||||
Result := ResultCode = 1;
|
||||
end;
|
||||
|
||||
{ Is the task registered at all, whatever its state? Distinct from TaskEnabled: an operator who
|
||||
deliberately DISABLED the runner must keep it disabled across an upgrade, which is indistinguishable
|
||||
from a fresh install if you only ask "was it enabled". }
|
||||
function TaskExists(TaskName: String): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
if Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -Command "' +
|
||||
'$t=Get-ScheduledTask -TaskName ''' + PsLiteral(TaskName) + ''' -ErrorAction SilentlyContinue; ' +
|
||||
'if($t){exit 1}; exit 0"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
Result := ResultCode = 1;
|
||||
end;
|
||||
|
||||
{ Free the bundled bun.exe (and the console's own files) BEFORE the copy. Windows will not delete a
|
||||
running image, so a surviving bun means "DeleteFile failed; code 5" on bun\bun.exe - the modal a
|
||||
user hit updating to 0.22.1.
|
||||
@@ -664,6 +685,9 @@ var
|
||||
begin
|
||||
WebTaskWasEnabled := TaskEnabled('PunktfunkWeb');
|
||||
ScriptingTaskWasEnabled := TaskEnabled('PunktfunkScripting');
|
||||
{ Probed BEFORE the Disable below, which would otherwise make every upgrade look like a fresh
|
||||
install to the registration entry. }
|
||||
ScriptingTaskExisted := TaskExists('PunktfunkScripting');
|
||||
Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -Command "' +
|
||||
'$ErrorActionPreference=''SilentlyContinue''; ' +
|
||||
@@ -689,6 +713,35 @@ end;
|
||||
DELETED the legacy task (the console runs under the host service now), so Enable-ScheduledTask
|
||||
hits nothing and no-ops under SilentlyContinue. If the user cancels mid-install, though,
|
||||
DeinitializeSetup runs this same restore and puts the old (task-owned) world back intact. }
|
||||
{ Register PunktfunkScripting, and decide whether it comes up ENABLED.
|
||||
`Register-ScheduledTask` registers enabled, so the state is decided by what follows:
|
||||
* FRESH install (the task did not exist) -> leave it enabled and start it now, so the runner is
|
||||
live without waiting for a reboot. Since the library's scanners become plugins (design D9),
|
||||
shipping this opt-in would mean a fresh box comes up with an empty library and no obvious
|
||||
reason why.
|
||||
* UPGRADE (the task existed) -> disable here and let RestoreTasksParams put the operator's own
|
||||
state back. That order is deliberate: this entry cannot know what they chose, and defaulting
|
||||
to "on" here would silently switch the runner on for everyone who had turned it off.
|
||||
It remains opt-OUT: `punktfunk-host plugins disable`, or the task's own Disable, still wins and
|
||||
survives every later upgrade through exactly this path. }
|
||||
function ScriptingRegisterParams(Param: String): String;
|
||||
begin
|
||||
Result := '-NoProfile -ExecutionPolicy Bypass -Command "' +
|
||||
'$ErrorActionPreference=''SilentlyContinue''; ' +
|
||||
'$a=New-ScheduledTaskAction -Execute ''' +
|
||||
PsLiteral(ExpandConstant('{app}\scripting\scripting-run.cmd')) + '''; ' +
|
||||
'$t=New-ScheduledTaskTrigger -AtStartup; ' +
|
||||
'$p=New-ScheduledTaskPrincipal -UserId ''LocalService'' -LogonType ServiceAccount; ' +
|
||||
'$s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ' +
|
||||
'-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; ' +
|
||||
'Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p ' +
|
||||
'-Settings $s -Force | Out-Null; ';
|
||||
if ScriptingTaskExisted then
|
||||
Result := Result + 'Disable-ScheduledTask -TaskName PunktfunkScripting | Out-Null"'
|
||||
else
|
||||
Result := Result + 'Start-ScheduledTask -TaskName PunktfunkScripting | Out-Null"';
|
||||
end;
|
||||
|
||||
function RestoreTasksParams(Param: String): String;
|
||||
begin
|
||||
Result := '-NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=''SilentlyContinue''; ';
|
||||
|
||||
@@ -53,6 +53,41 @@ export default definePluginKit({
|
||||
| `loggingLayer` | runner-journal line format |
|
||||
| `@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/library` | everything a **game-library scanner** plugin needs — see below |
|
||||
|
||||
## Library-scanner plugins (`@punktfunk/plugin-kit/library`)
|
||||
|
||||
The six first-party scanners (steam, lutris, heroic, epic, gog, xbox) each live in **their own
|
||||
repo**, like every other punktfunk plugin. Nothing is lost by that split because everything they
|
||||
share is published here rather than sitting adjacent to them:
|
||||
|
||||
| Export | What it saves you writing |
|
||||
| --- | --- |
|
||||
| `defineLibraryPlugin` | the whole plugin except the scan: store claim, sync engine (poll + fs-watch + debounce), launcher entries, `__config`, `category: "library"` registration, and the `detect` / `scan` / `parity` / `uninstall` CLI verbs |
|
||||
| `parsers/*` | text VDF + `.acf`, binary `shortcuts.vdf` (with the CRC-32 appid and the 64-bit `rungameid` composition), read-only SQLite, `reg.exe`, capped readers, a confined path join, Steam root/library discovery, art location helpers, an anti-SSRF fetch |
|
||||
| `diffParity` + the `parity` verb | the acceptance gate below |
|
||||
|
||||
A first-party scanner is therefore **its parsers and a `scan` function** — a few hundred lines.
|
||||
|
||||
### The parity gate
|
||||
|
||||
Ported unit tests pin the parsers; they do not prove the plugin reproduces the scanner it replaces.
|
||||
A plugin that parses perfectly and emits `steam:440.0` instead of `steam:440` breaks every Moonlight
|
||||
pin on the host, and no parser test notices. So, on a box with that launcher installed:
|
||||
|
||||
```sh
|
||||
# 1. while the host is still using its BUILT-IN scanner:
|
||||
punktfunk-plugin-steam parity --snapshot before.json
|
||||
# 2. offline — runs this plugin's own scan and diffs:
|
||||
punktfunk-plugin-steam parity --compare before.json
|
||||
```
|
||||
|
||||
`--compare` exits non-zero on any difference, so it works as a release gate. It compares ids,
|
||||
titles, launch recipes, roles and metadata exactly; **art by presence, not value** (the
|
||||
representation legitimately changes — a host-relative proxy path or inlined `data:` URL becomes a
|
||||
`file://` path or a CDN URL), so spot-check a few covers by eye once. Launcher entries the plugin
|
||||
adds are reported separately rather than failing the run; an ordinary title the scanner never had
|
||||
still fails.
|
||||
|
||||
## Telling the host how to recognize a running title (`detect`)
|
||||
|
||||
|
||||
+16
-9
@@ -21,12 +21,15 @@
|
||||
],
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^8.9.0",
|
||||
},
|
||||
"packages": {
|
||||
"@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.98", "", { "dependencies": { "swagger2openapi": "^7.0.8" }, "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.98", "effect": "^4.0.0-beta.98" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-7bqawr/HqJWqQ8H/bHyzBlLPA3LIIm3Y+cGYlIxnC/QVK795QpiEXb7uxTnP7V7w49V0sBtTerv4/9ZjsMffLQ=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.99", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.99" } }, "sha512-POBAowafsAAb3bH1x1rJlWnv32yMAazFgEuRW5LhkW/JJA5VGoEk9OnuoUkIH1OW6K/X6IrdNpqcO+5e9lPQJA=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.103", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.103" } }, "sha512-0aCZMBid5ifqmY55TkfCDLaGTIM8qu3bNFUW7qL9vh/7jFOkaIAMX2MA8muG4deqW17XWxawddWu4v0fK+UW3g=="],
|
||||
|
||||
"@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="],
|
||||
|
||||
@@ -44,15 +47,15 @@
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||
|
||||
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
|
||||
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "bun2nix": "2.1.2", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
|
||||
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
@@ -62,6 +65,8 @@
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"bun2nix": ["bun2nix@2.1.2", "", { "dependencies": { "sade": "^1.8.1" }, "bin": { "bun2nix": "index.ts" } }, "sha512-0wx6Ar5ccrz4aSD5prbShwymjDEXFh7Bucxs+YrpAMa67TnVB95Hv8FV3oaQEbtOx6QGgIAyOmap6Y3WCRqetg=="],
|
||||
|
||||
"call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
@@ -108,9 +113,11 @@
|
||||
|
||||
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
|
||||
"msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="],
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||
|
||||
@@ -144,6 +151,8 @@
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"should": ["should@13.2.3", "", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="],
|
||||
|
||||
"should-equal": ["should-equal@2.0.0", "", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="],
|
||||
@@ -170,7 +179,7 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
"undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
@@ -182,7 +191,7 @@
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
|
||||
"ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
@@ -192,8 +201,6 @@
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"@effect/platform-node/undici": ["undici@8.8.0", "", {}, "sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw=="],
|
||||
|
||||
"oas-linter/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
|
||||
"oas-resolver/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// A COMPLETE library-scanner plugin, and the template the six first-party ones are cut from.
|
||||
//
|
||||
// This is the lutris pilot (design M5/WP5.1) — the smallest of the six, and the one that exercises
|
||||
// the POSIX local-art path end to end. It lives here as a worked example rather than shipped code:
|
||||
// each scanner gets its OWN repo (the house pattern), and this is what you copy into a fresh one.
|
||||
// `package.json`'s `files` is dist + README, so nothing here is published.
|
||||
//
|
||||
// The point it proves: everything below the `scan` function is store-specific parsing, and
|
||||
// everything else — the store claim, the sync engine, launcher entries, `__config`, the console
|
||||
// registration, the CLI verbs including the parity gate — comes from `defineLibraryPlugin`. That is
|
||||
// what makes six repos cost nothing in duplication.
|
||||
//
|
||||
// Ported from crates/punktfunk-host/src/library/lutris.rs, with two deliberate changes:
|
||||
// * art is emitted as `file://` URLs instead of inlined `data:` URLs. The host proxies the bytes,
|
||||
// so the reconcile payload stays tiny — inlining covers is what blew the host's 2 MB body limit
|
||||
// at 49 titles during the playnite work, and it is exactly why the POSIX art path exists (G4).
|
||||
// * the `installed = 1` filter and the untrusted-slug guard are carried over verbatim. The slug
|
||||
// comes from Lutris's own database and is interpolated into a path, so the guard is load-bearing.
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Effect, Schema } from "effect";
|
||||
import {
|
||||
defineLibraryPlugin,
|
||||
fileUrl,
|
||||
isFile,
|
||||
withReadOnlyDb,
|
||||
} from "../src/library/index.js";
|
||||
import type { ProviderEntry } from "../src/wire.js";
|
||||
|
||||
const LutrisConfig = Schema.Struct({
|
||||
/**
|
||||
* Where `pga.db` lives, when it isn't in one of the standard places. Annotated because the
|
||||
* console's generic settings form derives its label and help text from exactly these.
|
||||
*/
|
||||
databasePath: Schema.optionalKey(
|
||||
Schema.String.annotate({
|
||||
title: "Lutris database",
|
||||
description:
|
||||
"Absolute path to pga.db. Leave empty to find it automatically.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/** Candidate `pga.db` locations: XDG data dir, the classic path, Flatpak. */
|
||||
const databaseCandidates = (): string[] => {
|
||||
const out: string[] = [];
|
||||
const xdg = process.env.XDG_DATA_HOME;
|
||||
if (xdg) out.push(path.join(xdg, "lutris/pga.db"));
|
||||
const home = os.homedir();
|
||||
if (home) {
|
||||
out.push(path.join(home, ".local/share/lutris/pga.db"));
|
||||
out.push(path.join(home, ".var/app/net.lutris.Lutris/data/lutris/pga.db"));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const findDatabase = (cfg: { databasePath?: string }): string | undefined =>
|
||||
[...(cfg.databasePath ? [cfg.databasePath] : []), ...databaseCandidates()].find(
|
||||
isFile,
|
||||
);
|
||||
|
||||
/**
|
||||
* `<kind>/<slug>.jpg` across the current, legacy-cache and Flatpak Lutris roots.
|
||||
*
|
||||
* The slug comes verbatim from Lutris's database and is interpolated into a path, so a separator,
|
||||
* parent ref or NUL is refused — otherwise a crafted slug is an arbitrary-file-read primitive, and
|
||||
* the resulting path would be handed to the host's art proxy to serve (security-review 2026-07-17).
|
||||
* Real Lutris slugs are `[a-z0-9-]`.
|
||||
*/
|
||||
const artFile = (kind: string, slug: string): string | undefined => {
|
||||
if (
|
||||
slug === "" ||
|
||||
slug.includes("/") ||
|
||||
slug.includes("\\") ||
|
||||
slug.includes("..") ||
|
||||
slug.includes("\0")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const home = os.homedir();
|
||||
if (!home) return undefined;
|
||||
const roots = [
|
||||
path.join(home, ".local/share/lutris"),
|
||||
path.join(home, ".cache/lutris"),
|
||||
path.join(home, ".var/app/net.lutris.Lutris/data/lutris"),
|
||||
path.join(home, ".var/app/net.lutris.Lutris/cache/lutris"),
|
||||
];
|
||||
for (const root of roots) {
|
||||
const p = path.join(root, kind, `${slug}.jpg`);
|
||||
if (isFile(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
interface GameRow {
|
||||
id: number;
|
||||
slug: string | null;
|
||||
name: string;
|
||||
directory: string | null;
|
||||
}
|
||||
|
||||
export default defineLibraryPlugin({
|
||||
// One string: plugin id, provider id, store claim, and the id of the built-in scanner this
|
||||
// replaces. That identity chain is what keeps entry ids, GameStream app ids and the operator's
|
||||
// existing enable/disable state intact across the migration.
|
||||
name: "lutris",
|
||||
configSchema: LutrisConfig,
|
||||
|
||||
detect: (cfg) => Effect.sync(() => findDatabase(cfg) !== undefined),
|
||||
|
||||
scan: (cfg) =>
|
||||
Effect.sync(() => {
|
||||
const db = findDatabase(cfg);
|
||||
if (!db) return [];
|
||||
// Read-only + immutable: a running Lutris holding the file can neither block us nor be
|
||||
// disturbed by us.
|
||||
const rows =
|
||||
withReadOnlyDb(db, (h) =>
|
||||
// `directory` is our only detect signal but is not load-bearing for the library, so
|
||||
// a schema without it must not cost the whole source — the helper answers [] on a
|
||||
// bad query, and the fallback keeps the titles.
|
||||
h.query<GameRow>(
|
||||
"SELECT id, slug, name, directory FROM games " +
|
||||
"WHERE installed = 1 AND name IS NOT NULL AND name <> '' " +
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
),
|
||||
) ?? [];
|
||||
const usable =
|
||||
rows.length > 0
|
||||
? rows
|
||||
: (withReadOnlyDb(db, (h) =>
|
||||
h.query<GameRow>(
|
||||
"SELECT id, slug, name, NULL AS directory FROM games " +
|
||||
"WHERE installed = 1 AND name IS NOT NULL AND name <> '' " +
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
),
|
||||
) ?? []);
|
||||
|
||||
return usable.map((row): ProviderEntry => {
|
||||
const portrait = row.slug ? artFile("coverart", row.slug) : undefined;
|
||||
const header = row.slug ? artFile("banners", row.slug) : undefined;
|
||||
const dir = row.directory?.trim();
|
||||
return {
|
||||
// The host composes `lutris:<external_id>` — byte-identical to what the built-in
|
||||
// scanner produced, which the parity gate checks.
|
||||
external_id: String(row.id),
|
||||
title: row.name,
|
||||
launch: { kind: "lutris_id", value: String(row.id) },
|
||||
art: {
|
||||
...(portrait ? { portrait: fileUrl(portrait) } : {}),
|
||||
...(header ? { header: fileUrl(header) } : {}),
|
||||
},
|
||||
// Lutris stamps no per-game env marker worth relying on, so the install dir is the
|
||||
// whole recipe; a game with none (an emulator entry pointing at a bare ROM) stays
|
||||
// untracked, exactly as it did in-host.
|
||||
...(dir ? { detect: { install_dir: dir } } : {}),
|
||||
platform: "PC",
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
// Re-scan when Lutris writes: installing a game touches the database, and downloading art
|
||||
// touches the cover directories.
|
||||
watchDirs: (cfg) => {
|
||||
const db = findDatabase(cfg);
|
||||
return db ? [path.dirname(db)] : [];
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-kit",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -29,6 +29,10 @@
|
||||
"types": "./dist/wire.d.ts",
|
||||
"default": "./dist/wire.js"
|
||||
},
|
||||
"./library": {
|
||||
"types": "./dist/library/index.d.ts",
|
||||
"default": "./dist/library/index.js"
|
||||
},
|
||||
"./theme.css": "./dist/theme.css"
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
@@ -57,5 +61,8 @@
|
||||
"@types/react": "^19.2.16",
|
||||
"effect": "4.0.0-beta.99",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^8.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ export {
|
||||
type SyncSettings,
|
||||
type SyncStatus,
|
||||
} from "./sync-engine.js";
|
||||
export { httpApiEnv, serveUi, type ServeUiOptions } from "./ui-server.js";
|
||||
export {
|
||||
deriveConfigJsonSchema,
|
||||
httpApiEnv,
|
||||
makeConfigHandler,
|
||||
serveUi,
|
||||
type ServeUiConfig,
|
||||
type ServeUiOptions,
|
||||
} from "./ui-server.js";
|
||||
export { sseRoute, type SseRouteOptions } from "./sse.js";
|
||||
export { type CliCommand, runPluginCli } from "./cli.js";
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
// `defineLibraryPlugin` — the shared framework behind every library-scanner plugin (design D10).
|
||||
//
|
||||
// The point of this module is that a first-party scanner should be **its parsers and a scan
|
||||
// function**, ~200–400 lines, and nothing else. Everything a scanner needs beyond that is identical
|
||||
// across all six of them and lives here: claiming the store, reconciling through the sync engine,
|
||||
// appending launcher entries, serving `__config` so the console renders settings without the plugin
|
||||
// shipping an SPA, registering under `category: "library"` so it stays out of the nav, and the
|
||||
// standard CLI verbs.
|
||||
import type { PluginDef } from "@punktfunk/host";
|
||||
import * as fs from "node:fs";
|
||||
import { Duration, Effect, Layer, Schema, Stream } from "effect";
|
||||
import { type CliCommand, runPluginCli } from "../cli.js";
|
||||
import { type ConfigService, makeConfigService } from "../config.js";
|
||||
import { HostClient, PluginInfo } from "../host-client.js";
|
||||
import { ProviderClient, type ProviderClientService } from "../reconcile.js";
|
||||
import { definePluginKit, type PluginKitDef } from "../runtime.js";
|
||||
import { makeSyncEngine } from "../sync-engine.js";
|
||||
import { serveUi } from "../ui-server.js";
|
||||
import type { ProviderEntry } from "../wire.js";
|
||||
import {
|
||||
diffParity,
|
||||
formatParityReport,
|
||||
fromHostEntry,
|
||||
fromProviderEntry,
|
||||
type HostGameEntry,
|
||||
} from "./parity.js";
|
||||
|
||||
/** What a scan produced — the status surface and the CLI's `scan` verb both render this. */
|
||||
export interface ScanReport {
|
||||
readonly entries: number;
|
||||
readonly launchers: number;
|
||||
/** False when the launcher isn't installed here — the library is legitimately empty. */
|
||||
readonly present: boolean;
|
||||
}
|
||||
|
||||
export interface LibraryPluginDef<S extends Schema.Top> {
|
||||
/**
|
||||
* The plugin id. **This one string is also the provider id, the store claim, and the id of the
|
||||
* built-in scanner this plugin replaces.** That identity chain is what makes the migration
|
||||
* invisible: entry ids stay `<name>:<external_id>`, GameStream app ids and client art caches
|
||||
* stay valid, and the operator's existing enable/disable state carries over untouched.
|
||||
*/
|
||||
readonly name: string;
|
||||
readonly version?: string;
|
||||
/**
|
||||
* The store to claim (design D2). Defaults to {@link name} and should almost never differ — see
|
||||
* the identity note above. Pass `null` to opt out of claiming entirely, which makes this an
|
||||
* ordinary unclaimed provider whose entries surface as `custom:`.
|
||||
*/
|
||||
readonly store?: string | null;
|
||||
/** The operator-facing config schema. Drives `__config` and every callback's argument. */
|
||||
readonly configSchema: S;
|
||||
/**
|
||||
* Is this launcher present on the host at all? Surfaces in the CLI's `detect` verb, and lets the
|
||||
* plugin report "not installed" rather than silently syncing an empty library.
|
||||
*/
|
||||
readonly detect: (cfg: S["Type"]) => Effect.Effect<boolean>;
|
||||
/** Enumerate the launcher's installed titles — the only real per-store code. */
|
||||
readonly scan: (
|
||||
cfg: S["Type"],
|
||||
) => Effect.Effect<ReadonlyArray<ProviderEntry>>;
|
||||
/**
|
||||
* Entries that open the LAUNCHER itself (design D4) — Steam Big Picture, Heroic, … Appended to
|
||||
* every reconcile, so toggling one in config takes effect on the next sync. Emit them with
|
||||
* `role: "launcher"`; the kit does not stamp it for you, because a plugin may legitimately want
|
||||
* an entry that opens a launcher but still lists as an ordinary game.
|
||||
*/
|
||||
readonly launchers?: (cfg: S["Type"]) => ReadonlyArray<ProviderEntry>;
|
||||
/** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */
|
||||
readonly watchDirs?: (cfg: S["Type"]) => ReadonlyArray<string>;
|
||||
/** How often to re-scan regardless of watches. Default `Duration.minutes(15)`. */
|
||||
readonly pollInterval?: Duration.Duration;
|
||||
/** Debounce on filesystem events. Default `Duration.seconds(3)`. */
|
||||
readonly debounce?: Duration.Duration;
|
||||
/** Display title (the console's sources row falls back to the scanner label). Defaults to `name`. */
|
||||
readonly title?: string;
|
||||
/** Extra CLI verbs beyond the standard `detect` / `scan` / `uninstall` set. */
|
||||
readonly commands?: Record<string, CliCommand<never>>;
|
||||
}
|
||||
|
||||
/** `--flag value` from an argv slice, or undefined. */
|
||||
const flagValue = (
|
||||
argv: ReadonlyArray<string>,
|
||||
flag: string,
|
||||
): string | undefined => {
|
||||
const i = argv.indexOf(flag);
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
||||
};
|
||||
|
||||
/** The pieces a library plugin package wires into its entry points. */
|
||||
export interface LibraryPlugin {
|
||||
/** The runner-discovered default export (`export default plugin.def`). */
|
||||
readonly def: PluginDef;
|
||||
/** The CLI entry (`await plugin.cli()` from the package's bin). */
|
||||
readonly cli: (argv?: ReadonlyArray<string>) => Promise<void>;
|
||||
}
|
||||
|
||||
export const defineLibraryPlugin = <S extends Schema.Top>(
|
||||
def: LibraryPluginDef<S>,
|
||||
): LibraryPlugin => {
|
||||
const store = def.store === null ? undefined : (def.store ?? def.name);
|
||||
const poll = def.pollInterval ?? Duration.minutes(15);
|
||||
const debounce = def.debounce ?? Duration.seconds(3);
|
||||
|
||||
/** The config service, built fresh wherever it is needed (it only requires `PluginInfo`). */
|
||||
const config: Effect.Effect<ConfigService<S>, never, PluginInfo> =
|
||||
makeConfigService({ schema: def.configSchema });
|
||||
|
||||
/** Scan + launcher entries, in the order they should reach the host. */
|
||||
const computeEntries = (
|
||||
cfg: S["Type"],
|
||||
): Effect.Effect<{
|
||||
readonly entries: ReadonlyArray<ProviderEntry>;
|
||||
readonly report: ScanReport;
|
||||
}> =>
|
||||
Effect.gen(function* () {
|
||||
const present = yield* def.detect(cfg);
|
||||
// A launcher that isn't installed contributes NOTHING — not even its launcher entries. A
|
||||
// "Steam Big Picture" tile on a box without Steam would only fail to launch.
|
||||
if (!present) {
|
||||
return {
|
||||
entries: [] as ReadonlyArray<ProviderEntry>,
|
||||
report: { entries: 0, launchers: 0, present: false } as const,
|
||||
};
|
||||
}
|
||||
const scanned = yield* def.scan(cfg);
|
||||
const launchers = def.launchers?.(cfg) ?? [];
|
||||
return {
|
||||
entries: [...scanned, ...launchers],
|
||||
report: {
|
||||
entries: scanned.length,
|
||||
launchers: launchers.length,
|
||||
present: true,
|
||||
} as const,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Push one entry set to the host under the store claim, warning **once** if the host is too old
|
||||
* to honour it.
|
||||
*
|
||||
* This degradation is worth the code: a pre-M2 host ignores `?store=` silently, and the only
|
||||
* symptom would be this plugin's titles appearing as unbadged `custom:` entries *beside* the
|
||||
* built-in scanner's identical ones — a confusing double-listing with no error anywhere.
|
||||
* Checking the echoed entries turns that into one actionable log line.
|
||||
*/
|
||||
const applyEntries =
|
||||
(provider: ProviderClientService, state: { warned: boolean }) =>
|
||||
(entries: ReadonlyArray<ProviderEntry>): Effect.Effect<void, unknown> =>
|
||||
provider.reconcile(def.name, entries, store).pipe(
|
||||
Effect.tap((echoed) => {
|
||||
if (!store || state.warned || echoed.length === 0) return Effect.void;
|
||||
if (echoed.some((e) => e.store === store)) return Effect.void;
|
||||
state.warned = true;
|
||||
return Effect.logWarning(
|
||||
`host is too old for store claims: this source's games will appear as custom ` +
|
||||
`entries and the host's own "${store}" scanner is not suppressed, so titles ` +
|
||||
`may be listed twice. Updating the host resolves it.`,
|
||||
);
|
||||
}),
|
||||
Effect.asVoid,
|
||||
);
|
||||
|
||||
const main = Effect.gen(function* () {
|
||||
const cfgService = yield* config;
|
||||
const provider = yield* ProviderClient;
|
||||
const state = { warned: false };
|
||||
|
||||
const engine = yield* makeSyncEngine<
|
||||
ScanReport,
|
||||
ReadonlyArray<ProviderEntry>,
|
||||
never
|
||||
>({
|
||||
compute: () => cfgService.load.pipe(Effect.flatMap(computeEntries)),
|
||||
apply: applyEntries(provider, state),
|
||||
// The host IS the state: a full-replace reconcile is idempotent, so there is nothing to
|
||||
// persist between runs. Reporting no previous fingerprint means the first sync after a
|
||||
// restart always pushes, which is exactly what we want (the host may have been reinstalled
|
||||
// underneath us).
|
||||
lastSync: { get: Effect.succeed(undefined), set: () => Effect.void },
|
||||
settings: cfgService.load.pipe(
|
||||
Effect.map((cfg) => def.watchDirs?.(cfg) ?? []),
|
||||
// A config file that won't decode must not stop the poll loop: fall back to no watch
|
||||
// dirs, keep syncing on the timer, and let the operator see the parse error in the
|
||||
// settings drawer (`GET /__config` reports it).
|
||||
Effect.catch(() => Effect.succeed([] as ReadonlyArray<string>)),
|
||||
Effect.map((watchDirs) => ({
|
||||
pollInterval: poll,
|
||||
watch: true,
|
||||
debounce,
|
||||
watchDirs,
|
||||
})),
|
||||
),
|
||||
});
|
||||
|
||||
// The UI server exists ONLY to serve `__config` (and the SDK's `__health`): no `staticDir`,
|
||||
// no API. That is the whole "settings without an SPA" story (design D7, closing G8), and the
|
||||
// `library` category is what keeps six installed scanners out of the console's sidebar.
|
||||
yield* serveUi({
|
||||
title: def.title ?? def.name,
|
||||
category: "library",
|
||||
config: { schema: def.configSchema, service: cfgService },
|
||||
});
|
||||
|
||||
yield* engine.start;
|
||||
// A saved settings change is exactly when a user expects the library to update — and it may
|
||||
// have changed `watchDirs`, so re-read settings rather than just re-syncing.
|
||||
yield* Effect.forkScoped(
|
||||
Stream.runForEach(cfgService.changes, () => engine.reconfigure),
|
||||
);
|
||||
yield* Effect.never;
|
||||
});
|
||||
|
||||
const kitDef: PluginKitDef<never, ProviderClient> = {
|
||||
name: def.name,
|
||||
...(def.version !== undefined ? { version: def.version } : {}),
|
||||
layer: ProviderClient.layer,
|
||||
main: main as Effect.Effect<
|
||||
void,
|
||||
never,
|
||||
ProviderClient | HostClient | PluginInfo | never
|
||||
>,
|
||||
};
|
||||
|
||||
const standardCommands: Record<string, CliCommand<ProviderClient>> = {
|
||||
detect: {
|
||||
summary: "report whether this launcher is installed on the host",
|
||||
// Offline on purpose: "is Steam here?" must be answerable without a running host.
|
||||
offline: true,
|
||||
run: () =>
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* (yield* config).load;
|
||||
console.log((yield* def.detect(cfg)) ? "present" : "absent");
|
||||
}),
|
||||
},
|
||||
scan: {
|
||||
summary: "scan and print what WOULD be synced (--preview for the JSON entries)",
|
||||
// Also offline: the point is to debug a scanner against real launcher files without
|
||||
// touching the host's library.
|
||||
offline: true,
|
||||
run: (argv) =>
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* (yield* config).load;
|
||||
const { entries, report } = yield* computeEntries(cfg);
|
||||
if (argv.includes("--preview")) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
} else {
|
||||
console.log(
|
||||
`${report.present ? "present" : "absent"}: ${report.entries} games, ` +
|
||||
`${report.launchers} launcher entries`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
},
|
||||
parity: {
|
||||
summary:
|
||||
"prove this plugin reproduces the built-in scanner (--snapshot <f> | --compare <f>)",
|
||||
// `--compare` is offline (it runs THIS plugin's scan); `--snapshot` needs the host. The
|
||||
// dispatcher decides per invocation below, so the verb is registered as online and the
|
||||
// snapshot path is the one that actually uses the client.
|
||||
run: (argv) =>
|
||||
Effect.gen(function* () {
|
||||
const snapshot = flagValue(argv, "--snapshot");
|
||||
const compare = flagValue(argv, "--compare");
|
||||
if (!snapshot && !compare) {
|
||||
console.error(
|
||||
"usage: parity --snapshot <file> (capture the host's CURRENT library for this store)\n" +
|
||||
" parity --compare <file> (diff this plugin's scan against that capture)",
|
||||
);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (snapshot) {
|
||||
// The baseline: what the host reports for THIS store while its built-in scanner
|
||||
// is still the thing producing it. Capture before installing the plugin.
|
||||
const host = yield* HostClient;
|
||||
const body = yield* host.request("GET", "/library");
|
||||
const mine = (Array.isArray(body) ? (body as HostGameEntry[]) : [])
|
||||
.filter((e) => e.store === (store ?? def.name))
|
||||
.map(fromHostEntry)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
yield* Effect.sync(() =>
|
||||
fs.writeFileSync(snapshot, `${JSON.stringify(mine, null, 2)}\n`),
|
||||
);
|
||||
console.log(
|
||||
`captured ${mine.length} "${store ?? def.name}" entries to ${snapshot}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const baseline = yield* Effect.try({
|
||||
try: () =>
|
||||
JSON.parse(fs.readFileSync(compare as string, "utf8")) as ReturnType<
|
||||
typeof fromHostEntry
|
||||
>[],
|
||||
catch: (cause) => new Error(`cannot read ${compare}: ${cause}`),
|
||||
});
|
||||
const cfg = yield* (yield* config).load;
|
||||
const { entries } = yield* computeEntries(cfg);
|
||||
const produced = entries.map((e) =>
|
||||
fromProviderEntry(store ?? def.name, e),
|
||||
);
|
||||
const report = diffParity(baseline, produced);
|
||||
console.log(formatParityReport(report));
|
||||
// A non-zero exit is what makes this usable as a release gate rather than a report
|
||||
// somebody skims.
|
||||
if (!report.ok) process.exitCode = 1;
|
||||
}),
|
||||
},
|
||||
uninstall: {
|
||||
summary: "remove this source's games from the host and release its store claim",
|
||||
run: () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* ProviderClient;
|
||||
// The empty reconcile clears the entries; DELETE is what releases the CLAIM — and
|
||||
// releasing is what brings the host's own built-in scanner straight back.
|
||||
yield* provider.reconcile(def.name, [], undefined);
|
||||
yield* provider.remove(def.name);
|
||||
console.log(`${def.name}: entries removed, store claim released`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
def: definePluginKit(kitDef),
|
||||
cli: (argv) =>
|
||||
runPluginCli({
|
||||
def: kitDef,
|
||||
commands: {
|
||||
...standardCommands,
|
||||
...(def.commands ?? {}),
|
||||
} as Record<string, CliCommand<ProviderClient>>,
|
||||
...(argv !== undefined ? { argv } : {}),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
// `@punktfunk/plugin-kit/library` — the shared framework for library-scanner plugins.
|
||||
//
|
||||
// A first-party scanner is its parsers plus a scan function; everything else (store claim, sync
|
||||
// engine wiring, launcher entries, `__config`, nav category, CLI verbs) comes from
|
||||
// `defineLibraryPlugin`. See design/library-scanner-plugins.md D10.
|
||||
export {
|
||||
defineLibraryPlugin,
|
||||
type LibraryPlugin,
|
||||
type LibraryPluginDef,
|
||||
type ScanReport,
|
||||
} from "./define.js";
|
||||
export {
|
||||
claimedLibraryId,
|
||||
diffParity,
|
||||
formatParityReport,
|
||||
fromHostEntry,
|
||||
fromProviderEntry,
|
||||
type HostGameEntry,
|
||||
type ParityChange,
|
||||
type ParityEntry,
|
||||
type ParityReport,
|
||||
} from "./parity.js";
|
||||
export * from "./parsers/index.js";
|
||||
@@ -0,0 +1,249 @@
|
||||
// The parity harness: proof that a library plugin reproduces the in-host scanner it replaces.
|
||||
//
|
||||
// This is the acceptance gate for every extracted scanner (design M5). Ported unit tests are
|
||||
// necessary but nowhere near sufficient — they pin the PARSERS, while what actually has to hold is
|
||||
// that the whole pipeline lands the same entries, with the same ids, launch recipes and detect
|
||||
// signals, on a real box with a real launcher installed. A plugin that parses perfectly and emits
|
||||
// `steam:440` as `steam:440.0` breaks every Moonlight pin on the host and no parser test notices.
|
||||
//
|
||||
// It lives in the KIT, not in a plugin, because it is identical for all six: capture what the host
|
||||
// reports while its built-in scanner is doing the work, then check the plugin produces the same set.
|
||||
// (One plugin per repo is the house pattern, so anything shared has to be published, not adjacent.)
|
||||
//
|
||||
// Usage, per plugin, on a box with that launcher installed:
|
||||
//
|
||||
// punktfunk-plugin-steam parity --snapshot before.json # host still on its built-in scanner
|
||||
// punktfunk-plugin-steam parity --compare before.json # offline: runs THIS plugin's scan
|
||||
//
|
||||
// `--compare` runs the plugin's own scan directly rather than installing it first, so a mismatch is
|
||||
// visible before anything is published — and the run is repeatable while you fix it.
|
||||
import type { ProviderEntry } from "../wire.js";
|
||||
|
||||
/** The four art slots, in the order the host's box-art ladder tries them. */
|
||||
const ART_KINDS = ["portrait", "hero", "logo", "header"] as const;
|
||||
type ArtKind = (typeof ART_KINDS)[number];
|
||||
|
||||
/** One entry, reduced to the facts parity is about. */
|
||||
export interface ParityEntry {
|
||||
/** The store-qualified library id — the field everything downstream is keyed on. */
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
/** `<kind>:<value>`, or null when the entry has no launch recipe. */
|
||||
readonly launch: string | null;
|
||||
/** `"game"` or `"launcher"`. */
|
||||
readonly role: string;
|
||||
/**
|
||||
* Which art kinds are PRESENT, not their values. The representation legitimately changes on
|
||||
* extraction (a scanner's `data:` URL or host-relative proxy path becomes a `file://` path or a
|
||||
* CDN URL), so comparing values would fail every time for no reason. Presence is the invariant
|
||||
* that matters: a title that had a poster must still have one.
|
||||
*/
|
||||
readonly art: Readonly<Record<ArtKind, boolean>>;
|
||||
/** Flat descriptive metadata (platform, genres, …) — compared verbatim. */
|
||||
readonly meta: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** What the host reports for one entry in `GET /library`. */
|
||||
export interface HostGameEntry {
|
||||
id: string;
|
||||
store: string;
|
||||
title: string;
|
||||
role?: string;
|
||||
launch?: { kind: string; value: string } | null;
|
||||
art?: Partial<Record<ArtKind, string | null>>;
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
|
||||
/** Keys on a host entry that are structure, not descriptive metadata. */
|
||||
const NON_META = new Set([
|
||||
"id",
|
||||
"store",
|
||||
"title",
|
||||
"role",
|
||||
"launch",
|
||||
"art",
|
||||
"provider",
|
||||
"external_id",
|
||||
"prep",
|
||||
"detect",
|
||||
]);
|
||||
|
||||
const artPresence = (
|
||||
art: Partial<Record<ArtKind, string | null>> | undefined,
|
||||
): Record<ArtKind, boolean> => {
|
||||
const out = {} as Record<ArtKind, boolean>;
|
||||
for (const k of ART_KINDS) out[k] = Boolean(art?.[k]);
|
||||
return out;
|
||||
};
|
||||
|
||||
const pickMeta = (src: Record<string, unknown>): Record<string, unknown> => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
// Absent and empty are the same thing here: the host omits empty lists and null fields, and a
|
||||
// plugin that sends `genres: []` has not changed anything.
|
||||
if (NON_META.has(k) || v == null) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** The library id the host assigns a claimed entry — the deterministic `<store>:<external_id>`. */
|
||||
export const claimedLibraryId = (store: string, externalId: string): string =>
|
||||
`${store}:${externalId}`;
|
||||
|
||||
/** Reduce what the host reported (the BEFORE side) to a comparable entry. */
|
||||
export const fromHostEntry = (e: HostGameEntry): ParityEntry => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null,
|
||||
role: e.role ?? "game",
|
||||
art: artPresence(e.art),
|
||||
meta: pickMeta(e as Record<string, unknown>),
|
||||
});
|
||||
|
||||
/** Reduce what this plugin produced (the AFTER side) to a comparable entry. */
|
||||
export const fromProviderEntry = (
|
||||
store: string,
|
||||
e: ProviderEntry,
|
||||
): ParityEntry => {
|
||||
const rec = e as unknown as Record<string, unknown>;
|
||||
return {
|
||||
id: claimedLibraryId(store, e.external_id),
|
||||
title: e.title,
|
||||
launch: e.launch ? `${e.launch.kind}:${e.launch.value}` : null,
|
||||
role: (e as { role?: string }).role ?? "game",
|
||||
art: artPresence(
|
||||
e.art as Partial<Record<ArtKind, string | null>> | undefined,
|
||||
),
|
||||
meta: pickMeta(rec),
|
||||
};
|
||||
};
|
||||
|
||||
/** One field that differs between the two sides. */
|
||||
export interface ParityChange {
|
||||
readonly id: string;
|
||||
readonly field: string;
|
||||
readonly before: unknown;
|
||||
readonly after: unknown;
|
||||
}
|
||||
|
||||
export interface ParityReport {
|
||||
/** In the baseline, absent from what the plugin produced — the plugin LOST a title. */
|
||||
readonly missing: ParityEntry[];
|
||||
/** Produced by the plugin, absent from the baseline — the plugin invented a title. */
|
||||
readonly extra: ParityEntry[];
|
||||
/** Same id, different facts. */
|
||||
readonly changed: ParityChange[];
|
||||
/** Entries present on both sides and identical. */
|
||||
readonly matched: number;
|
||||
/**
|
||||
* Launcher entries the plugin adds (design D4). Never a failure: the built-in scanner had no
|
||||
* concept of them, so they are expected to be `extra` and are reported separately so a real
|
||||
* regression isn't buried under them.
|
||||
*/
|
||||
readonly launchersAdded: ParityEntry[];
|
||||
readonly ok: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff a baseline (what the host reported while its built-in scanner ran) against what this plugin
|
||||
* produced. `ok` is true only when nothing is missing, nothing unexpected is extra, and no compared
|
||||
* field changed.
|
||||
*/
|
||||
export const diffParity = (
|
||||
baseline: ReadonlyArray<ParityEntry>,
|
||||
produced: ReadonlyArray<ParityEntry>,
|
||||
): ParityReport => {
|
||||
const byId = new Map(baseline.map((e) => [e.id, e]));
|
||||
const producedIds = new Set(produced.map((e) => e.id));
|
||||
const changed: ParityChange[] = [];
|
||||
const extra: ParityEntry[] = [];
|
||||
const launchersAdded: ParityEntry[] = [];
|
||||
let matched = 0;
|
||||
|
||||
for (const after of produced) {
|
||||
const before = byId.get(after.id);
|
||||
if (!before) {
|
||||
// A launcher entry has no counterpart by construction — the scanner never emitted one.
|
||||
(after.role === "launcher" ? launchersAdded : extra).push(after);
|
||||
continue;
|
||||
}
|
||||
const diffs = compareEntry(before, after);
|
||||
if (diffs.length === 0) matched++;
|
||||
else changed.push(...diffs);
|
||||
}
|
||||
|
||||
const missing = baseline.filter((e) => !producedIds.has(e.id));
|
||||
return {
|
||||
missing,
|
||||
extra,
|
||||
changed,
|
||||
matched,
|
||||
launchersAdded,
|
||||
ok: missing.length === 0 && extra.length === 0 && changed.length === 0,
|
||||
};
|
||||
};
|
||||
|
||||
const compareEntry = (
|
||||
before: ParityEntry,
|
||||
after: ParityEntry,
|
||||
): ParityChange[] => {
|
||||
const out: ParityChange[] = [];
|
||||
const note = (field: string, b: unknown, a: unknown) =>
|
||||
out.push({ id: before.id, field, before: b, after: a });
|
||||
|
||||
if (before.title !== after.title) note("title", before.title, after.title);
|
||||
if (before.launch !== after.launch)
|
||||
note("launch", before.launch, after.launch);
|
||||
if (before.role !== after.role) note("role", before.role, after.role);
|
||||
for (const k of ART_KINDS) {
|
||||
// Only a LOST art kind is a regression. Gaining one is an improvement (the plugin can reach
|
||||
// art the host never resolved), and failing a run over it would just train people to ignore
|
||||
// the harness.
|
||||
if (before.art[k] && !after.art[k]) note(`art.${k}`, true, false);
|
||||
}
|
||||
const keys = new Set([
|
||||
...Object.keys(before.meta),
|
||||
...Object.keys(after.meta),
|
||||
]);
|
||||
for (const k of keys) {
|
||||
const b = before.meta[k];
|
||||
const a = after.meta[k];
|
||||
if (JSON.stringify(b) !== JSON.stringify(a)) note(`meta.${k}`, b, a);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Render a report for a terminal. Empty-ish when everything matched. */
|
||||
export const formatParityReport = (r: ParityReport): string => {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
r.ok
|
||||
? `parity OK — ${r.matched} entries identical`
|
||||
: `parity FAILED — ${r.matched} identical, ${r.missing.length} missing, ${r.extra.length} unexpected, ${r.changed.length} changed`,
|
||||
);
|
||||
for (const e of r.missing) lines.push(` missing: ${e.id} ${e.title}`);
|
||||
for (const e of r.extra) lines.push(` extra: ${e.id} ${e.title}`);
|
||||
for (const c of r.changed) {
|
||||
lines.push(
|
||||
` changed: ${c.id} ${c.field}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`,
|
||||
);
|
||||
}
|
||||
if (r.launchersAdded.length > 0) {
|
||||
lines.push(
|
||||
` (+${r.launchersAdded.length} launcher ${r.launchersAdded.length === 1 ? "entry" : "entries"}, expected: ${r.launchersAdded
|
||||
.map((e) => e.id)
|
||||
.join(", ")})`,
|
||||
);
|
||||
}
|
||||
// Art REPRESENTATION always changes on extraction (a host-relative proxy path or an inlined
|
||||
// `data:` URL becomes a `file://` path or a CDN URL). Presence is what this harness checks, so
|
||||
// say plainly that the bytes still want a human's eyes once.
|
||||
if (r.ok) {
|
||||
lines.push(
|
||||
" note: art is compared by presence, not value — spot-check a few covers render.",
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
// Where a title's cover art lives: Steam's local caches, its per-account `grid/` overrides, and the
|
||||
// public CDN. Ported from the host scanner's art resolution (steam.rs).
|
||||
//
|
||||
// After extraction a plugin emits art VALUES and the host serves them: a `file://` URL for anything
|
||||
// on disk (the documented local-art contract — the host proxies the bytes), or an absolute CDN URL
|
||||
// the client fetches itself. `data:` URLs remain legal but are small-logo-only: inlining covers is
|
||||
// what blew the host's 2 MB body limit at 49 titles during the playnite work.
|
||||
import * as path from "node:path";
|
||||
import { isFile, listDir } from "./fs.js";
|
||||
|
||||
/** The four art slots the library model carries. */
|
||||
export type ArtKind = "portrait" | "hero" | "logo" | "header";
|
||||
|
||||
export const ART_KINDS: readonly ArtKind[] = [
|
||||
"portrait",
|
||||
"hero",
|
||||
"logo",
|
||||
"header",
|
||||
];
|
||||
|
||||
/** A `file://` URL for a local path — the shape the host's art proxy understands. */
|
||||
export const fileUrl = (p: string): string => {
|
||||
// Percent-encode, but keep the separators: the host converts this back to a path and expects the
|
||||
// structure intact. Windows drive paths become `file:///C:/…`.
|
||||
const abs = path.resolve(p);
|
||||
const posix = abs.replace(/\\/g, "/");
|
||||
const encoded = posix
|
||||
.split("/")
|
||||
.map((seg) => encodeURIComponent(seg))
|
||||
.join("/");
|
||||
return posix.startsWith("/") ? `file://${encoded}` : `file:///${encoded}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The legacy flat CDN URL for a Steam appid's art kind. Correct for the many titles Valve hasn't
|
||||
* re-hashed; newer ones serve from an unpredictable per-asset-hash path, where this 404s and the
|
||||
* client falls through to its next candidate. That degradation is intentional and pre-existing.
|
||||
*/
|
||||
export const steamCdnUrl = (appid: number, kind: ArtKind): string | undefined => {
|
||||
// A non-Steam shortcut's appid has the high bit set and is never a real store appid — the CDN
|
||||
// would only 404, so don't emit a URL that is guaranteed to fail.
|
||||
if ((appid & 0x8000_0000) !== 0) return undefined;
|
||||
const file =
|
||||
kind === "portrait"
|
||||
? "library_600x900.jpg"
|
||||
: kind === "hero"
|
||||
? "library_hero.jpg"
|
||||
: kind === "logo"
|
||||
? "logo.png"
|
||||
: "header.jpg";
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${file}`;
|
||||
};
|
||||
|
||||
/** Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper). */
|
||||
const localFilenames = (kind: ArtKind): string[] =>
|
||||
kind === "portrait"
|
||||
? ["library_600x900_2x.jpg", "library_600x900.jpg"]
|
||||
: kind === "hero"
|
||||
? ["library_hero.jpg"]
|
||||
: kind === "logo"
|
||||
? ["logo.png"]
|
||||
: // Steam's local cache names the header asset differently from the store CDN's
|
||||
// `header.jpg` — this trips everyone once.
|
||||
["library_header.jpg"];
|
||||
|
||||
/**
|
||||
* This kind's file under one Steam root's `appcache/librarycache/<appid>/<hash>/`, or `undefined`.
|
||||
* Steam reuses one hash dir per asset version, so there is normally exactly one candidate.
|
||||
*/
|
||||
export const findLocalArtFile = (
|
||||
root: string,
|
||||
appid: number,
|
||||
kind: ArtKind,
|
||||
): string | undefined => {
|
||||
const base = path.join(root, "appcache", "librarycache", String(appid));
|
||||
for (const hash of listDir(base)) {
|
||||
for (const name of localFilenames(kind)) {
|
||||
const p = path.join(base, hash, name);
|
||||
if (isFile(p)) return p;
|
||||
}
|
||||
}
|
||||
// Older Steam wrote the files directly under `librarycache/` with the appid in the name.
|
||||
for (const name of localFilenames(kind)) {
|
||||
const flat = path.join(root, "appcache", "librarycache", `${appid}_${name}`);
|
||||
if (isFile(flat)) return flat;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `grid/` basenames Steam names each art kind under for an appid: portrait `<A>p`, hero
|
||||
* `<A>_hero`, logo `<A>_logo`, wide capsule `<A>` — each as `.png` then `.jpg`.
|
||||
*
|
||||
* These overrides are the **only** art a non-Steam shortcut ever has.
|
||||
*/
|
||||
export const gridFilenames = (appid: number, kind: ArtKind): string[] => {
|
||||
const base =
|
||||
kind === "portrait"
|
||||
? `${appid}p`
|
||||
: kind === "hero"
|
||||
? `${appid}_hero`
|
||||
: kind === "logo"
|
||||
? `${appid}_logo`
|
||||
: `${appid}`;
|
||||
return [`${base}.png`, `${base}.jpg`];
|
||||
};
|
||||
|
||||
/** This kind's user override under a `userdata/<id>/config/grid/` dir, or `undefined`. */
|
||||
export const findGridArtFile = (
|
||||
configDir: string,
|
||||
appid: number,
|
||||
kind: ArtKind,
|
||||
): string | undefined => {
|
||||
const grid = path.join(configDir, "grid");
|
||||
for (const name of gridFilenames(appid, kind)) {
|
||||
const p = path.join(grid, name);
|
||||
if (isFile(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
// Bounded filesystem reads and path confinement — the posture the in-host scanners established,
|
||||
// ported so a library plugin inherits it instead of re-deriving it.
|
||||
//
|
||||
// The rules here exist because a plugin reads files it does not own: a launcher's manifests, a
|
||||
// catalog cache, a `goggame-*.info` a user could have edited. None of that is hostile in the normal
|
||||
// case, and all of it is untrusted in the case that matters.
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** A launcher manifest / `.acf` / `.info`: text, small. Matches `epic.rs`'s posture. */
|
||||
export const MAX_MANIFEST_BYTES = 1024 * 1024;
|
||||
/** A binary catalog cache (Epic's `catcache.bin`, a `shortcuts.vdf`): larger, still bounded. */
|
||||
export const MAX_CACHE_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Read a file as UTF-8, refusing anything over `max`. `undefined` on any error, a non-regular file,
|
||||
* or an over-cap file — a plugin scanning a directory must never die on one odd entry.
|
||||
*
|
||||
* The size is checked by `stat` BEFORE the read, so an enormous file costs a stat, not the memory.
|
||||
*/
|
||||
export const readTextCapped = (
|
||||
file: string,
|
||||
max = MAX_MANIFEST_BYTES,
|
||||
): string | undefined => {
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (!st.isFile() || st.size === 0 || st.size > max) return undefined;
|
||||
return fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Read a file as bytes, refusing anything over `max`. Same posture as {@link readTextCapped}. */
|
||||
export const readBytesCapped = (
|
||||
file: string,
|
||||
max = MAX_CACHE_BYTES,
|
||||
): Uint8Array | undefined => {
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (!st.isFile() || st.size === 0 || st.size > max) return undefined;
|
||||
return new Uint8Array(fs.readFileSync(file));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Read + `JSON.parse` a capped text file. `undefined` on any read or parse failure. */
|
||||
export const readJsonCapped = <T = unknown>(
|
||||
file: string,
|
||||
max = MAX_MANIFEST_BYTES,
|
||||
): T | undefined => {
|
||||
const text = readTextCapped(file, max);
|
||||
if (text === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** List a directory's entry names, or `[]` if it isn't readable. */
|
||||
export const listDir = (dir: string): string[] => {
|
||||
try {
|
||||
return fs.readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Does this path exist as a directory? */
|
||||
export const isDir = (p: string): boolean => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Does this path exist as a regular, non-empty file? */
|
||||
export const isFile = (p: string): boolean => {
|
||||
try {
|
||||
const st = fs.statSync(p);
|
||||
return st.isFile() && st.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Join `rel` onto `base` **only if it cannot escape** — the port of the host's `confined_join`
|
||||
* (gog.rs), which exists because a crafted `goggame-<id>.info` could otherwise point a play task's
|
||||
* exe at an arbitrary program (security-review 2026-07-17).
|
||||
*
|
||||
* Refuses any relative path carrying a drive prefix (`C:`), a root (`/` or `\`), or a `..`
|
||||
* component — each of which `path.join` would let REPLACE or climb out of `base`. `undefined` ⇒
|
||||
* out of bounds, and the caller must refuse the launch rather than fall back to something plausible.
|
||||
*/
|
||||
export const confinedJoin = (base: string, rel: string): string | undefined => {
|
||||
if (rel === "") return undefined;
|
||||
// Normalize separators so a Windows-shaped relative path is checked on any platform (a plugin
|
||||
// may parse a Windows manifest while its tests run on Linux).
|
||||
const parts = rel.split(/[\\/]/);
|
||||
if (parts[0] === "" ) return undefined; // rooted
|
||||
if (/^[A-Za-z]:$/.test(parts[0])) return undefined; // drive prefix
|
||||
if (parts.some((p) => p === "..")) return undefined; // traversal
|
||||
const joined = path.join(base, ...parts.filter((p) => p !== "" && p !== "."));
|
||||
// Belt and braces: the component check above is the real guard, but a symlink-free string check
|
||||
// costs nothing and catches anything the split missed.
|
||||
const rootWithSep = base.endsWith(path.sep) ? base : base + path.sep;
|
||||
return joined === base || joined.startsWith(rootWithSep) ? joined : undefined;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
// The one outbound-HTTP helper a library plugin should use, carrying the host's `fetch_image`
|
||||
// posture verbatim (art.rs): http(s) only, **no redirects**, a size cap, and a short timeout.
|
||||
//
|
||||
// The no-redirect rule is the important one and it is not paranoia: a scanner fetches URLs it read
|
||||
// out of a launcher's cache — data the plugin did not author. A `3xx` chased automatically is an
|
||||
// SSRF pivot from a process running on the operator's box (`http://169.254.169.254/…`, an internal
|
||||
// service). The host learned this in the 2026-07-17 security review; a plugin fetching the same
|
||||
// class of URL inherits the same rule. A rare legitimately-redirecting CDN just yields no art.
|
||||
import { HostRequestError } from "../../errors.js";
|
||||
import { Effect } from "effect";
|
||||
|
||||
export interface FetchLimits {
|
||||
/** Hard cap on the response body. Default 8 MiB — a cover never approaches it. */
|
||||
readonly maxBytes?: number;
|
||||
/** Wall-clock timeout in ms. Default 10 000. */
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX = 8 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT = 10_000;
|
||||
|
||||
export interface FetchedBytes {
|
||||
readonly bytes: Uint8Array;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET an `http(s)` URL under the posture above. Fails with {@link HostRequestError} on any non-2xx,
|
||||
* a redirect, an over-cap body, a timeout, or a non-http(s) scheme.
|
||||
*
|
||||
* Most scanners never need this: they emit CDN URLs and let the CLIENT fetch them, which is both
|
||||
* faster and keeps the host out of the loop. Reach for it only when a store's art requires an API
|
||||
* lookup the client cannot do (GOG's product API, Microsoft's display catalog).
|
||||
*/
|
||||
export const fetchBytes = (
|
||||
url: string,
|
||||
limits: FetchLimits = {},
|
||||
): Effect.Effect<FetchedBytes, HostRequestError> =>
|
||||
Effect.tryPromise({
|
||||
try: async (): Promise<FetchedBytes> => {
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
throw new Error("only http(s) URLs may be fetched");
|
||||
}
|
||||
const maxBytes = limits.maxBytes ?? DEFAULT_MAX;
|
||||
const signal = AbortSignal.timeout(limits.timeoutMs ?? DEFAULT_TIMEOUT);
|
||||
// `redirect: "manual"` rather than "error": we want to SEE the 3xx and report it as a
|
||||
// refusal, not have fetch throw something opaque.
|
||||
const res = await fetch(url, { redirect: "manual", signal });
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
throw new Error(`refusing to follow a ${res.status} redirect`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
// Trust Content-Length when it is there (cheap rejection), but still bound the read: a
|
||||
// hostile server can lie about it or omit it entirely.
|
||||
const declared = Number(res.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
throw new Error(`body larger than ${maxBytes} bytes`);
|
||||
}
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
if (buf.byteLength === 0) throw new Error("empty body");
|
||||
if (buf.byteLength > maxBytes) {
|
||||
throw new Error(`body larger than ${maxBytes} bytes`);
|
||||
}
|
||||
return {
|
||||
bytes: buf,
|
||||
contentType: res.headers.get("content-type") ?? "image/jpeg",
|
||||
};
|
||||
},
|
||||
catch: (cause) =>
|
||||
new HostRequestError({
|
||||
method: "GET",
|
||||
path: url,
|
||||
cause,
|
||||
}),
|
||||
});
|
||||
|
||||
/** {@link fetchBytes}, JSON-decoded. Same posture; use for a store's public product API. */
|
||||
export const fetchJson = <T = unknown>(
|
||||
url: string,
|
||||
limits: FetchLimits = {},
|
||||
): Effect.Effect<T, HostRequestError> =>
|
||||
fetchBytes(url, limits).pipe(
|
||||
Effect.flatMap((r) =>
|
||||
Effect.try({
|
||||
try: () => JSON.parse(new TextDecoder().decode(r.bytes)) as T,
|
||||
catch: (cause) =>
|
||||
new HostRequestError({
|
||||
method: "GET",
|
||||
path: url,
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
// The launcher-file parsing toolkit: what the six in-host scanners hand-rolled, hoisted so a
|
||||
// library plugin is its scan function and nothing else.
|
||||
//
|
||||
// Everything here is total — a missing launcher, a truncated file, a schema drift in a launcher
|
||||
// upgrade all degrade to "no titles from this source", never to a thrown error. A scanner that dies
|
||||
// on one odd file takes the user's whole library with it.
|
||||
export {
|
||||
ART_KINDS,
|
||||
type ArtKind,
|
||||
fileUrl,
|
||||
findGridArtFile,
|
||||
findLocalArtFile,
|
||||
gridFilenames,
|
||||
steamCdnUrl,
|
||||
} from "./art.js";
|
||||
export {
|
||||
confinedJoin,
|
||||
isDir,
|
||||
isFile,
|
||||
listDir,
|
||||
MAX_CACHE_BYTES,
|
||||
MAX_MANIFEST_BYTES,
|
||||
readBytesCapped,
|
||||
readJsonCapped,
|
||||
readTextCapped,
|
||||
} from "./fs.js";
|
||||
export {
|
||||
type FetchedBytes,
|
||||
type FetchLimits,
|
||||
fetchBytes,
|
||||
fetchJson,
|
||||
} from "./http.js";
|
||||
export {
|
||||
parseRegQuery,
|
||||
regQueryValue,
|
||||
regQueryValues,
|
||||
regSubKeys,
|
||||
type RegValue,
|
||||
validRegKey,
|
||||
} from "./registry.js";
|
||||
export { openReadOnly, type ReadOnlyDb, withReadOnlyDb } from "./sqlite.js";
|
||||
export {
|
||||
crc32,
|
||||
parseShortcuts,
|
||||
type Shortcut,
|
||||
shortcutAppId,
|
||||
shortcutGameId,
|
||||
} from "./shortcuts.js";
|
||||
export {
|
||||
steamLibraryDirs,
|
||||
steamRoots,
|
||||
steamUserConfigDirs,
|
||||
} from "./steam-root.js";
|
||||
export {
|
||||
type AppManifest,
|
||||
isSteamTool,
|
||||
parseAppManifest,
|
||||
vdfField,
|
||||
vdfPaths,
|
||||
vdfValue,
|
||||
} from "./vdf.js";
|
||||
@@ -0,0 +1,94 @@
|
||||
// Windows registry reads by spawning `reg.exe query` — dependency-free, and (the part that
|
||||
// matters) it works from the scripting runner's LocalService account.
|
||||
//
|
||||
// **HKLM only, by design.** The runner runs as `NT AUTHORITY\LocalService` on Windows, which has no
|
||||
// user profile: HKCU is not the operator's hive there, it is LocalService's own — so a plugin that
|
||||
// read HKCU would silently see an empty registry rather than the user's launcher config. Every
|
||||
// launcher fact a scanner needs (Steam's InstallPath, GOG's game list) lives under HKLM
|
||||
// `WOW6432Node` anyway. Asking for HKCU is a bug, so this refuses it outright.
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
/** One `reg.exe query` value row. */
|
||||
export interface RegValue {
|
||||
readonly name: string;
|
||||
/** `REG_SZ`, `REG_DWORD`, … */
|
||||
readonly type: string;
|
||||
readonly data: string;
|
||||
}
|
||||
|
||||
const HKLM = "HKLM\\";
|
||||
|
||||
/** Is this a key path this module will touch? See the module docs on why HKLM only. */
|
||||
export const validRegKey = (key: string): boolean =>
|
||||
key.startsWith(HKLM) &&
|
||||
key.length > HKLM.length &&
|
||||
key.length <= 260 &&
|
||||
!key.includes("..") &&
|
||||
// `reg.exe` takes the key as one argv element (no shell), but keep the charset tame anyway so a
|
||||
// malformed key can never turn into a switch.
|
||||
!key.startsWith("/") &&
|
||||
!/[\r\n\0"]/.test(key);
|
||||
|
||||
const run = (args: string[]): string | undefined => {
|
||||
if (process.platform !== "win32") return undefined;
|
||||
const r = spawnSync("reg.exe", args, {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
// A registry read is instant; a hang means something is badly wrong and a scan must not
|
||||
// block on it forever.
|
||||
timeout: 10_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
});
|
||||
if (r.status !== 0 || typeof r.stdout !== "string") return undefined;
|
||||
return r.stdout;
|
||||
};
|
||||
|
||||
/**
|
||||
* The values directly under one HKLM key. `[]` when the key is absent, unreadable, or this is not
|
||||
* Windows — a missing launcher is the normal case, never an error.
|
||||
*/
|
||||
export const regQueryValues = (key: string): RegValue[] => {
|
||||
if (!validRegKey(key)) return [];
|
||||
const out = run(["query", key]);
|
||||
if (out === undefined) return [];
|
||||
return parseRegQuery(out);
|
||||
};
|
||||
|
||||
/** One named value under an HKLM key, or `undefined`. */
|
||||
export const regQueryValue = (key: string, name: string): string | undefined =>
|
||||
regQueryValues(key).find((v) => v.name.toLowerCase() === name.toLowerCase())
|
||||
?.data;
|
||||
|
||||
/** The immediate SUBKEY paths under one HKLM key (GOG lists one subkey per installed game). */
|
||||
export const regSubKeys = (key: string): string[] => {
|
||||
if (!validRegKey(key)) return [];
|
||||
const out = run(["query", key]);
|
||||
if (out === undefined) return [];
|
||||
const prefix = `${key.toLowerCase()}\\`;
|
||||
return out
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.toLowerCase().startsWith(prefix))
|
||||
.filter((l) => !l.slice(key.length + 1).includes("\\"));
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse `reg.exe query` output rows: ` <name> <TYPE> <data>`, separated by runs of
|
||||
* whitespace. Data may itself contain spaces (a path), so only the first two columns are split off.
|
||||
*
|
||||
* Exported for tests — the format is stable but this is exactly the kind of thing that quietly
|
||||
* breaks, and a plugin's tests can pin it without a Windows box.
|
||||
*/
|
||||
export const parseRegQuery = (stdout: string): RegValue[] => {
|
||||
const out: RegValue[] = [];
|
||||
for (const raw of stdout.split(/\r?\n/)) {
|
||||
// Value rows are indented; the key path header is not.
|
||||
if (!/^\s/.test(raw)) continue;
|
||||
const line = raw.trim();
|
||||
if (line === "") continue;
|
||||
const m = line.match(/^(.*?)\s{2,}(REG_[A-Z_]+)\s{2,}([\s\S]*)$/);
|
||||
if (!m) continue;
|
||||
out.push({ name: m[1], type: m[2], data: m[3] });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
// Steam's BINARY `shortcuts.vdf` — the user's "Add a Non-Steam Game to My Library" entries.
|
||||
//
|
||||
// Ported from the host's in-tree scanner (crates/punktfunk-host/src/library/steam.rs), together
|
||||
// with its unit tests, which are the real specification here: the format is undocumented, and the
|
||||
// two id derivations below (`shortcutAppId`, `shortcutGameId`) are the difference between a
|
||||
// shortcut that launches and one that silently does nothing.
|
||||
//
|
||||
// Format: a 1-byte type tag (`0x00` nested map, `0x01` string, `0x02` int32, `0x07` uint64), a
|
||||
// NUL-terminated key, then a type-specific payload; `0x08` closes the current map. The whole file is
|
||||
// one `shortcuts` map whose children (keyed "0", "1", …) are the individual shortcuts.
|
||||
//
|
||||
// Lenient and total by design: a truncated file or an unrecognized tag stops the walk and returns
|
||||
// whatever parsed so far. A user's shortcuts file is not something to be strict about.
|
||||
|
||||
export interface Shortcut {
|
||||
/** The 32-bit shortcut appid — always high-bit set. Keys the entry id and its `grid/` art. */
|
||||
readonly appid: number;
|
||||
readonly name: string;
|
||||
/** The shortcut's target, as Steam stores it (quoted, possibly with trailing arguments). */
|
||||
readonly exe: string;
|
||||
readonly hidden: boolean;
|
||||
}
|
||||
|
||||
/** A cursor over the buffer — the ported code's `pos` threaded explicitly. */
|
||||
interface Cursor {
|
||||
pos: number;
|
||||
}
|
||||
|
||||
/** Read a NUL-terminated UTF-8 string, advancing past the terminator. `undefined` if unterminated. */
|
||||
const readCStr = (buf: Uint8Array, c: Cursor): string | undefined => {
|
||||
const start = c.pos;
|
||||
let end = start;
|
||||
while (end < buf.length && buf[end] !== 0) end++;
|
||||
if (end >= buf.length) return undefined;
|
||||
const s = new TextDecoder("utf-8").decode(buf.subarray(start, end));
|
||||
c.pos = end + 1;
|
||||
return s;
|
||||
};
|
||||
|
||||
/** Read a little-endian int32, advancing 4 bytes. `undefined` if fewer than 4 remain. */
|
||||
const readI32 = (buf: Uint8Array, c: Cursor): number | undefined => {
|
||||
if (c.pos + 4 > buf.length) return undefined;
|
||||
const v = new DataView(buf.buffer, buf.byteOffset + c.pos, 4).getInt32(0, true);
|
||||
c.pos += 4;
|
||||
return v;
|
||||
};
|
||||
|
||||
/** Skip a nested map's contents (positioned just after its key) up to and including its `0x08`. */
|
||||
const skipMap = (buf: Uint8Array, c: Cursor): boolean => {
|
||||
for (;;) {
|
||||
if (c.pos >= buf.length) return false;
|
||||
const tag = buf[c.pos];
|
||||
c.pos += 1;
|
||||
if (tag === 0x08) return true;
|
||||
if (readCStr(buf, c) === undefined) return false;
|
||||
if (tag === 0x00) {
|
||||
if (!skipMap(buf, c)) return false;
|
||||
} else if (tag === 0x01) {
|
||||
if (readCStr(buf, c) === undefined) return false;
|
||||
} else if (tag === 0x02) {
|
||||
c.pos += 4;
|
||||
} else if (tag === 0x07) {
|
||||
c.pos += 8;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Parse one shortcut's fields (positioned just after its index key) up to the map-closing `0x08`. */
|
||||
const parseOne = (buf: Uint8Array, c: Cursor): Shortcut | undefined => {
|
||||
let appid: number | undefined;
|
||||
let name = "";
|
||||
let exe = "";
|
||||
let hidden = false;
|
||||
for (;;) {
|
||||
if (c.pos >= buf.length) return undefined;
|
||||
const tag = buf[c.pos];
|
||||
c.pos += 1;
|
||||
if (tag === 0x08) break;
|
||||
const key = readCStr(buf, c)?.toLowerCase();
|
||||
if (key === undefined) return undefined;
|
||||
if (tag === 0x00) {
|
||||
if (!skipMap(buf, c)) return undefined; // nested map (e.g. `tags`) — not needed
|
||||
} else if (tag === 0x01) {
|
||||
const val = readCStr(buf, c);
|
||||
if (val === undefined) return undefined;
|
||||
if (key === "appname") name = val;
|
||||
else if (key === "exe") exe = val;
|
||||
} else if (tag === 0x02) {
|
||||
const val = readI32(buf, c);
|
||||
if (val === undefined) return undefined;
|
||||
if (key === "appid") appid = val >>> 0;
|
||||
else if (key === "ishidden") hidden = val !== 0;
|
||||
} else if (tag === 0x07) {
|
||||
c.pos += 8; // uint64 — skip
|
||||
} else {
|
||||
return undefined; // unknown tag: payload size unknown, can't continue safely
|
||||
}
|
||||
}
|
||||
if (name.trim() === "") return undefined; // nothing worth showing
|
||||
// Prefer the stored appid; fall back to Steam's derivation when it's absent (0 / missing).
|
||||
const id = appid && appid !== 0 ? appid : shortcutAppId(exe, name);
|
||||
return { appid: id, name, exe, hidden };
|
||||
};
|
||||
|
||||
/** Parse a binary `shortcuts.vdf` into its shortcuts. Never throws. */
|
||||
export const parseShortcuts = (buf: Uint8Array): Shortcut[] => {
|
||||
const out: Shortcut[] = [];
|
||||
const c: Cursor = { pos: 0 };
|
||||
// Enter the top-level map (`<0x00> "shortcuts" <NUL>`); tolerate any key name.
|
||||
if (buf[0] !== 0x00) return out;
|
||||
c.pos = 1;
|
||||
if (readCStr(buf, c) === undefined) return out;
|
||||
while (c.pos < buf.length) {
|
||||
const tag = buf[c.pos];
|
||||
c.pos += 1;
|
||||
if (tag !== 0x00) break; // `0x08` (end of shortcuts) or anything unexpected
|
||||
if (readCStr(buf, c) === undefined) break; // the index key ("0", "1", …)
|
||||
const sc = parseOne(buf, c);
|
||||
if (!sc) break;
|
||||
out.push(sc);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Standard reflected (IEEE) CRC-32 — what Steam hashes a shortcut's `exe + name` with. */
|
||||
export const crc32 = (data: Uint8Array): number => {
|
||||
let crc = 0xffff_ffff;
|
||||
for (const byte of data) {
|
||||
crc ^= byte;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const mask = -(crc & 1);
|
||||
crc = (crc >>> 1) ^ (0xedb8_8320 & mask);
|
||||
}
|
||||
}
|
||||
return (~crc) >>> 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
|
||||
* high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
|
||||
* Steam writes it and the stored value is preferred.
|
||||
*
|
||||
* The high bit is load-bearing downstream: it is how a shortcut is told apart from a real store
|
||||
* appid, which is what makes the CDN art fetch skippable for shortcuts (they only ever have `grid/`
|
||||
* overrides).
|
||||
*/
|
||||
export const shortcutAppId = (exe: string, name: string): number =>
|
||||
(crc32(new TextEncoder().encode(exe + name)) | 0x8000_0000) >>> 0;
|
||||
|
||||
/**
|
||||
* The 64-bit game id `steam://rungameid/` needs in order to launch a non-Steam shortcut: high dword
|
||||
* = the 32-bit shortcut appid, low dword = the shortcut marker `0x02000000`.
|
||||
*
|
||||
* Handing `rungameid` the bare 32-bit appid does NOT launch a shortcut — it must be this composed
|
||||
* id. Returned as a decimal string because it exceeds 2^53 and would lose precision as a `number`.
|
||||
*/
|
||||
export const shortcutGameId = (appid: number): string =>
|
||||
((BigInt(appid >>> 0) << 32n) | 0x0200_0000n).toString();
|
||||
@@ -0,0 +1,68 @@
|
||||
// Read-only SQLite over `bun:sqlite` — for launcher databases a plugin must never disturb.
|
||||
//
|
||||
// Lutris' `pga.db` is the motivating case: it belongs to a running application, and a scanner that
|
||||
// opened it read-write could take a write lock, create `-wal`/`-shm` sidecars next to it, or (worst
|
||||
// case) be blamed for a corrupted library. `immutable=1` promises the file will not change while
|
||||
// open, which makes Bun skip locking entirely — the strictest possible "look, don't touch".
|
||||
import { Database } from "bun:sqlite";
|
||||
import { isFile } from "./fs.js";
|
||||
|
||||
export interface ReadOnlyDb {
|
||||
/** Run a query and return its rows. Returns `[]` rather than throwing on a bad query. */
|
||||
readonly query: <T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
...params: unknown[]
|
||||
) => T[];
|
||||
readonly close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a launcher database read-only and immutably. `undefined` if the file is absent or not a
|
||||
* database — the normal "this launcher isn't installed" case, not an error.
|
||||
*
|
||||
* Always `close()` when done (or use {@link withReadOnlyDb}, which does it for you).
|
||||
*/
|
||||
export const openReadOnly = (file: string): ReadOnlyDb | undefined => {
|
||||
if (!isFile(file)) return undefined;
|
||||
let db: Database;
|
||||
try {
|
||||
// `readonly` alone still takes locks and can spawn WAL sidecars; `immutable=1` is what makes
|
||||
// this a pure read. It is safe here precisely because a scan is a point-in-time snapshot —
|
||||
// if the launcher writes mid-scan we simply pick it up on the next sync.
|
||||
db = new Database(`file:${encodeURI(file)}?immutable=1`, { readonly: true });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
query: <T = Record<string, unknown>>(sql: string, ...params: unknown[]) => {
|
||||
try {
|
||||
return db.query(sql).all(...(params as never[])) as T[];
|
||||
} catch {
|
||||
// A schema drift (a renamed column in a launcher upgrade) must degrade to "no
|
||||
// titles from this source", never take the whole plugin down.
|
||||
return [] as T[];
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Open, use, and always close. Returns `undefined` when the database isn't there. */
|
||||
export const withReadOnlyDb = <T>(
|
||||
file: string,
|
||||
use: (db: ReadOnlyDb) => T,
|
||||
): T | undefined => {
|
||||
const db = openReadOnly(file);
|
||||
if (!db) return undefined;
|
||||
try {
|
||||
return use(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// Where Steam lives on this host, and which `steamapps` dirs hold installed titles.
|
||||
//
|
||||
// Ported from the host scanner (steam.rs `steam_roots` / `steam_library_dirs`) with one deliberate
|
||||
// addition and one deliberate exclusion, both about the Windows runner's account:
|
||||
//
|
||||
// * ADDED: HKLM `WOW6432Node\Valve\Steam\InstallPath`, so a non-default Steam install dir is
|
||||
// found. The host scanner never covered this (it relied on an explorer.exe protocol fallback at
|
||||
// launch time), but a plugin that can't find the root finds no games at all.
|
||||
// * EXCLUDED: HKCU `Software\Valve\Steam`. The runner is LocalService, whose HKCU is its own empty
|
||||
// hive, not the operator's — reading it would look like "Steam isn't installed".
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { isDir, listDir, readTextCapped } from "./fs.js";
|
||||
import { regQueryValue } from "./registry.js";
|
||||
import { vdfPaths } from "./vdf.js";
|
||||
|
||||
/** Canonicalize-ish: resolve and drop a trailing separator so dedup is reliable. */
|
||||
const norm = (p: string): string => path.resolve(p);
|
||||
|
||||
/**
|
||||
* Candidate Steam roots that actually exist (have a `steamapps` dir), deduped.
|
||||
*
|
||||
* A "root" is the Steam install itself — `userdata/`, `appcache/` and the first `steamapps/` live
|
||||
* under it. Extra library folders on other drives are NOT roots; see {@link steamLibraryDirs}.
|
||||
*/
|
||||
export const steamRoots = (): string[] => {
|
||||
const candidates: string[] = [];
|
||||
if (process.platform === "win32") {
|
||||
for (const v of ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"]) {
|
||||
const pf = process.env[v];
|
||||
if (pf) candidates.push(path.join(pf, "Steam"));
|
||||
}
|
||||
// The registry install path — covers a Steam installed somewhere other than Program Files.
|
||||
for (const key of [
|
||||
"HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam",
|
||||
"HKLM\\SOFTWARE\\Valve\\Steam",
|
||||
]) {
|
||||
const p = regQueryValue(key, "InstallPath");
|
||||
if (p) candidates.push(p);
|
||||
}
|
||||
} else {
|
||||
const home = os.homedir();
|
||||
if (home) {
|
||||
candidates.push(
|
||||
path.join(home, ".local/share/Steam"),
|
||||
path.join(home, ".steam/steam"),
|
||||
path.join(home, ".steam/root"),
|
||||
// Flatpak Steam
|
||||
path.join(home, ".var/app/com.valvesoftware.Steam/.local/share/Steam"),
|
||||
);
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const roots: string[] = [];
|
||||
for (const c of candidates) {
|
||||
const n = norm(c);
|
||||
if (!seen.has(n) && isDir(path.join(n, "steamapps"))) {
|
||||
seen.add(n);
|
||||
roots.push(n);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every `steamapps` dir holding installed titles: each root's own, plus the extra library folders
|
||||
* listed in its `libraryfolders.vdf` (Steam installs to other drives).
|
||||
*/
|
||||
export const steamLibraryDirs = (roots = steamRoots()): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const dirs: string[] = [];
|
||||
const push = (p: string) => {
|
||||
const n = norm(p);
|
||||
if (!seen.has(n) && isDir(n)) {
|
||||
seen.add(n);
|
||||
dirs.push(n);
|
||||
}
|
||||
};
|
||||
for (const root of roots) {
|
||||
const steamapps = path.join(root, "steamapps");
|
||||
const text = readTextCapped(path.join(steamapps, "libraryfolders.vdf"));
|
||||
if (text !== undefined) {
|
||||
for (const p of vdfPaths(text)) push(path.join(p, "steamapps"));
|
||||
}
|
||||
push(steamapps);
|
||||
}
|
||||
return dirs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every `userdata/<accountId>/config` dir across all roots — one per Steam account that has signed
|
||||
* in on this host. `shortcuts.vdf` and the `grid/` art overrides live here.
|
||||
*/
|
||||
export const steamUserConfigDirs = (roots = steamRoots()): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const root of roots) {
|
||||
const userdata = path.join(root, "userdata");
|
||||
for (const acct of listDir(userdata)) {
|
||||
const cfg = path.join(userdata, acct, "config");
|
||||
if (isDir(cfg)) out.push(cfg);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
// Valve Data Format (text) — the flat-field reader Steam's `libraryfolders.vdf` and
|
||||
// `appmanifest_<appid>.acf` need, ported from the host's in-tree scanner
|
||||
// (crates/punktfunk-host/src/library/steam.rs `vdf_value` / `vdf_paths` / `scan_manifests`).
|
||||
//
|
||||
// Deliberately NOT a full VDF parser. Every field these files expose that a library plugin cares
|
||||
// about sits on one line as `"key" "value"`, and a real parser would be a much larger surface to
|
||||
// keep correct against a format Valve changes without notice. If you need nested values, read the
|
||||
// file yourself — this is the 90% case, kept small enough to be obviously right.
|
||||
|
||||
/** `"<key>" "<value>"` on a single line → `<value>`. Whitespace between the two is arbitrary. */
|
||||
export const vdfValue = (line: string, key: string): string | undefined => {
|
||||
const rest = line.trimStart();
|
||||
const prefix = `"${key}"`;
|
||||
if (!rest.startsWith(prefix)) return undefined;
|
||||
const after = rest.slice(prefix.length);
|
||||
const open = after.indexOf('"');
|
||||
if (open === -1) return undefined;
|
||||
const value = after.slice(open + 1);
|
||||
const close = value.indexOf('"');
|
||||
if (close === -1) return undefined;
|
||||
return value.slice(0, close);
|
||||
};
|
||||
|
||||
/** The first `"<key>" "<value>"` anywhere in a multi-line document. */
|
||||
export const vdfField = (text: string, key: string): string | undefined => {
|
||||
for (const line of text.split("\n")) {
|
||||
const v = vdfValue(line, key);
|
||||
if (v !== undefined) return v;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every `"path" "<dir>"` value in a `libraryfolders.vdf` — the extra drives Steam installs to.
|
||||
*
|
||||
* On Windows the values are backslash-escaped (`D:\\SteamLibrary`), so `\\` collapses to `\`. POSIX
|
||||
* paths need no unescaping, and the collapse is harmless there (a literal `\\` in a Linux path is
|
||||
* vanishingly rare and was already ambiguous).
|
||||
*/
|
||||
export const vdfPaths = (text: string): string[] =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((l) => vdfValue(l, "path"))
|
||||
.filter((p): p is string => p !== undefined)
|
||||
.map((p) => p.replaceAll("\\\\", "\\"));
|
||||
|
||||
/** One installed title as described by its `appmanifest_<appid>.acf`. */
|
||||
export interface AppManifest {
|
||||
readonly appid: number;
|
||||
readonly name: string;
|
||||
/** The bare folder name under this library's `common/` — resolve it yourself. */
|
||||
readonly installdir?: string;
|
||||
}
|
||||
|
||||
/** Parse an `.acf` manifest's flat fields. `undefined` when it carries no usable appid+name. */
|
||||
export const parseAppManifest = (text: string): AppManifest | undefined => {
|
||||
const appid = Number(vdfField(text, "appid"));
|
||||
const name = vdfField(text, "name");
|
||||
if (!Number.isInteger(appid) || appid <= 0 || !name) return undefined;
|
||||
const installdir = vdfField(text, "installdir");
|
||||
return installdir ? { appid, name, installdir } : { appid, name };
|
||||
};
|
||||
|
||||
/**
|
||||
* Steam installs runtimes and redistributables as "apps" too. A *game* library must not list them.
|
||||
* Ported verbatim from the host scanner so an extracted steam plugin filters identically — the
|
||||
* parity harness compares entry sets, and a stray Proton row would fail it.
|
||||
*/
|
||||
export const isSteamTool = (appid: number, name: string): boolean => {
|
||||
// Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier).
|
||||
const TOOL_IDS = [228980, 1070560, 1391110, 1628350, 1493710];
|
||||
if (TOOL_IDS.includes(appid)) return true;
|
||||
const n = name.toLowerCase();
|
||||
return (
|
||||
n.includes("proton") ||
|
||||
n.startsWith("steam linux runtime") ||
|
||||
n.includes("steamworks common") ||
|
||||
n.includes("steamvr")
|
||||
);
|
||||
};
|
||||
@@ -9,13 +9,36 @@ import type { ProviderEntry } from "./wire.js";
|
||||
|
||||
export * from "./wire.js";
|
||||
|
||||
/** What the host echoed back for one reconciled entry — enough to tell whether a claim took. */
|
||||
export interface ReconciledEntry {
|
||||
readonly id: string;
|
||||
readonly external_id?: string;
|
||||
/** The store badge the host assigned: the claim when it honoured one, else `"custom"`. */
|
||||
readonly store?: string;
|
||||
}
|
||||
|
||||
export interface ProviderClientService {
|
||||
/** Full-replace reconcile: PUT the desired set; the host diffs by `external_id`. */
|
||||
/**
|
||||
* Full-replace reconcile: PUT the desired set; the host diffs by `external_id`.
|
||||
*
|
||||
* `store` claims that store for this provider (design D2), which is what makes the entries carry
|
||||
* the store's own identity — deterministic `<store>:<external_id>` ids instead of opaque
|
||||
* `custom:<id>` ones, the store's badge, and suppression of the host's matching built-in scanner
|
||||
* so the two never double-list. One provider per store: a second claimant gets a 409.
|
||||
*
|
||||
* Returns the host's echoed entries so a caller can verify the claim actually took — a host
|
||||
* predating claims ignores the query parameter silently, and the only way to notice is that the
|
||||
* entries come back as `custom`.
|
||||
*/
|
||||
readonly reconcile: (
|
||||
providerId: string,
|
||||
entries: ReadonlyArray<ProviderEntry>,
|
||||
) => Effect.Effect<void, HostRequestError>;
|
||||
/** Remove every entry this provider owns (the explicit-uninstall path). */
|
||||
store?: string,
|
||||
) => Effect.Effect<ReadonlyArray<ReconciledEntry>, HostRequestError>;
|
||||
/**
|
||||
* Remove every entry this provider owns **and release its store claim** (the explicit-uninstall
|
||||
* path). Releasing is what brings the host's built-in scanner back.
|
||||
*/
|
||||
readonly remove: (providerId: string) => Effect.Effect<void, HostRequestError>;
|
||||
}
|
||||
|
||||
@@ -28,10 +51,25 @@ export class ProviderClient extends Context.Service<
|
||||
Effect.gen(function* () {
|
||||
const host = yield* HostClient;
|
||||
return {
|
||||
reconcile: (providerId, entries) =>
|
||||
reconcile: (providerId, entries, store) =>
|
||||
host
|
||||
.request("PUT", `/library/provider/${providerId}`, entries)
|
||||
.pipe(Effect.asVoid),
|
||||
.request(
|
||||
"PUT",
|
||||
`/library/provider/${providerId}${
|
||||
store ? `?store=${encodeURIComponent(store)}` : ""
|
||||
}`,
|
||||
entries,
|
||||
)
|
||||
.pipe(
|
||||
// The host answers with its resulting entries. An older host may answer
|
||||
// with something else, so treat a non-array as "no echo" rather than
|
||||
// failing the sync.
|
||||
Effect.map((body) =>
|
||||
Array.isArray(body)
|
||||
? (body as ReadonlyArray<ReconciledEntry>)
|
||||
: [],
|
||||
),
|
||||
),
|
||||
remove: (providerId) =>
|
||||
host
|
||||
.request("DELETE", `/library/provider/${providerId}`)
|
||||
|
||||
+130
-3
@@ -3,8 +3,9 @@
|
||||
// register/renew/deregister through Scope. Validated end-to-end by the phase-0 spike:
|
||||
// core-only env layers, no platform package, SPA fallthrough preserved.
|
||||
import { type PluginUiHandle, servePluginUi } from "@punktfunk/host";
|
||||
import { Effect, FileSystem, Layer, Path, Scope } from "effect";
|
||||
import { Effect, FileSystem, Layer, Path, Schema, Scope } from "effect";
|
||||
import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http";
|
||||
import type { ConfigService } from "./config.js";
|
||||
import { UiServeError } from "./errors.js";
|
||||
import { HostClient, PluginInfo } from "./host-client.js";
|
||||
|
||||
@@ -17,6 +18,100 @@ export const httpApiEnv = Layer.provideMerge(
|
||||
FileSystem.layerNoop({}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Derive a JSON Schema for a config schema, for the console's generic settings form.
|
||||
*
|
||||
* Returns `null` when derivation isn't possible, which the console reads as "render the raw JSON
|
||||
* editor instead" — the fallback that bounds this whole feature's risk.
|
||||
*
|
||||
* Authoring rules, verified against effect 4.0.0-beta.99 and pinned by
|
||||
* `test/library-config.test.ts` — if an effect upgrade changes any of them, that test fails:
|
||||
*
|
||||
* * Use `Schema.Finite` / `Schema.Int`, **never `Schema.Number`** — Number's *encoded* form admits
|
||||
* the strings `"NaN"`/`"Infinity"`/`"-Infinity"`, so it derives a four-way `anyOf` that no sane
|
||||
* form can render as a number input.
|
||||
* * A decoding default is an **Effect**: `withDecodingDefaultKey(Effect.succeed(true), …)`. Passing
|
||||
* a bare thunk (`() => true`) still derives a schema and still type-checks, then dies at DECODE
|
||||
* time with "Not a valid effect" — deriving is not evidence that the schema works.
|
||||
* * Annotate every field: `.annotate({ title, description, default })`. The derivation does NOT
|
||||
* infer `default` from `withDecodingDefaultKey`, so an un-annotated field shows no placeholder.
|
||||
* * A *checked* schema (`Schema.Int`, or anything with `.check(...)`) nests its annotations and
|
||||
* constraints under `allOf`, so a form must merge those branches, not read only the top level.
|
||||
* * `Schema.Literals([...])` derives a clean `enum` — prefer it over a union of strings. A union of
|
||||
* non-literals derives an `anyOf`, which is the JSON-editor fallback case.
|
||||
* * Fields carrying `withDecodingDefaultKey(..., { encodingStrategy: "omit" })` correctly drop out
|
||||
* of `required`, which is what keeps the raw file free of baked-in defaults.
|
||||
*/
|
||||
export const deriveConfigJsonSchema = (
|
||||
schema: Schema.Top,
|
||||
): Record<string, unknown> | null => {
|
||||
try {
|
||||
const doc = Schema.toJsonSchemaDocument(schema as never);
|
||||
return doc as unknown as Record<string, unknown>;
|
||||
} catch {
|
||||
// A schema shape the derivation can't express (a transform, a recursive ref). The console
|
||||
// falls back to the JSON editor; the PUT still validates by decode, so nothing is lost but
|
||||
// the pretty form.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** The plugin config surface the console's settings drawer drives. */
|
||||
export interface ServeUiConfig<S extends Schema.Top> {
|
||||
/** The schema the raw file is validated against, and the form is derived from. */
|
||||
readonly schema: S;
|
||||
/** The config service (from `makeConfigService`) holding the raw round-trip semantics. */
|
||||
readonly service: ConfigService<S>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/__config` request handler, split out so it can be driven directly in tests (the wire shape
|
||||
* is the contract the console's settings drawer codes against — it deserves a real round-trip test,
|
||||
* not a mock).
|
||||
*
|
||||
* `ConfigService`'s effects are context-free by construction (the `PluginInfo` was resolved when the
|
||||
* service was built), so this runs them straight from a plain async handler.
|
||||
*/
|
||||
export const makeConfigHandler = <S extends Schema.Top>(
|
||||
cfg: ServeUiConfig<S>,
|
||||
): ((req: Request) => Promise<Response>) => {
|
||||
// The derivation is stable for the life of the process — do it once, not per request.
|
||||
const schema = deriveConfigJsonSchema(cfg.schema);
|
||||
return async (req: Request): Promise<Response> => {
|
||||
if (req.method === "GET") {
|
||||
// A config file that fails to decode must not blank the whole drawer — answer with a
|
||||
// null value so the operator can still see (and replace) what is on disk.
|
||||
const value = await Effect.runPromise(cfg.service.loadRaw).catch(
|
||||
() => null,
|
||||
);
|
||||
return Response.json({ schema, value });
|
||||
}
|
||||
if (req.method === "PUT") {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch (cause) {
|
||||
return Response.json(
|
||||
{ error: "body must be JSON", issue: String(cause) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
// Validate-by-decode, persist RAW: `saveRaw` refuses a body the schema rejects and
|
||||
// never writes decoded defaults back into the operator's file.
|
||||
await Effect.runPromise(cfg.service.saveRaw(body));
|
||||
return Response.json({ ok: true });
|
||||
} catch (cause) {
|
||||
return Response.json(
|
||||
{ error: "config rejected", issue: String(cause) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return new Response("method not allowed", { status: 405 });
|
||||
};
|
||||
};
|
||||
|
||||
export interface ServeUiOptions {
|
||||
/** Console nav title. */
|
||||
readonly title: string;
|
||||
@@ -26,12 +121,33 @@ export interface ServeUiOptions {
|
||||
readonly version?: string;
|
||||
/** Built SPA directory (served with SPA fallback by the SDK). */
|
||||
readonly staticDir?: string | URL;
|
||||
/**
|
||||
* What kind of plugin this is (`[a-z][a-z0-9-]{0,31}`). `"library"` keeps the plugin out of the
|
||||
* console nav — its entry point is the Library section's Game sources surface instead.
|
||||
*/
|
||||
readonly category?: string;
|
||||
/**
|
||||
* Serve `GET`/`PUT /__config` for the console's **generic settings form**, so a plugin with
|
||||
* settings does not need to ship an SPA at all.
|
||||
*
|
||||
* `GET` answers `{schema, value}` — the derived JSON Schema (or `null`) and the raw,
|
||||
* operator-authored config. `PUT` validates by decoding the body against the schema and, only
|
||||
* then, persists it **raw**; defaults are never baked into the file. A rejected body comes back
|
||||
* 400 with the decode issue.
|
||||
*
|
||||
* Auth is the existing per-boot UI secret — the console reaches this through its session-gated
|
||||
* `/plugin-ui/<id>/…` proxy, so there is no new host surface and nothing new exposed to the LAN.
|
||||
*/
|
||||
readonly config?: ServeUiConfig<Schema.Top>;
|
||||
/**
|
||||
* The plugin API: `HttpApiBuilder.layer(api)` + group handler layers + raw routes
|
||||
* (e.g. `sseRoute`), with plugin services already provided. `httpApiEnv` is provided
|
||||
* here — only `HttpRouter` may remain open.
|
||||
*
|
||||
* Optional: a plugin whose only surface is `__config` (every library scanner) serves no API of
|
||||
* its own, and omitting this leaves an empty router that 404s under `apiPrefix`.
|
||||
*/
|
||||
readonly api: Layer.Layer<never, never, HttpRouter.HttpRouter>;
|
||||
readonly api?: Layer.Layer<never, never, HttpRouter.HttpRouter>;
|
||||
/** Path prefix owned by the API handler (default "/api/"). */
|
||||
readonly apiPrefix?: string;
|
||||
}
|
||||
@@ -54,14 +170,22 @@ export const serveUi = (
|
||||
const prefix = opts.apiPrefix ?? "/api/";
|
||||
|
||||
const { handler, dispose } = HttpRouter.toWebHandler(
|
||||
Layer.provide(opts.api, httpApiEnv),
|
||||
Layer.provide(opts.api ?? Layer.empty, httpApiEnv),
|
||||
);
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => dispose()).pipe(Effect.ignore),
|
||||
);
|
||||
|
||||
const serveConfig = opts.config ? makeConfigHandler(opts.config) : undefined;
|
||||
|
||||
const fetch = async (req: Request): Promise<Response | undefined> => {
|
||||
const url = new URL(req.url);
|
||||
// `__`-prefixed paths are the kit/SDK's own contract surface (`__health` lives in the
|
||||
// SDK), deliberately checked BEFORE the API prefix and before any static asset so a
|
||||
// plugin's own routes can never shadow them.
|
||||
if (url.pathname === "/__config") {
|
||||
return serveConfig?.(req) ?? new Response("not found", { status: 404 });
|
||||
}
|
||||
if (!url.pathname.startsWith(prefix)) return undefined; // → static SPA
|
||||
return handler(req);
|
||||
};
|
||||
@@ -79,6 +203,9 @@ export const serveUi = (
|
||||
...(opts.staticDir !== undefined
|
||||
? { staticDir: opts.staticDir }
|
||||
: {}),
|
||||
...(opts.category !== undefined
|
||||
? { category: opts.category }
|
||||
: {}),
|
||||
fetch,
|
||||
}),
|
||||
catch: (cause) => new UiServeError({ cause }),
|
||||
|
||||
+57
-1
@@ -12,12 +12,44 @@ export const Artwork = Schema.Struct({
|
||||
});
|
||||
export type Artwork = typeof Artwork.Type;
|
||||
|
||||
/**
|
||||
* How the host should launch a title. **The host owns this vocabulary** — it validates the value
|
||||
* per kind and builds the actual URI / command line itself, so a plugin only ever supplies a
|
||||
* validated value, never a command. That is the security invariant behind the whole provider lane:
|
||||
* a client sends an entry id, and the host resolves what to run.
|
||||
*
|
||||
* `kind` is a plain string rather than a union so the kit never has to ship a release to keep up
|
||||
* with a host that grew a new kind. The kinds the host understands today:
|
||||
*
|
||||
* | kind | value | platforms |
|
||||
* |---|---|---|
|
||||
* | `command` | a shell command (operator-trust tier) | both |
|
||||
* | `steam_appid` | digits — an appid, or a 64-bit non-Steam-shortcut game id | both |
|
||||
* | `steam_ui` | `bigpicture` \| `desktop` — opens the Steam client itself | both |
|
||||
* | `lutris_id` | digits — a pga.db game id | linux |
|
||||
* | `heroic` | `<runner>:<appName>`, runner ∈ legendary/gog/nile | linux |
|
||||
* | `epic` | `<namespace>:<catalogItemId>:<appName>` or a bare appName | windows |
|
||||
* | `gog` | `exe \t args \t workdir` | windows |
|
||||
* | `aumid` | `<PFN>!<AppId>` | windows |
|
||||
*
|
||||
* An unknown kind is accepted on the wire and simply yields no launch recipe on that host, so a
|
||||
* plugin targeting a newer host degrades to an unlaunchable tile rather than a failed reconcile.
|
||||
*/
|
||||
export const LaunchSpec = Schema.Struct({
|
||||
kind: Schema.Literal("command"),
|
||||
kind: Schema.String,
|
||||
value: Schema.String,
|
||||
});
|
||||
export type LaunchSpec = typeof LaunchSpec.Type;
|
||||
|
||||
/**
|
||||
* Whether an entry is an ordinary title or the launcher application itself (Steam Big Picture,
|
||||
* Heroic, Playnite fullscreen). Launcher entries launch, lease and list exactly like games; a
|
||||
* console or client that knows the field groups them into their own rail, and one that doesn't
|
||||
* renders them as plain tiles.
|
||||
*/
|
||||
export const GameRole = Schema.Literals(["game", "launcher"]);
|
||||
export type GameRole = typeof GameRole.Type;
|
||||
|
||||
export const PrepStep = Schema.Struct({
|
||||
do: Schema.String,
|
||||
undo: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||
@@ -43,6 +75,28 @@ export const DetectHint = Schema.Struct({
|
||||
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)),
|
||||
/**
|
||||
* The Steam appid, for a title Steam itself installed. On Linux this is the **sharpest** signal
|
||||
* there is: Steam wraps every launch — native or Proton — in `reaper SteamLaunch AppId=<appid>`,
|
||||
* whose lifetime is exactly the game's. Send it if you have it.
|
||||
*/
|
||||
steam_appid: Schema.optionalKey(Schema.NullOr(Schema.Number)),
|
||||
/**
|
||||
* An environment variable the launcher stamps on the game's process. Load-bearing for launchers
|
||||
* that run games under Proton/Wine, where the process tree tells you very little (Heroic's
|
||||
* `HEROIC_APP_NAME` is the verified case). Omit `value` to match on the key's mere presence —
|
||||
* only safe for a launcher that runs one game at a time.
|
||||
*/
|
||||
env_marker: Schema.optionalKey(
|
||||
Schema.NullOr(
|
||||
Schema.Struct({
|
||||
/** `[A-Za-z0-9_]{1,64}` — the host rejects anything else. */
|
||||
key: Schema.String,
|
||||
/** At most 256 chars. */
|
||||
value: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
export type DetectHint = typeof DetectHint.Type;
|
||||
|
||||
@@ -76,6 +130,8 @@ export const ProviderEntry = Schema.Struct({
|
||||
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
|
||||
prep: Schema.optionalKey(Schema.Array(PrepStep)),
|
||||
detect: Schema.optionalKey(DetectHint),
|
||||
/** `"game"` (default) or `"launcher"` — see {@link GameRole}. */
|
||||
role: Schema.optionalKey(GameRole),
|
||||
...GameMeta.fields,
|
||||
});
|
||||
export type ProviderEntry = typeof ProviderEntry.Type;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// The `__config` contract — the wire shape the console's generic settings drawer codes against,
|
||||
// plus the JSON-Schema derivation's committed fixture (design M0/S2).
|
||||
//
|
||||
// The derivation fixture is not decoration: it is the record of WHICH schema shapes the generic
|
||||
// form can render. If an effect upgrade changes any of it, this test fails and the console's form
|
||||
// needs re-checking before the change ships — far cheaper than discovering it on a user's box.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Effect, Layer, Schema } from "effect";
|
||||
import { makeConfigService } from "../src/config.js";
|
||||
import { pluginInfoLayer } from "../src/host-client.js";
|
||||
import { deriveConfigJsonSchema, makeConfigHandler } from "../src/ui-server.js";
|
||||
|
||||
/** A representative scanner config: booleans, a string, a string array, a nested object, an enum. */
|
||||
const ScannerConfig = Schema.Struct({
|
||||
enabled: Schema.Boolean.annotate({
|
||||
title: "Enable scanning",
|
||||
description: "Whether this source contributes titles.",
|
||||
default: true,
|
||||
}).pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed(true), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
root: Schema.optionalKey(
|
||||
Schema.String.annotate({ title: "Launcher root", description: "Absolute path." }),
|
||||
),
|
||||
extraRoots: Schema.Array(Schema.String)
|
||||
.annotate({ title: "Extra roots" })
|
||||
.pipe(
|
||||
Schema.withDecodingDefaultKey(
|
||||
Effect.succeed([] as ReadonlyArray<string>),
|
||||
{ encodingStrategy: "omit" },
|
||||
),
|
||||
),
|
||||
launchers: Schema.Struct({
|
||||
bigpicture: Schema.Boolean.annotate({ title: "Big Picture", default: true }),
|
||||
desktop: Schema.Boolean.annotate({ title: "Desktop", default: false }),
|
||||
}).pipe(
|
||||
Schema.withDecodingDefaultKey(
|
||||
Effect.succeed({ bigpicture: true, desktop: false }),
|
||||
{ encodingStrategy: "omit" },
|
||||
),
|
||||
),
|
||||
pollMinutes: Schema.Int.annotate({
|
||||
title: "Poll interval (minutes)",
|
||||
default: 15,
|
||||
}).pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed(15), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
artSource: Schema.Literals(["local", "cdn", "both"])
|
||||
.annotate({ title: "Art source", default: "both" })
|
||||
.pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed("both" as const), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const props = (): Record<string, Record<string, unknown>> => {
|
||||
const doc = deriveConfigJsonSchema(ScannerConfig) as {
|
||||
schema: { properties: Record<string, Record<string, unknown>> };
|
||||
};
|
||||
return doc.schema.properties;
|
||||
};
|
||||
|
||||
describe("S2 — JSON Schema derivation for __config", () => {
|
||||
test("derives a renderable form for every shape a scanner config uses", () => {
|
||||
const p = props();
|
||||
expect(p.enabled).toMatchObject({ type: "boolean" });
|
||||
expect(p.root).toMatchObject({ type: "string" });
|
||||
expect(p.extraRoots).toMatchObject({
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
});
|
||||
// A nested object stays nested — the form renders a fieldset, not a JSON blob.
|
||||
expect(p.launchers).toMatchObject({
|
||||
type: "object",
|
||||
properties: { bigpicture: { type: "boolean" }, desktop: { type: "boolean" } },
|
||||
});
|
||||
// A literal union derives a clean enum — prefer it over a union of strings.
|
||||
expect(p.artSource).toMatchObject({
|
||||
type: "string",
|
||||
enum: ["local", "cdn", "both"],
|
||||
});
|
||||
});
|
||||
|
||||
test("annotations pass through — they are the ONLY source of labels and defaults", () => {
|
||||
const p = props();
|
||||
expect(p.enabled.title).toBe("Enable scanning");
|
||||
expect(p.enabled.description).toBe("Whether this source contributes titles.");
|
||||
// The derivation does NOT infer `default` from withDecodingDefaultKey, so an un-annotated
|
||||
// field shows the form no placeholder at all. Annotate every field.
|
||||
expect(p.enabled.default).toBe(true);
|
||||
expect(p.artSource.default).toBe("both");
|
||||
// A CHECKED schema (Int is String-plus-a-check) nests its annotations under `allOf`, so a
|
||||
// form reading `default` must merge allOf branches rather than only looking at the top level.
|
||||
expect(p.pollMinutes.allOf).toEqual([
|
||||
{ default: 15, title: "Poll interval (minutes)" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a decoding default is an Effect, not a thunk — and it actually applies", () => {
|
||||
// The trap this pins: `withDecodingDefaultKey` takes an `Effect`, and passing a bare thunk
|
||||
// (`() => true`) type-checks against the derivation path but blows up at DECODE time with
|
||||
// "Not a valid effect". Deriving a schema is therefore NOT evidence that it works.
|
||||
expect(Schema.decodeUnknownSync(ScannerConfig)({})).toMatchObject({
|
||||
enabled: true,
|
||||
pollMinutes: 15,
|
||||
artSource: "both",
|
||||
launchers: { bigpicture: true, desktop: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("Schema.Int derives a plain integer — Schema.Number does NOT", () => {
|
||||
expect(props().pollMinutes).toMatchObject({ type: "integer" });
|
||||
// The trap, pinned: Schema.Number's ENCODED form admits "NaN"/"Infinity"/"-Infinity", so it
|
||||
// derives a four-way anyOf that no number input can render. Use Finite or Int.
|
||||
const bad = deriveConfigJsonSchema(
|
||||
Schema.Struct({ n: Schema.Number }),
|
||||
) as { schema: { properties: { n: { anyOf?: unknown[] } } } };
|
||||
expect(Array.isArray(bad.schema.properties.n.anyOf)).toBe(true);
|
||||
const ok = deriveConfigJsonSchema(
|
||||
Schema.Struct({ n: Schema.Finite }),
|
||||
) as { schema: { properties: { n: { type?: string } } } };
|
||||
expect(ok.schema.properties.n.type).toBe("number");
|
||||
});
|
||||
|
||||
test("defaulted fields drop out of `required` — the raw file stays default-free", () => {
|
||||
const doc = deriveConfigJsonSchema(ScannerConfig) as {
|
||||
schema: { required?: string[] };
|
||||
};
|
||||
// Every field here either has a decoding default or is optionalKey, so nothing is required.
|
||||
expect(doc.schema.required ?? []).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("__config wire contract", () => {
|
||||
const withService = async <A>(
|
||||
use: (handler: (req: Request) => Promise<Response>, file: string) => Promise<A>,
|
||||
): Promise<A> => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-cfg-"));
|
||||
const prev = process.env.PUNKTFUNK_CONFIG_DIR;
|
||||
process.env.PUNKTFUNK_CONFIG_DIR = dir;
|
||||
try {
|
||||
const service = await Effect.runPromise(
|
||||
makeConfigService({ schema: ScannerConfig }).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(pluginInfoLayer({ name: "steam", version: "0.1.0" })),
|
||||
),
|
||||
),
|
||||
);
|
||||
return await use(
|
||||
makeConfigHandler({ schema: ScannerConfig, service }),
|
||||
service.path,
|
||||
);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
|
||||
else process.env.PUNKTFUNK_CONFIG_DIR = prev;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
test("GET answers {schema, value} with an absent file reading as empty", async () => {
|
||||
await withService(async (handler) => {
|
||||
const res = await handler(new Request("http://x/__config"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { schema: unknown; value: unknown };
|
||||
// Both keys are ALWAYS present and never `undefined` — the console decodes this shape,
|
||||
// and an omitted-vs-null field is the wire trap that bit the rom-manager 0.3.1 release.
|
||||
expect(body).toHaveProperty("schema");
|
||||
expect(body).toHaveProperty("value");
|
||||
expect(body.schema).not.toBeNull();
|
||||
// A missing config file is an EMPTY config, not an error.
|
||||
expect(body.value).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test("PUT validates by decode, persists RAW, and never bakes in defaults", async () => {
|
||||
await withService(async (handler, file) => {
|
||||
const res = await handler(
|
||||
new Request("http://x/__config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// The file holds exactly what was authored — the five defaulted fields are NOT written,
|
||||
// which is what keeps a future change to a default from being silently pinned.
|
||||
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
const get = (await (
|
||||
await handler(new Request("http://x/__config"))
|
||||
).json()) as { value: unknown };
|
||||
expect(get.value).toEqual({ enabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
test("PUT rejects a body the schema refuses, with the issue, and writes nothing", async () => {
|
||||
await withService(async (handler, file) => {
|
||||
const res = await handler(
|
||||
new Request("http://x/__config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled: "yes please" }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string; issue: string };
|
||||
expect(body.error).toBe("config rejected");
|
||||
expect(body.issue.length).toBeGreaterThan(0);
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("PUT rejects a non-JSON body", async () => {
|
||||
await withService(async (handler) => {
|
||||
const res = await handler(
|
||||
new Request("http://x/__config", { method: "PUT", body: "not json" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toBe(
|
||||
"body must be JSON",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("other methods are refused", async () => {
|
||||
await withService(async (handler) => {
|
||||
const res = await handler(
|
||||
new Request("http://x/__config", { method: "DELETE" }),
|
||||
);
|
||||
expect(res.status).toBe(405);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
// The parity harness is the release gate for every extracted scanner, so the thing that decides
|
||||
// pass/fail needs its own tests. The cases below are the ones that actually happen during a port:
|
||||
// a lost title, a wrong id, a dropped launch recipe, art whose representation changed but whose
|
||||
// presence didn't, and the launcher entries the plugin legitimately adds.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
claimedLibraryId,
|
||||
diffParity,
|
||||
formatParityReport,
|
||||
fromHostEntry,
|
||||
fromProviderEntry,
|
||||
type HostGameEntry,
|
||||
} from "../src/library/parity.js";
|
||||
import type { ProviderEntry } from "../src/wire.js";
|
||||
|
||||
/** What the host reports while its BUILT-IN steam scanner is producing the library. */
|
||||
const hostEntry = (over: Partial<HostGameEntry> = {}): HostGameEntry => ({
|
||||
id: "steam:440",
|
||||
store: "steam",
|
||||
title: "Team Fortress 2",
|
||||
launch: { kind: "steam_appid", value: "440" },
|
||||
// The scanner emits host-relative proxy paths the CLIENT resolves.
|
||||
art: {
|
||||
portrait: "/api/v1/library/art/steam:440/portrait",
|
||||
hero: "/api/v1/library/art/steam:440/hero",
|
||||
logo: null,
|
||||
header: "/api/v1/library/art/steam:440/header",
|
||||
},
|
||||
platform: "PC",
|
||||
...over,
|
||||
});
|
||||
|
||||
/** What the extracted plugin produces for the same title. */
|
||||
const pluginEntry = (over: Partial<ProviderEntry> = {}): ProviderEntry =>
|
||||
({
|
||||
external_id: "440",
|
||||
title: "Team Fortress 2",
|
||||
launch: { kind: "steam_appid", value: "440" },
|
||||
// The plugin emits file:// paths and CDN URLs — a DIFFERENT representation of the same art.
|
||||
art: {
|
||||
portrait: "file:///home/u/.steam/appcache/librarycache/440/a/p.jpg",
|
||||
hero: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/library_hero.jpg",
|
||||
header: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/header.jpg",
|
||||
},
|
||||
platform: "PC",
|
||||
...over,
|
||||
}) as ProviderEntry;
|
||||
|
||||
describe("id mapping", () => {
|
||||
test("a claimed entry's id is the scanner's id", () => {
|
||||
// The whole migration rests on this one line: Moonlight pins, GameStream app ids and client
|
||||
// art caches are all derived from it.
|
||||
expect(claimedLibraryId("steam", "440")).toBe("steam:440");
|
||||
expect(claimedLibraryId("heroic", "legendary:Quail")).toBe(
|
||||
"heroic:legendary:Quail",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffParity", () => {
|
||||
const base = [fromHostEntry(hostEntry())];
|
||||
|
||||
test("a faithful port passes, even though the art VALUES all changed", () => {
|
||||
const r = diffParity(base, [fromProviderEntry("steam", pluginEntry())]);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.matched).toBe(1);
|
||||
expect(r.changed).toEqual([]);
|
||||
expect(formatParityReport(r)).toContain("parity OK");
|
||||
});
|
||||
|
||||
test("a lost title is reported as missing", () => {
|
||||
const r = diffParity(base, []);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.missing.map((e) => e.id)).toEqual(["steam:440"]);
|
||||
expect(formatParityReport(r)).toContain("missing: steam:440");
|
||||
});
|
||||
|
||||
test("a wrong id shows up as BOTH missing and extra — the loudest failure", () => {
|
||||
// The exact shape of the bug this harness exists to catch: the plugin found the title, but
|
||||
// under an id nothing downstream recognizes.
|
||||
const r = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry({ external_id: "440.0" })),
|
||||
]);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.missing.map((e) => e.id)).toEqual(["steam:440"]);
|
||||
expect(r.extra.map((e) => e.id)).toEqual(["steam:440.0"]);
|
||||
});
|
||||
|
||||
test("a changed launch recipe is caught", () => {
|
||||
const r = diffParity(base, [
|
||||
fromProviderEntry(
|
||||
"steam",
|
||||
pluginEntry({ launch: { kind: "command", value: "steam" } }),
|
||||
),
|
||||
]);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.changed).toEqual([
|
||||
{
|
||||
id: "steam:440",
|
||||
field: "launch",
|
||||
before: "steam_appid:440",
|
||||
after: "command:steam",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("a dropped launch recipe is caught (an unlaunchable tile)", () => {
|
||||
const r = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry({ launch: null })),
|
||||
]);
|
||||
expect(r.changed.map((c) => c.field)).toEqual(["launch"]);
|
||||
});
|
||||
|
||||
test("LOSING an art kind fails; gaining one does not", () => {
|
||||
const lost = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry({ art: { portrait: null } })),
|
||||
]);
|
||||
expect(lost.ok).toBe(false);
|
||||
expect(lost.changed.map((c) => c.field)).toContain("art.portrait");
|
||||
|
||||
// The baseline had no logo; the plugin resolves one. That is an improvement, and failing the
|
||||
// run over it would only train people to ignore the harness.
|
||||
const gained = diffParity(base, [
|
||||
fromProviderEntry(
|
||||
"steam",
|
||||
pluginEntry({
|
||||
art: { ...pluginEntry().art, logo: "file:///l.png" },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
expect(gained.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("metadata drift is caught, but absent-vs-empty is not drift", () => {
|
||||
const changed = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry({ platform: "Linux" })),
|
||||
]);
|
||||
expect(changed.changed).toEqual([
|
||||
{ id: "steam:440", field: "meta.platform", before: "PC", after: "Linux" },
|
||||
]);
|
||||
// The host omits empty lists and nulls; a plugin sending them has changed nothing.
|
||||
const noise = diffParity(base, [
|
||||
fromProviderEntry(
|
||||
"steam",
|
||||
pluginEntry({ genres: [], tags: [], region: null } as never),
|
||||
),
|
||||
]);
|
||||
expect(noise.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("launcher entries are expected extras, not failures", () => {
|
||||
// The built-in scanner had no concept of a launcher entry, so it can never be in the
|
||||
// baseline — reporting it as `extra` would fail every steam run forever.
|
||||
const r = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry()),
|
||||
fromProviderEntry(
|
||||
"steam",
|
||||
pluginEntry({
|
||||
external_id: "ui:bigpicture",
|
||||
title: "Steam Big Picture",
|
||||
role: "launcher",
|
||||
launch: { kind: "steam_ui", value: "bigpicture" },
|
||||
art: {},
|
||||
} as never),
|
||||
),
|
||||
]);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.extra).toEqual([]);
|
||||
expect(r.launchersAdded.map((e) => e.id)).toEqual(["steam:ui:bigpicture"]);
|
||||
expect(formatParityReport(r)).toContain("+1 launcher entry");
|
||||
});
|
||||
|
||||
test("an ordinary title the scanner never had IS a failure", () => {
|
||||
// The mirror of the case above: only `role: "launcher"` gets the exemption, so a plugin that
|
||||
// invents games (a bad filter, a tool listed as a game) still fails.
|
||||
const r = diffParity(base, [
|
||||
fromProviderEntry("steam", pluginEntry()),
|
||||
fromProviderEntry(
|
||||
"steam",
|
||||
pluginEntry({ external_id: "228980", title: "Steamworks Common" }),
|
||||
),
|
||||
]);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.extra.map((e) => e.id)).toEqual(["steam:228980"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
// The parser ports, tested against the SAME cases the host's Rust scanners pin.
|
||||
//
|
||||
// These are not "does TypeScript work" tests. The formats here are undocumented and the host's
|
||||
// versions are the reference implementation; a port that drifts produces a library that looks fine
|
||||
// and launches nothing. Where a Rust test exists, its assertions are carried over verbatim — the
|
||||
// per-plugin parity harness (design M5) then checks the whole pipeline against a live host, but
|
||||
// these catch a drift long before that.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
confinedJoin,
|
||||
crc32,
|
||||
findGridArtFile,
|
||||
findLocalArtFile,
|
||||
fileUrl,
|
||||
gridFilenames,
|
||||
isSteamTool,
|
||||
parseAppManifest,
|
||||
parseRegQuery,
|
||||
parseShortcuts,
|
||||
readTextCapped,
|
||||
shortcutAppId,
|
||||
shortcutGameId,
|
||||
steamCdnUrl,
|
||||
vdfPaths,
|
||||
vdfValue,
|
||||
} from "../src/library/parsers/index.js";
|
||||
|
||||
const tmp = (name: string): string => {
|
||||
const dir = path.join(os.tmpdir(), `pf-kit-${name}-${process.pid}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
};
|
||||
|
||||
describe("text VDF / ACF", () => {
|
||||
test("vdfValue extracts a quoted field", () => {
|
||||
expect(vdfValue('"path"\t\t"/mnt/games/SteamLibrary"', "path")).toBe(
|
||||
"/mnt/games/SteamLibrary",
|
||||
);
|
||||
expect(vdfValue('"appid"\t\t"570"', "appid")).toBe("570");
|
||||
expect(vdfValue('"name"\t\t"Dota 2"', "name")).toBe("Dota 2");
|
||||
// Wrong key → nothing (a prefix match must not leak the neighbouring field).
|
||||
expect(vdfValue('"installdir"\t\t"x"', "appid")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("vdfPaths pulls every library folder and unescapes Windows separators", () => {
|
||||
const vdf = `
|
||||
"libraryfolders"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"path" "/home/u/.local/share/Steam"
|
||||
"label" ""
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"path" "D:\\\\SteamLibrary"
|
||||
}
|
||||
}`;
|
||||
expect(vdfPaths(vdf)).toEqual([
|
||||
"/home/u/.local/share/Steam",
|
||||
"D:\\SteamLibrary",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseAppManifest reads the flat fields it needs", () => {
|
||||
const acf = `"AppState"
|
||||
{
|
||||
"appid" "570"
|
||||
"name" "Dota 2"
|
||||
"installdir" "dota 2 beta"
|
||||
}`;
|
||||
expect(parseAppManifest(acf)).toEqual({
|
||||
appid: 570,
|
||||
name: "Dota 2",
|
||||
installdir: "dota 2 beta",
|
||||
});
|
||||
// A manifest missing the essentials is not a title.
|
||||
expect(parseAppManifest('"AppState" { "name" "x" }')).toBeUndefined();
|
||||
});
|
||||
|
||||
test("isSteamTool keeps runtimes out of a game library", () => {
|
||||
expect(isSteamTool(228980, "Steamworks Common Redistributables")).toBe(true);
|
||||
expect(isSteamTool(1628350, "Steam Linux Runtime 3.0 (sniper)")).toBe(true);
|
||||
expect(isSteamTool(999, "Proton 9.0")).toBe(true);
|
||||
expect(isSteamTool(999, "SteamVR")).toBe(true);
|
||||
expect(isSteamTool(570, "Dota 2")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binary shortcuts.vdf", () => {
|
||||
/** Build a binary shortcuts.vdf the way Steam writes one. */
|
||||
const buildShortcuts = (
|
||||
entries: ReadonlyArray<{
|
||||
appid?: number;
|
||||
appname: string;
|
||||
exe: string;
|
||||
hidden?: boolean;
|
||||
}>,
|
||||
): Uint8Array => {
|
||||
const parts: number[] = [];
|
||||
const cstr = (s: string) => {
|
||||
for (const b of new TextEncoder().encode(s)) parts.push(b);
|
||||
parts.push(0);
|
||||
};
|
||||
const i32 = (v: number) => {
|
||||
parts.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
|
||||
};
|
||||
parts.push(0x00);
|
||||
cstr("shortcuts");
|
||||
entries.forEach((e, i) => {
|
||||
parts.push(0x00);
|
||||
cstr(String(i));
|
||||
if (e.appid !== undefined) {
|
||||
parts.push(0x02);
|
||||
cstr("appid");
|
||||
i32(e.appid);
|
||||
}
|
||||
parts.push(0x01);
|
||||
cstr("AppName");
|
||||
cstr(e.appname);
|
||||
parts.push(0x01);
|
||||
cstr("Exe");
|
||||
cstr(e.exe);
|
||||
parts.push(0x02);
|
||||
cstr("IsHidden");
|
||||
i32(e.hidden ? 1 : 0);
|
||||
// A nested map the parser must skip wholesale.
|
||||
parts.push(0x00);
|
||||
cstr("tags");
|
||||
parts.push(0x01);
|
||||
cstr("0");
|
||||
cstr("favourite");
|
||||
parts.push(0x08);
|
||||
parts.push(0x08); // end of this shortcut
|
||||
});
|
||||
parts.push(0x08); // end of shortcuts
|
||||
parts.push(0x08); // end of document
|
||||
return new Uint8Array(parts);
|
||||
};
|
||||
|
||||
test("parses entries, skips nested maps, and reads the hidden flag", () => {
|
||||
const buf = buildShortcuts([
|
||||
{ appid: 2456789012, appname: "My Emulator", exe: '"/usr/bin/foo"' },
|
||||
{ appid: 3000000000, appname: "Hidden One", exe: '"/x"', hidden: true },
|
||||
]);
|
||||
const got = parseShortcuts(buf);
|
||||
expect(got).toHaveLength(2);
|
||||
expect(got[0]).toMatchObject({
|
||||
appid: 2456789012,
|
||||
name: "My Emulator",
|
||||
hidden: false,
|
||||
});
|
||||
expect(got[1]).toMatchObject({ name: "Hidden One", hidden: true });
|
||||
});
|
||||
|
||||
test("derives the appid when the file omits it", () => {
|
||||
const buf = buildShortcuts([{ appname: "No Appid", exe: '"/usr/bin/x"' }]);
|
||||
const got = parseShortcuts(buf);
|
||||
expect(got).toHaveLength(1);
|
||||
// Derived ids always carry the high bit — that is how a shortcut is told apart from a real
|
||||
// store appid downstream (and why its CDN art fetch is skipped).
|
||||
expect(got[0].appid & 0x8000_0000).not.toBe(0);
|
||||
expect(got[0].appid).toBe(shortcutAppId('"/usr/bin/x"', "No Appid"));
|
||||
});
|
||||
|
||||
test("is total on a truncated or garbled file", () => {
|
||||
expect(parseShortcuts(new Uint8Array([]))).toEqual([]);
|
||||
expect(parseShortcuts(new Uint8Array([0x01, 0x02, 0x03]))).toEqual([]);
|
||||
const good = buildShortcuts([{ appid: 1, appname: "A", exe: "/a" }]);
|
||||
// Every truncation of a valid file must return, not throw.
|
||||
for (let i = 0; i < good.length; i++) {
|
||||
expect(() => parseShortcuts(good.subarray(0, i))).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("crc32 matches the IEEE check value", () => {
|
||||
// The canonical CRC-32 check: crc32("123456789") == 0xCBF43926.
|
||||
expect(crc32(new TextEncoder().encode("123456789"))).toBe(0xcbf4_3926);
|
||||
});
|
||||
|
||||
test("shortcutGameId composes the appid and the shortcut marker", () => {
|
||||
// high dword = appid, low dword = 0x02000000. Handing rungameid the bare 32-bit appid does
|
||||
// NOT launch a shortcut, which is the entire reason this function exists.
|
||||
const id = BigInt(shortcutGameId(0x8000_0000));
|
||||
expect(id >> 32n).toBe(0x8000_0000n);
|
||||
expect(id & 0xffff_ffffn).toBe(0x0200_0000n);
|
||||
// Digits only — it rides the `steam_appid` launch kind, which the host validates as digits.
|
||||
expect(shortcutGameId(2_456_789_012)).toMatch(/^\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path confinement", () => {
|
||||
test("confinedJoin refuses anything that could escape the install dir", () => {
|
||||
const base = path.join(path.sep, "games", "W3");
|
||||
expect(confinedJoin(base, "bin/game.exe")).toBe(
|
||||
path.join(base, "bin", "game.exe"),
|
||||
);
|
||||
expect(confinedJoin(base, "bin\\game.exe")).toBe(
|
||||
path.join(base, "bin", "game.exe"),
|
||||
);
|
||||
// The three shapes a crafted goggame-*.info would use to point elsewhere.
|
||||
expect(confinedJoin(base, "../../windows/system32/cmd.exe")).toBeUndefined();
|
||||
expect(confinedJoin(base, "/etc/passwd")).toBeUndefined();
|
||||
expect(confinedJoin(base, "C:\\Windows\\system32\\cmd.exe")).toBeUndefined();
|
||||
expect(confinedJoin(base, "")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("capped reads", () => {
|
||||
test("readTextCapped refuses an over-cap file and a missing one", () => {
|
||||
const dir = tmp("caps");
|
||||
const small = path.join(dir, "small.txt");
|
||||
fs.writeFileSync(small, "hello");
|
||||
expect(readTextCapped(small)).toBe("hello");
|
||||
expect(readTextCapped(small, 2)).toBeUndefined(); // over the cap
|
||||
expect(readTextCapped(path.join(dir, "nope.txt"))).toBeUndefined();
|
||||
expect(readTextCapped(dir)).toBeUndefined(); // a directory is not a file
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("art locations", () => {
|
||||
test("steamCdnUrl skips shortcut appids, which have no CDN entry", () => {
|
||||
expect(steamCdnUrl(570, "header")).toContain("/570/header.jpg");
|
||||
expect(steamCdnUrl(570, "portrait")).toContain("library_600x900.jpg");
|
||||
// The local cache names the header asset differently from the CDN — pinned because it is
|
||||
// the single most common way to get Steam art wrong.
|
||||
expect(steamCdnUrl(570, "header")).not.toContain("library_header");
|
||||
expect(steamCdnUrl(0x8000_0001, "header")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("grid filenames follow Steam's per-kind naming", () => {
|
||||
expect(gridFilenames(570, "portrait")).toEqual(["570p.png", "570p.jpg"]);
|
||||
expect(gridFilenames(570, "hero")).toEqual(["570_hero.png", "570_hero.jpg"]);
|
||||
expect(gridFilenames(570, "logo")).toEqual(["570_logo.png", "570_logo.jpg"]);
|
||||
expect(gridFilenames(570, "header")).toEqual(["570.png", "570.jpg"]);
|
||||
});
|
||||
|
||||
test("finds cached and user-override art on disk", () => {
|
||||
const dir = tmp("art");
|
||||
const hashDir = path.join(dir, "appcache", "librarycache", "570", "abc123");
|
||||
fs.mkdirSync(hashDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(hashDir, "library_600x900.jpg"), "x");
|
||||
expect(findLocalArtFile(dir, 570, "portrait")).toBe(
|
||||
path.join(hashDir, "library_600x900.jpg"),
|
||||
);
|
||||
expect(findLocalArtFile(dir, 570, "hero")).toBeUndefined();
|
||||
|
||||
const cfg = path.join(dir, "userdata", "1", "config");
|
||||
fs.mkdirSync(path.join(cfg, "grid"), { recursive: true });
|
||||
fs.writeFileSync(path.join(cfg, "grid", "570p.jpg"), "x");
|
||||
expect(findGridArtFile(cfg, 570, "portrait")).toBe(
|
||||
path.join(cfg, "grid", "570p.jpg"),
|
||||
);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("fileUrl produces the host's local-art contract shape", () => {
|
||||
const u = fileUrl(path.join(path.sep, "home", "u", "My Games", "c.jpg"));
|
||||
expect(u.startsWith("file:///")).toBe(true);
|
||||
// Spaces are percent-encoded; the separators survive so the host can rebuild the path.
|
||||
expect(u).toContain("My%20Games");
|
||||
expect(u).toContain("/c.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reg.exe output", () => {
|
||||
test("parses value rows and leaves the key header alone", () => {
|
||||
const stdout = [
|
||||
"",
|
||||
"HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Valve\\Steam",
|
||||
" InstallPath REG_SZ C:\\Program Files (x86)\\Steam",
|
||||
" Language REG_SZ english",
|
||||
"",
|
||||
].join("\r\n");
|
||||
expect(parseRegQuery(stdout)).toEqual([
|
||||
{
|
||||
name: "InstallPath",
|
||||
type: "REG_SZ",
|
||||
// Data may contain spaces — only the first two columns are split off.
|
||||
data: "C:\\Program Files (x86)\\Steam",
|
||||
},
|
||||
{ name: "Language", type: "REG_SZ", data: "english" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -10,5 +10,8 @@
|
||||
"noEmit": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
// `examples` is type-checked but never built: tsconfig.build.json narrows to `src`, and
|
||||
// package.json ships only `dist` + README. A worked example that doesn't compile is worse than
|
||||
// no example, and these are what the first-party scanner repos are cut from.
|
||||
"include": ["src", "test", "examples"]
|
||||
}
|
||||
|
||||
@@ -6,9 +6,16 @@
|
||||
# SIGTERM interrupts the whole tree STRUCTURALLY, so every plugin's scoped finalizers run before
|
||||
# exit (clean deregister / preset release) — hence the generous stop timeout below.
|
||||
#
|
||||
# OPT-IN — unlike punktfunk-web, the package does NOT auto-enable this: the runner does nothing until
|
||||
# you add scripts or install plugins. Turn it on once you have automation to run:
|
||||
# systemctl --user enable --now punktfunk-scripting
|
||||
# ON BY DEFAULT — the packages enable this for every user (`systemctl --global enable` from the
|
||||
# .deb/.rpm scriptlets; a baked-in default.target.wants symlink in the sysext image). It used to be
|
||||
# opt-in, on the reasoning that the runner does nothing until you add scripts or plugins. That
|
||||
# stopped being true when the game-library scanners became plugins: the library is a flagship
|
||||
# surface, and a host whose runner is off now comes up with an empty library and no obvious reason
|
||||
# why (design/library-scanner-plugins.md D9).
|
||||
#
|
||||
# It remains opt-OUT, per user:
|
||||
# systemctl --user mask punktfunk-scripting
|
||||
# (`mask`, not `disable` — a plain disable cannot remove a symlink that lives in /etc or /usr.)
|
||||
#
|
||||
# Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity
|
||||
# cert from ~/.config/punktfunk/{plugin-token,cert.pem} (written by the host's `serve`) — no env
|
||||
|
||||
+4
-3
@@ -20,6 +20,9 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^8.9.0",
|
||||
},
|
||||
"packages": {
|
||||
"@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.98", "", { "dependencies": { "swagger2openapi": "^7.0.8" }, "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.98", "effect": "^4.0.0-beta.98" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-7bqawr/HqJWqQ8H/bHyzBlLPA3LIIm3Y+cGYlIxnC/QVK795QpiEXb7uxTnP7V7w49V0sBtTerv4/9ZjsMffLQ=="],
|
||||
|
||||
@@ -169,7 +172,7 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
"undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
@@ -191,8 +194,6 @@
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"@effect/platform-node/undici": ["undici@8.7.0", "", {}, "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ=="],
|
||||
|
||||
"oas-linter/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
|
||||
"oas-resolver/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
|
||||
+3
-7
@@ -313,13 +313,9 @@
|
||||
url = "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz";
|
||||
hash = "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==";
|
||||
};
|
||||
"undici@7.28.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz";
|
||||
hash = "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==";
|
||||
};
|
||||
"undici@8.7.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/undici/-/undici-8.7.0.tgz";
|
||||
hash = "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==";
|
||||
"undici@8.10.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz";
|
||||
hash = "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==";
|
||||
};
|
||||
"uuid@14.0.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz";
|
||||
|
||||
@@ -55,5 +55,8 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"undici": "^7.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^8.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
+37
-22
File diff suppressed because one or more lines are too long
@@ -44,6 +44,17 @@ export interface PluginUiOptions {
|
||||
version?: string;
|
||||
/** Optional lucide icon name for the nav entry (`[a-z0-9-]`, e.g. `"gamepad-2"`). */
|
||||
icon?: string;
|
||||
/**
|
||||
* What KIND of plugin this is (`[a-z][a-z0-9-]{0,31}`). The console groups and filters on it —
|
||||
* and notably keeps `"library"` plugins **out of the nav**, because a scanner's entry point is
|
||||
* the Library section's Game sources surface, not a sidebar item of its own. Six installed
|
||||
* scanners would otherwise flood the sidebar.
|
||||
*
|
||||
* `@punktfunk/plugin-kit`'s `defineLibraryPlugin` sets this for you. Set it by hand only if you
|
||||
* are building a library plugin without the kit — and omit it if your plugin wants a full page
|
||||
* despite also syncing a library (rom-manager does).
|
||||
*/
|
||||
category?: string;
|
||||
/**
|
||||
* Directory of the built SPA. Requests are served from here first (with an `index.html` SPA
|
||||
* fallback for navigations); a static miss falls through to [`fetch`]. Accepts a filesystem
|
||||
@@ -182,6 +193,9 @@ export const servePluginUi = async (
|
||||
secret,
|
||||
...(opts.icon !== undefined ? { icon: opts.icon } : {}),
|
||||
},
|
||||
// Sent through the UNTYPED `pf.request` below, so an older host simply ignores the unknown
|
||||
// field rather than rejecting the registration — no runner flag, no version gate.
|
||||
...(opts.category !== undefined ? { category: opts.category } : {}),
|
||||
};
|
||||
|
||||
const register = () => pf.request("PUT", `/plugins/${opts.id}`, body);
|
||||
|
||||
+8
-8
@@ -46,16 +46,16 @@
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "^5.0.8",
|
||||
"brace-expansion": "^5.0.9",
|
||||
"dompurify": "^3.4.12",
|
||||
"fast-uri": "^3.1.4",
|
||||
"fast-uri": "^3.1.5",
|
||||
"immutable": "^4.3.9",
|
||||
"js-yaml": "^4.3.0",
|
||||
"linkify-it": "^5.0.2",
|
||||
"postcss": "^8.5.10",
|
||||
"postcss": "^8.5.25",
|
||||
"sharp": "^0.35.3",
|
||||
"tar": "^7.5.21",
|
||||
"undici": "^7.28.0",
|
||||
"undici": "^7.29.0",
|
||||
},
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
||||
@@ -1118,7 +1118,7 @@
|
||||
|
||||
"body-scroll-lock": ["body-scroll-lock@4.0.0-beta.0", "", {}, "sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
||||
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
@@ -1388,7 +1388,7 @@
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
|
||||
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
@@ -1876,7 +1876,7 @@
|
||||
|
||||
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.22", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ=="],
|
||||
"postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="],
|
||||
|
||||
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||
|
||||
@@ -2196,7 +2196,7 @@
|
||||
|
||||
"unctx": ["unctx@2.5.0", "", { "dependencies": { "acorn": "^8.15.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21", "unplugin": "^2.3.11" } }, "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg=="],
|
||||
|
||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
|
||||
+12
-20
@@ -2151,10 +2151,6 @@
|
||||
url = "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz";
|
||||
hash = "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==";
|
||||
};
|
||||
"balanced-match@1.0.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz";
|
||||
hash = "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==";
|
||||
};
|
||||
"balanced-match@4.0.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz";
|
||||
hash = "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==";
|
||||
@@ -2199,13 +2195,9 @@
|
||||
url = "https://registry.npmjs.org/body-scroll-lock/-/body-scroll-lock-4.0.0-beta.0.tgz";
|
||||
hash = "sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==";
|
||||
};
|
||||
"brace-expansion@2.1.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz";
|
||||
hash = "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==";
|
||||
};
|
||||
"brace-expansion@5.0.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz";
|
||||
hash = "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==";
|
||||
"brace-expansion@5.0.9" = fetchurl {
|
||||
url = "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz";
|
||||
hash = "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==";
|
||||
};
|
||||
"braces@3.0.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz";
|
||||
@@ -2815,9 +2807,9 @@
|
||||
url = "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz";
|
||||
hash = "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==";
|
||||
};
|
||||
"fast-uri@3.1.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz";
|
||||
hash = "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==";
|
||||
"fast-uri@3.1.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz";
|
||||
hash = "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==";
|
||||
};
|
||||
"fastq@1.20.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz";
|
||||
@@ -3911,9 +3903,9 @@
|
||||
url = "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz";
|
||||
hash = "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==";
|
||||
};
|
||||
"postcss@8.5.22" = fetchurl {
|
||||
url = "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz";
|
||||
hash = "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==";
|
||||
"postcss@8.5.25" = fetchurl {
|
||||
url = "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz";
|
||||
hash = "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==";
|
||||
};
|
||||
"powershell-utils@0.1.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz";
|
||||
@@ -4619,9 +4611,9 @@
|
||||
url = "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz";
|
||||
hash = "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==";
|
||||
};
|
||||
"undici@7.28.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz";
|
||||
hash = "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==";
|
||||
"undici@7.29.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz";
|
||||
hash = "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==";
|
||||
};
|
||||
"unenv@2.0.0-rc.24" = fetchurl {
|
||||
url = "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz";
|
||||
|
||||
+18
-2
@@ -285,9 +285,25 @@
|
||||
"library_field_players": "Spieler",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_owned_by": "über {provider}",
|
||||
"library_providers_title": "Von Plugins synchronisiert",
|
||||
"library_providers_help": "Diese Einträge gehören einem Plugin und lassen sich deshalb nicht einzeln bearbeiten oder löschen — das Plugin synchronisiert sie neu. Ist das Plugin weg, entferne seine Einträge hier.",
|
||||
"library_provider_count": "{count} Einträge",
|
||||
"library_launchers_title": "Launcher",
|
||||
"library_empty_add_source": "Füge unten eine Spielquelle hinzu, damit deine installierten Spiele hier erscheinen.",
|
||||
"library_add_source": "Quelle hinzufügen",
|
||||
"library_source_detected": "Erkannt",
|
||||
"library_source_running": "Läuft",
|
||||
"library_source_stopped": "Gestoppt",
|
||||
"library_source_settings": "Einstellungen",
|
||||
"library_source_settings_title": "Einstellungen für {source}",
|
||||
"library_source_settings_save": "Einstellungen speichern",
|
||||
"library_source_settings_saved": "Einstellungen gespeichert.",
|
||||
"library_source_settings_failed": "Einstellungen konnten nicht gespeichert werden: {issue}",
|
||||
"library_source_settings_unreachable": "Die Einstellungen dieser Quelle sind nicht erreichbar: {issue}",
|
||||
"library_source_settings_json_hint": "Die Einstellungen dieser Quelle passen in kein einfaches Formular — bearbeite sie als JSON. Sie werden vor dem Speichern geprüft.",
|
||||
"library_migrate_title": "Spielquellen werden zu Plugins",
|
||||
"library_migrate_help": "Jeder Launcher wird ein eigenes Add-on — du installierst nur die, die du nutzt, und jedes bekommt eigene Einstellungen. Installierst du eines, übernimmt es vom eingebauten Scanner; deine Spiele behalten ihre Kacheln. Wenn du nichts tust, ändert sich nichts.",
|
||||
"library_migrate_install": "Quelle {source} installieren",
|
||||
"library_source_installing": "{title} wird installiert…",
|
||||
"library_source_install_failed": "Diese Quelle konnte nicht installiert werden.",
|
||||
"library_provider_filter": "Nur diese zeigen",
|
||||
"library_provider_show_all": "Alle zeigen",
|
||||
"library_provider_purge": "Einträge dieses Anbieters entfernen",
|
||||
|
||||
+18
-2
@@ -285,9 +285,25 @@
|
||||
"library_field_players": "Players",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_owned_by": "via {provider}",
|
||||
"library_providers_title": "Synced by plugins",
|
||||
"library_providers_help": "These entries are owned by a plugin, so they can't be edited or removed one at a time — the plugin re-syncs them. If the plugin is gone, remove its entries here.",
|
||||
"library_provider_count": "{count} entries",
|
||||
"library_launchers_title": "Launchers",
|
||||
"library_empty_add_source": "Add a game source below to see your installed games here.",
|
||||
"library_add_source": "Add a source",
|
||||
"library_source_detected": "Detected",
|
||||
"library_source_running": "Running",
|
||||
"library_source_stopped": "Stopped",
|
||||
"library_source_settings": "Settings",
|
||||
"library_source_settings_title": "{source} settings",
|
||||
"library_source_settings_save": "Save settings",
|
||||
"library_source_settings_saved": "Settings saved.",
|
||||
"library_source_settings_failed": "Could not save the settings: {issue}",
|
||||
"library_source_settings_unreachable": "Could not reach this source's settings: {issue}",
|
||||
"library_source_settings_json_hint": "This source's settings don't fit a simple form, so edit them as JSON. They're checked before saving.",
|
||||
"library_migrate_title": "Game sources are moving to plugins",
|
||||
"library_migrate_help": "Each launcher is becoming its own add-on, so you only install the ones you use — and each gets its own settings. Install one and it takes over from the built-in scanner; your games keep the same tiles. Nothing changes if you do nothing yet.",
|
||||
"library_migrate_install": "Install the {source} source",
|
||||
"library_source_installing": "Installing {title}…",
|
||||
"library_source_install_failed": "Could not install this source.",
|
||||
"library_provider_filter": "Show only these",
|
||||
"library_provider_show_all": "Show all",
|
||||
"library_provider_purge": "Remove this provider's entries",
|
||||
|
||||
+4
-4
@@ -64,11 +64,11 @@
|
||||
"dompurify": "^3.4.12",
|
||||
"linkify-it": "^5.0.2",
|
||||
"sharp": "^0.35.3",
|
||||
"fast-uri": "^3.1.4",
|
||||
"fast-uri": "^3.1.5",
|
||||
"immutable": "^4.3.9",
|
||||
"undici": "^7.28.0",
|
||||
"postcss": "^8.5.10",
|
||||
"undici": "^7.29.0",
|
||||
"postcss": "^8.5.25",
|
||||
"js-yaml": "^4.3.0",
|
||||
"brace-expansion": "^5.0.8"
|
||||
"brace-expansion": "^5.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
+24
-2
@@ -29,8 +29,18 @@ export interface PluginSummary {
|
||||
version?: string;
|
||||
/** Present iff the plugin serves a UI (and thus gets a nav entry). */
|
||||
ui?: PluginUiSummary;
|
||||
/**
|
||||
* What kind of plugin this is. The console knows one value — `"library"` — and keeps those OUT
|
||||
* of the nav: a scanner's entry point is the Library section's Game sources surface, and six
|
||||
* installed scanners would otherwise flood the sidebar (design D5). Absent on an older host, and
|
||||
* absent by choice for a plugin that wants its own page anyway (rom-manager).
|
||||
*/
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/** The one category the console treats specially. */
|
||||
export const LIBRARY_CATEGORY = "library";
|
||||
|
||||
// A curated lucide set for plugin nav icons. Importing lucide's full dynamic icon map would defeat
|
||||
// tree-shaking (U-S4), so a plugin picks a name from here; anything unknown falls back to Puzzle.
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
@@ -97,6 +107,18 @@ export function usePlugins() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Only the plugins that surface a UI — the ones that get a nav entry. */
|
||||
/**
|
||||
* The plugins that get a **nav entry**: those serving a UI, minus the library-category ones.
|
||||
*
|
||||
* A library plugin still serves a UI port (that is how `__config` is reached) and its
|
||||
* `/plugins/$pluginId/$` route still resolves, so an existing deep link keeps working — it simply
|
||||
* isn't advertised in the sidebar.
|
||||
*/
|
||||
export const uiPlugins = (list: PluginSummary[] | undefined): PluginSummary[] =>
|
||||
(list ?? []).filter((p) => p.ui);
|
||||
(list ?? []).filter((p) => p.ui && p.category !== LIBRARY_CATEGORY);
|
||||
|
||||
/** The installed library-category plugins — the Game sources surface's own list. */
|
||||
export const libraryPlugins = (
|
||||
list: PluginSummary[] | undefined,
|
||||
): PluginSummary[] =>
|
||||
(list ?? []).filter((p) => p.category === LIBRARY_CATEGORY);
|
||||
|
||||
@@ -69,6 +69,17 @@ export interface StoreEntry {
|
||||
installed_version?: string;
|
||||
update_available: boolean;
|
||||
blocked?: string;
|
||||
/**
|
||||
* What kind of plugin this is. Browse filters on these, and the Library section's "Add a source"
|
||||
* rail shows exactly the `library` ones (design D5/D6). Absent on an index that predates them.
|
||||
*/
|
||||
categories?: string[];
|
||||
/**
|
||||
* Whether the launcher this plugin scans looks installed on this host, from the index's own
|
||||
* existence probes (design D8). `undefined` = the entry declares no probes for this platform,
|
||||
* which is "unknown" and must render differently from "not installed".
|
||||
*/
|
||||
detected?: boolean;
|
||||
}
|
||||
|
||||
export interface StoreCatalog {
|
||||
|
||||
@@ -82,14 +82,42 @@ export const LibraryGrid: FC<{
|
||||
/** Custom id of the card whose delete is in flight, or null — only that card disables. */
|
||||
deletingId: string | null;
|
||||
}> = ({ library, onEdit, onDelete, deletingId }) => {
|
||||
const games = library.data ?? [];
|
||||
const all = library.data ?? [];
|
||||
// Launcher entries (design D4) open the launcher itself — Steam Big Picture, Heroic — rather than
|
||||
// a title. They launch and lease exactly like games; grouping them into their own rail is purely
|
||||
// so a shelf of 400 games doesn't bury the two or three ways to open a launcher.
|
||||
const launchers = all.filter((g) => g.role === "launcher");
|
||||
const games = all.filter((g) => g.role !== "launcher");
|
||||
const card = (game: GameEntry) => (
|
||||
<GameCard
|
||||
key={game.id}
|
||||
game={game}
|
||||
onEdit={() => onEdit(game)}
|
||||
onDelete={() => onDelete(game)}
|
||||
deleting={deletingId === customId(game)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={library.isLoading}
|
||||
error={library.error}
|
||||
refetch={library.refetch}
|
||||
>
|
||||
{games.length === 0 ? (
|
||||
{launchers.length > 0 && (
|
||||
<div className="@container mb-card">
|
||||
<p className="pb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
|
||||
{m.library_launchers_title()}
|
||||
</p>
|
||||
<motion.div
|
||||
transition={{ delayChildren: stagger(0.1) }}
|
||||
variants={{ enter: {}, from: {} }}
|
||||
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
|
||||
>
|
||||
{launchers.map(card)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
{all.length === 0 ? (
|
||||
<Card>
|
||||
{/* `flush`, not a bare `p-8`: the default `sm:pt-0` would survive the override
|
||||
(tailwind-merge only resolves conflicts within a variant) and eat the top
|
||||
@@ -98,27 +126,25 @@ export const LibraryGrid: FC<{
|
||||
flush
|
||||
className="p-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{m.library_empty()}
|
||||
{/* After extraction a fresh host has NO scanners at all, so "no games" is the
|
||||
expected first-run state rather than a fault. Point at the fix (design D9)
|
||||
instead of leaving a bare empty grid. */}
|
||||
<p>{m.library_empty()}</p>
|
||||
<p className="mt-2">{m.library_empty_add_source()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="@container">
|
||||
<motion.div
|
||||
transition={{ delayChildren: stagger(0.1) }}
|
||||
variants={{ enter: {}, from: {} }}
|
||||
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
|
||||
>
|
||||
{games.map((game) => (
|
||||
<GameCard
|
||||
key={game.id}
|
||||
game={game}
|
||||
onEdit={() => onEdit(game)}
|
||||
onDelete={() => onDelete(game)}
|
||||
deleting={deletingId === customId(game)}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
games.length > 0 && (
|
||||
<div className="@container">
|
||||
<motion.div
|
||||
transition={{ delayChildren: stagger(0.1) }}
|
||||
variants={{ enter: {}, from: {} }}
|
||||
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
|
||||
>
|
||||
{games.map(card)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</QueryState>
|
||||
);
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
getGetLibraryQueryKey,
|
||||
useDeleteProviderEntries,
|
||||
} from "@/api/gen/library/library";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* Provider-owned entries: who put them there, and how to get rid of them.
|
||||
*
|
||||
* A plugin can sync entries into the library (RFC §8) and they are then refused to hand-edit or
|
||||
* delete individually — the host answers 409 and points at the provider's own reconcile. Which is
|
||||
* correct, and completely opaque if the plugin is gone: uninstalling it leaves its games in the
|
||||
* library with no console-side way to remove them. `DELETE /library/provider/{provider}` is the
|
||||
* documented clean-uninstall path and nothing called it.
|
||||
*
|
||||
* Renders nothing when no entry carries a provider, so an ordinary library sees no extra chrome.
|
||||
*/
|
||||
export const ProvidersCard: FC<{
|
||||
entries: GameEntry[];
|
||||
/** The provider currently filtered to, or null for "everything". */
|
||||
active: string | null;
|
||||
onFilter: (provider: string | null) => void;
|
||||
}> = ({ entries, active, onFilter }) => {
|
||||
const qc = useQueryClient();
|
||||
const purge = useDeleteProviderEntries();
|
||||
|
||||
// Count per provider, in first-seen order — the list is small and operator-facing.
|
||||
const counts = new Map<string, number>();
|
||||
for (const e of entries) {
|
||||
if (e.provider) counts.set(e.provider, (counts.get(e.provider) ?? 0) + 1);
|
||||
}
|
||||
if (counts.size === 0) return null;
|
||||
|
||||
const onPurge = async (provider: string, count: number) => {
|
||||
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
|
||||
try {
|
||||
await purge.mutateAsync({ provider });
|
||||
// The host emits `library.changed`, but don't wait for the round trip to redraw.
|
||||
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
if (active === provider) onFilter(null);
|
||||
toast.success(m.library_provider_purged({ provider }));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.library_providers_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.library_providers_help()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[...counts.entries()].map(([provider, count]) => (
|
||||
<div
|
||||
key={provider}
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<span className="font-medium">{provider}</span>
|
||||
<Badge variant="secondary">
|
||||
{m.library_provider_count({ count })}
|
||||
</Badge>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={active === provider ? "default" : "outline"}
|
||||
aria-pressed={active === provider}
|
||||
onClick={() =>
|
||||
onFilter(active === provider ? null : provider)
|
||||
}
|
||||
>
|
||||
{active === provider
|
||||
? m.library_provider_show_all()
|
||||
: m.library_provider_filter()}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={purge.isPending}
|
||||
aria-label={m.library_provider_purge()}
|
||||
onClick={() => onPurge(provider, count)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,345 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema.
|
||||
*
|
||||
* The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves
|
||||
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything
|
||||
* goes through the existing session-gated `/plugin-ui/<id>/…` proxy, so there is **zero new host
|
||||
* surface** — the browser never learns the plugin's port or secret.
|
||||
*
|
||||
* Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds
|
||||
* the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still
|
||||
* validates by decode host-side either way.
|
||||
*/
|
||||
export const SourceSettingsDialog: FC<{
|
||||
source: ScannerInfo;
|
||||
onClose: () => void;
|
||||
}> = ({ source, onClose }) => {
|
||||
const pluginId = source.provider ?? source.id;
|
||||
const [state, setState] = useState<
|
||||
| { tag: "loading" }
|
||||
| { tag: "error"; message: string }
|
||||
| { tag: "ready"; schema: JsonSchemaDoc | null; value: JsonObject }
|
||||
>({ tag: "loading" });
|
||||
const [raw, setRaw] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = (await res.json()) as {
|
||||
schema: JsonSchemaDoc | null;
|
||||
value: JsonObject | null;
|
||||
};
|
||||
if (cancelled) return;
|
||||
const value = body.value ?? {};
|
||||
setState({ tag: "ready", schema: body.schema, value });
|
||||
setRaw(JSON.stringify(value, null, 2));
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setState({ tag: "error", message: String(e) });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pluginId]);
|
||||
|
||||
const save = async (value: JsonObject) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(value),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
issue?: string;
|
||||
} | null;
|
||||
throw new Error(body?.issue ?? `HTTP ${res.status}`);
|
||||
}
|
||||
toast.success(m.library_source_settings_saved());
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast.error(m.library_source_settings_failed({ issue: String(e) }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{m.library_source_settings_title({ source: source.label })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{state.tag === "loading" && <Spinner />}
|
||||
{state.tag === "error" && (
|
||||
<p className="text-sm text-destructive">
|
||||
{m.library_source_settings_unreachable({ issue: state.message })}
|
||||
</p>
|
||||
)}
|
||||
{state.tag === "ready" && (
|
||||
<ConfigForm
|
||||
schema={state.schema}
|
||||
value={state.value}
|
||||
raw={raw}
|
||||
onRaw={setRaw}
|
||||
saving={saving}
|
||||
onSave={save}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
interface JsonSchemaNode {
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: string[];
|
||||
properties?: Record<string, JsonSchemaNode>;
|
||||
items?: JsonSchemaNode;
|
||||
allOf?: JsonSchemaNode[];
|
||||
}
|
||||
|
||||
interface JsonSchemaDoc {
|
||||
schema?: JsonSchemaNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a node's `allOf` branches into it. A *checked* schema (effect's `Schema.Int`, or anything
|
||||
* with `.check(...)`) nests its annotations and constraints there rather than at the top level, so
|
||||
* a form that only reads the top level silently loses every title and default on those fields.
|
||||
*/
|
||||
const flatten = (node: JsonSchemaNode): JsonSchemaNode =>
|
||||
(node.allOf ?? []).reduce<JsonSchemaNode>(
|
||||
(acc, branch) => ({ ...acc, ...branch }),
|
||||
{ ...node },
|
||||
);
|
||||
|
||||
/** Can this field be rendered as a real input? Anything else sends the whole form to the editor. */
|
||||
const renderable = (node: JsonSchemaNode): boolean => {
|
||||
const n = flatten(node);
|
||||
if (n.enum) return true;
|
||||
if (n.type === "boolean" || n.type === "string") return true;
|
||||
if (n.type === "number" || n.type === "integer") return true;
|
||||
if (n.type === "array" && flatten(n.items ?? {}).type === "string") return true;
|
||||
if (n.type === "object" && n.properties) {
|
||||
return Object.values(n.properties).every(renderable);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const ConfigForm: FC<{
|
||||
schema: JsonSchemaDoc | null;
|
||||
value: JsonObject;
|
||||
raw: string;
|
||||
onRaw: (v: string) => void;
|
||||
saving: boolean;
|
||||
onSave: (value: JsonObject) => void;
|
||||
}> = ({ schema, value, raw, onRaw, saving, onSave }) => {
|
||||
const [draft, setDraft] = useState<JsonObject>(value);
|
||||
const root = schema?.schema ? flatten(schema.schema) : undefined;
|
||||
const props = root?.properties;
|
||||
// Fall back to the JSON editor when there is no schema, or any field is a shape the generic
|
||||
// form can't express (a non-enum union, a $ref). Partial rendering would be worse than none:
|
||||
// a field silently missing from the form is a setting the operator cannot change.
|
||||
const canRender = props !== undefined && Object.values(props).every(renderable);
|
||||
|
||||
if (!canRender) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.library_source_settings_json_hint()}
|
||||
</p>
|
||||
<textarea
|
||||
className="h-64 w-full rounded-md border bg-background p-2 font-mono text-xs"
|
||||
value={raw}
|
||||
onChange={(e) => onRaw(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
try {
|
||||
onSave(JSON.parse(raw) as JsonObject);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
m.library_source_settings_failed({ issue: String(e) }),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{m.library_source_settings_save()}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(props).map(([key, rawNode]) => (
|
||||
<Field
|
||||
key={key}
|
||||
name={key}
|
||||
node={flatten(rawNode)}
|
||||
value={draft[key]}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, [key]: v }))}
|
||||
/>
|
||||
))}
|
||||
<Button disabled={saving} onClick={() => onSave(draft)}>
|
||||
{m.library_source_settings_save()}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** One schema field. `undefined` in the draft means "unset" — the file keeps its default out. */
|
||||
const Field: FC<{
|
||||
name: string;
|
||||
node: JsonSchemaNode;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
}> = ({ name, node, value, onChange }) => {
|
||||
const label = node.title ?? name;
|
||||
const id = `cfg-${name}`;
|
||||
|
||||
if (node.type === "object" && node.properties) {
|
||||
const nested = (value ?? {}) as JsonObject;
|
||||
return (
|
||||
<fieldset className="space-y-3 rounded-lg border p-3">
|
||||
<legend className="px-1 text-sm font-medium">{label}</legend>
|
||||
{Object.entries(node.properties).map(([k, n]) => (
|
||||
<Field
|
||||
key={k}
|
||||
name={`${name}.${k}`}
|
||||
node={flatten(n)}
|
||||
value={nested[k]}
|
||||
onChange={(v) => onChange({ ...nested, [k]: v })}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.enum) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<select
|
||||
id={id}
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={String(value ?? node.default ?? node.enum[0])}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{node.enum.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{node.description && (
|
||||
<p className="text-xs text-muted-foreground">{node.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === "boolean") {
|
||||
const checked = (value ?? node.default ?? false) as boolean;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
{node.description && (
|
||||
<p className="text-xs text-muted-foreground">{node.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === "array") {
|
||||
// One absolute path per line — the shape every "extra library folders" setting wants.
|
||||
const list = (value ?? node.default ?? []) as string[];
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<textarea
|
||||
id={id}
|
||||
className="h-24 w-full rounded-md border bg-background p-2 font-mono text-xs"
|
||||
value={list.join("\n")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.value
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== ""),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{node.description && (
|
||||
<p className="text-xs text-muted-foreground">{node.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const numeric = node.type === "number" || node.type === "integer";
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Input
|
||||
id={id}
|
||||
type={numeric ? "number" : "text"}
|
||||
value={String(value ?? "")}
|
||||
placeholder={node.default != null ? String(node.default) : undefined}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
// An emptied field means "unset", which is NOT the same as zero or "" — it is what
|
||||
// keeps the operator's file free of a value they never chose.
|
||||
if (v === "") return onChange(undefined);
|
||||
onChange(numeric ? Number(v) : v);
|
||||
}}
|
||||
/>
|
||||
{node.description && (
|
||||
<p className="text-xs text-muted-foreground">{node.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Check } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
getGetLibraryQueryKey,
|
||||
getListLibraryScannersQueryKey,
|
||||
useListLibraryScanners,
|
||||
useSetLibraryScanner,
|
||||
} from "@/api/gen/library/library";
|
||||
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* Container: the game-source (library scanner) toggles — owns the scanner query and the toggle
|
||||
* mutation. The host only reports the scanners its platform actually has (Steam everywhere,
|
||||
* Lutris/Heroic on Linux, Epic/GOG/Xbox on Windows), so whatever arrives is renderable as-is.
|
||||
* Rendered only once the list is loaded: this is a secondary control, and when the API is down
|
||||
* the grid's own QueryState already tells the story — no second error banner.
|
||||
*/
|
||||
export const SourceTogglesSection: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const scanners = useListLibraryScanners();
|
||||
const toggle = useSetLibraryScanner();
|
||||
|
||||
const onToggle = async (scanner: ScannerInfo) => {
|
||||
try {
|
||||
// The PUT answers with the full updated list — seed the query cache with it directly,
|
||||
// then refetch the library so the grid reflects the new source set.
|
||||
const list = await toggle.mutateAsync({
|
||||
id: scanner.id,
|
||||
data: { enabled: !scanner.enabled },
|
||||
});
|
||||
qc.setQueryData(getListLibraryScannersQueryKey(), list);
|
||||
await qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
} catch {
|
||||
toast.error(m.library_sources_failed());
|
||||
}
|
||||
};
|
||||
|
||||
if (!scanners.data) return null;
|
||||
return (
|
||||
<SourceToggles
|
||||
scanners={scanners.data}
|
||||
busyId={toggle.isPending ? (toggle.variables?.id ?? null) : null}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** The sources card: one pressed/unpressed chip per scanner (pressed = the host scans it). */
|
||||
export const SourceToggles: FC<{
|
||||
scanners: ScannerInfo[];
|
||||
/** Scanner id whose toggle is in flight, or null — only that chip disables. */
|
||||
busyId: string | null;
|
||||
onToggle: (scanner: ScannerInfo) => void;
|
||||
}> = ({ scanners, busyId, onToggle }) => (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{m.library_sources_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scanners.map((scanner) => (
|
||||
<Button
|
||||
key={scanner.id}
|
||||
size="sm"
|
||||
variant={scanner.enabled ? "default" : "outline"}
|
||||
aria-pressed={scanner.enabled}
|
||||
disabled={busyId === scanner.id}
|
||||
onClick={() => onToggle(scanner)}
|
||||
>
|
||||
{scanner.enabled && <Check className="size-4" />}
|
||||
{scanner.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="max-w-prose text-xs text-muted-foreground">
|
||||
{m.library_sources_help()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Check, Download, Settings2, Trash2 } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import {
|
||||
getGetLibraryQueryKey,
|
||||
getListLibraryScannersQueryKey,
|
||||
useDeleteProviderEntries,
|
||||
useListLibraryScanners,
|
||||
useSetLibraryScanner,
|
||||
} from "@/api/gen/library/library";
|
||||
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
|
||||
import { libraryPlugins, usePlugins } from "@/api/plugins";
|
||||
import {
|
||||
type StoreEntry,
|
||||
useInstallPlugin,
|
||||
useStoreCatalog,
|
||||
} from "@/api/store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { SourceSettingsDialog } from "./SourceSettings";
|
||||
|
||||
/**
|
||||
* **Game sources** — the single surface for "where do my games come from", merging what used to be
|
||||
* two cards (the scanner toggles and the "synced by plugins" list).
|
||||
*
|
||||
* They were split because they were different things: scanners were compiled into the host and
|
||||
* plugins were an afterthought. After the extraction they are the *same* thing — the host reports
|
||||
* one list of sources whose ids match whether they came from a built-in scanner or the plugin that
|
||||
* replaced it — so one surface is both simpler and the only honest presentation (design D6).
|
||||
*
|
||||
* Deliberately kept under the existing "Game sources" label rather than a new "Plugins" heading:
|
||||
* `store_title` and `nav_plugins` are both already "Plugins", and a third would be worse than the
|
||||
* merge is good.
|
||||
*/
|
||||
export const SourcesSection: FC<{
|
||||
/** The provider currently filtered to in the grid, or null for "everything". */
|
||||
activeFilter: string | null;
|
||||
onFilter: (provider: string | null) => void;
|
||||
}> = ({ activeFilter, onFilter }) => {
|
||||
const qc = useQueryClient();
|
||||
const scanners = useListLibraryScanners();
|
||||
const toggle = useSetLibraryScanner();
|
||||
const purge = useDeleteProviderEntries();
|
||||
const plugins = usePlugins();
|
||||
const catalog = useStoreCatalog();
|
||||
const install = useInstallPlugin();
|
||||
const [settingsFor, setSettingsFor] = useState<ScannerInfo | null>(null);
|
||||
|
||||
const onToggle = async (source: ScannerInfo) => {
|
||||
try {
|
||||
// The PUT answers with the full updated list — seed the query cache with it directly,
|
||||
// then refetch the library so the grid reflects the new source set.
|
||||
const list = await toggle.mutateAsync({
|
||||
id: source.id,
|
||||
data: { enabled: !source.enabled },
|
||||
});
|
||||
qc.setQueryData(getListLibraryScannersQueryKey(), list);
|
||||
await qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
} catch {
|
||||
toast.error(m.library_sources_failed());
|
||||
}
|
||||
};
|
||||
|
||||
const onPurge = async (source: ScannerInfo) => {
|
||||
const provider = source.provider ?? source.id;
|
||||
const count = source.entries ?? 0;
|
||||
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
|
||||
try {
|
||||
await purge.mutateAsync({ provider });
|
||||
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
qc.invalidateQueries({ queryKey: getListLibraryScannersQueryKey() });
|
||||
if (activeFilter === provider) onFilter(null);
|
||||
toast.success(m.library_provider_purged({ provider }));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed());
|
||||
}
|
||||
};
|
||||
|
||||
const onInstall = async (entry: StoreEntry) => {
|
||||
try {
|
||||
// Install by (source, id) — the catalogued, integrity-pinned path. The raw-spec form is
|
||||
// for unverified installs and must never be reachable from a one-click rail.
|
||||
await install.mutateAsync({ source: entry.source, id: entry.id });
|
||||
toast.success(m.library_source_installing({ title: entry.title }));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e) ?? m.library_source_install_failed());
|
||||
}
|
||||
};
|
||||
|
||||
// This is a secondary control: when the API is down the grid's own QueryState already tells the
|
||||
// story, so render nothing rather than a second error banner.
|
||||
if (!scanners.data) return null;
|
||||
|
||||
// Catalog rows that are library sources and not already installed — the "Add a source" rail.
|
||||
const installedPkgs = new Set(
|
||||
(catalog.data?.plugins ?? [])
|
||||
.filter((p) => p.installed_version)
|
||||
.map((p) => p.pkg),
|
||||
);
|
||||
const available = (catalog.data?.plugins ?? []).filter(
|
||||
(p) => p.categories?.includes("library") && !installedPkgs.has(p.pkg),
|
||||
);
|
||||
const running = new Set(libraryPlugins(plugins.data).map((p) => p.id));
|
||||
|
||||
// The bridge-release nudge (design D9): a built-in scanner still doing the work, with its
|
||||
// replacement plugin sitting uninstalled in the catalog. One click per scanner, and NEVER a
|
||||
// silent auto-install — installing code stays an explicit operator act.
|
||||
const migratable = scanners.data
|
||||
.filter((s) => s.origin === "builtin" && s.enabled)
|
||||
.map((s) => ({
|
||||
source: s,
|
||||
entry: available.find((p) => p.id === s.id && p.compatible),
|
||||
}))
|
||||
.filter((r): r is { source: ScannerInfo; entry: StoreEntry } => !!r.entry);
|
||||
|
||||
return (
|
||||
<>
|
||||
{migratable.length > 0 && (
|
||||
<MigrationBanner
|
||||
rows={migratable}
|
||||
busy={catalog.data?.busy === true || install.isPending}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
)}
|
||||
<SourcesCard
|
||||
sources={scanners.data}
|
||||
available={available}
|
||||
running={running}
|
||||
busyId={toggle.isPending ? (toggle.variables?.id ?? null) : null}
|
||||
installBusy={catalog.data?.busy === true || install.isPending}
|
||||
activeFilter={activeFilter}
|
||||
onToggle={onToggle}
|
||||
onFilter={onFilter}
|
||||
onSettings={setSettingsFor}
|
||||
onPurge={onPurge}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
{settingsFor && (
|
||||
<SourceSettingsDialog
|
||||
source={settingsFor}
|
||||
onClose={() => setSettingsFor(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* "Game sources are moving to plugins" — shown only while a built-in scanner is still doing a job a
|
||||
* catalogued plugin could take over.
|
||||
*
|
||||
* One button per scanner rather than a single "migrate everything": installing a plugin is an
|
||||
* explicit operator act under the store's consent model, and per-scanner is also what makes it safe
|
||||
* to repeat — the claim suppresses the built-in idempotently (design D2), so a half-finished
|
||||
* migration is a valid state rather than a mess.
|
||||
*/
|
||||
export const MigrationBanner: FC<{
|
||||
rows: ReadonlyArray<{ source: ScannerInfo; entry: StoreEntry }>;
|
||||
busy: boolean;
|
||||
onInstall: (entry: StoreEntry) => void;
|
||||
}> = ({ rows, busy, onInstall }) => (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{m.library_migrate_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.library_migrate_help()}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rows.map(({ source, entry }) => (
|
||||
<Button
|
||||
key={source.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => onInstall(entry)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{m.library_migrate_install({ source: source.label })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
/** The sources card itself — presentational, so Storybook can drive every state. */
|
||||
export const SourcesCard: FC<{
|
||||
sources: ScannerInfo[];
|
||||
/** Catalog rows offering a library source that isn't installed yet. */
|
||||
available: StoreEntry[];
|
||||
/** Ids of library plugins whose lease is currently live. */
|
||||
running: Set<string>;
|
||||
/** Source id whose toggle is in flight, or null — only that row disables. */
|
||||
busyId: string | null;
|
||||
installBusy: boolean;
|
||||
activeFilter: string | null;
|
||||
onToggle: (source: ScannerInfo) => void;
|
||||
onFilter: (provider: string | null) => void;
|
||||
onSettings: (source: ScannerInfo) => void;
|
||||
onPurge: (source: ScannerInfo) => void;
|
||||
onInstall: (entry: StoreEntry) => void;
|
||||
}> = ({
|
||||
sources,
|
||||
available,
|
||||
running,
|
||||
busyId,
|
||||
installBusy,
|
||||
activeFilter,
|
||||
onToggle,
|
||||
onFilter,
|
||||
onSettings,
|
||||
onPurge,
|
||||
onInstall,
|
||||
}) => (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{m.library_sources_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{sources.map((source) => (
|
||||
<SourceRow
|
||||
key={source.id}
|
||||
source={source}
|
||||
running={running.has(source.id)}
|
||||
busy={busyId === source.id}
|
||||
filtered={
|
||||
activeFilter !== null &&
|
||||
activeFilter === (source.provider ?? source.id)
|
||||
}
|
||||
onToggle={() => onToggle(source)}
|
||||
onFilter={() => {
|
||||
const p = source.provider ?? source.id;
|
||||
onFilter(activeFilter === p ? null : p);
|
||||
}}
|
||||
onSettings={() => onSettings(source)}
|
||||
onPurge={() => onPurge(source)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="max-w-prose text-xs text-muted-foreground">
|
||||
{m.library_sources_help()}
|
||||
</p>
|
||||
|
||||
{available.length > 0 && (
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<p className="text-sm font-medium">{m.library_add_source()}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{available.map((entry) => (
|
||||
<Button
|
||||
key={entry.pkg}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!entry.compatible || installBusy}
|
||||
title={entry.incompatible_reason ?? entry.description}
|
||||
onClick={() => onInstall(entry)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{entry.title}
|
||||
{/* `detected` is tri-state: only badge a POSITIVE probe. An entry with
|
||||
no probes for this platform is "unknown", and labelling that "not
|
||||
installed" would be a lie. */}
|
||||
{entry.detected === true && (
|
||||
<Badge variant="secondary">
|
||||
{m.library_source_detected()}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
/** One source row: enable toggle, provenance, counts, and its per-source actions. */
|
||||
const SourceRow: FC<{
|
||||
source: ScannerInfo;
|
||||
/** The plugin backing this source is currently registered (its lease is live). */
|
||||
running: boolean;
|
||||
busy: boolean;
|
||||
filtered: boolean;
|
||||
onToggle: () => void;
|
||||
onFilter: () => void;
|
||||
onSettings: () => void;
|
||||
onPurge: () => void;
|
||||
}> = ({
|
||||
source,
|
||||
running,
|
||||
busy,
|
||||
filtered,
|
||||
onToggle,
|
||||
onFilter,
|
||||
onSettings,
|
||||
onPurge,
|
||||
}) => {
|
||||
const isPlugin = source.origin === "plugin";
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border p-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={source.enabled ? "default" : "outline"}
|
||||
aria-pressed={source.enabled}
|
||||
disabled={busy}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{source.enabled && <Check className="size-4" />}
|
||||
{source.label}
|
||||
</Button>
|
||||
{isPlugin && (
|
||||
<Badge variant={running ? "secondary" : "outline"}>
|
||||
{running ? m.library_source_running() : m.library_source_stopped()}
|
||||
</Badge>
|
||||
)}
|
||||
{source.entries != null && (
|
||||
<Badge variant="secondary">
|
||||
{m.library_provider_count({ count: source.entries })}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
{isPlugin && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filtered ? "default" : "outline"}
|
||||
aria-pressed={filtered}
|
||||
onClick={onFilter}
|
||||
>
|
||||
{filtered
|
||||
? m.library_provider_show_all()
|
||||
: m.library_provider_filter()}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={m.library_source_settings()}
|
||||
onClick={onSettings}
|
||||
>
|
||||
<Settings2 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={m.library_provider_purge()}
|
||||
onClick={onPurge}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,8 +7,7 @@ import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { type FormTarget, GameFormSection } from "./GameForm";
|
||||
import { LibraryGridSection } from "./LibraryGrid";
|
||||
import { ProvidersCard } from "./Providers";
|
||||
import { SourceTogglesSection } from "./SourceToggles";
|
||||
import { SourcesSection } from "./Sources";
|
||||
|
||||
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
|
||||
// (LibraryGrid / GameForm) so the two concerns never share a component. This container owns only the
|
||||
@@ -44,11 +43,8 @@ export const SectionLibrary: FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SourceTogglesSection />
|
||||
|
||||
<ProvidersCard
|
||||
entries={entries}
|
||||
active={providerFilter}
|
||||
<SourcesSection
|
||||
activeFilter={providerFilter}
|
||||
onFilter={setProviderFilter}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { GameForm } from "@/sections/Library/GameForm";
|
||||
import { LibraryGrid } from "@/sections/Library/LibraryGrid";
|
||||
import { SourceToggles } from "@/sections/Library/SourceToggles";
|
||||
import { MigrationBanner, SourcesCard } from "@/sections/Library/Sources";
|
||||
import { library } from "./lib/fixtures";
|
||||
|
||||
const noop = () => {};
|
||||
@@ -45,6 +45,31 @@ export const Populated: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
/** Launcher entries (design D4) get their own rail above the grid. */
|
||||
export const WithLaunchers: Story = {
|
||||
render: () => (
|
||||
<LibraryGrid
|
||||
library={{
|
||||
data: [
|
||||
{
|
||||
id: "steam:bigpicture",
|
||||
store: "steam",
|
||||
title: "Steam Big Picture",
|
||||
art: { portrait: null, hero: null, logo: null, header: null },
|
||||
role: "launcher",
|
||||
launch: { kind: "steam_ui", value: "bigpicture" },
|
||||
},
|
||||
...library,
|
||||
],
|
||||
...idle,
|
||||
}}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
deletingId={null}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<LibraryGrid
|
||||
@@ -56,17 +81,148 @@ export const Empty: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
/** A catalog row for the "Add a source" rail — only the fields the card actually reads. */
|
||||
const catalogEntry = (
|
||||
over: Partial<Parameters<typeof SourcesCard>[0]["available"][number]>,
|
||||
) =>
|
||||
({
|
||||
id: "steam",
|
||||
pkg: "@punktfunk/plugin-steam",
|
||||
title: "Steam",
|
||||
description: "Steam library scanner",
|
||||
author: "unom",
|
||||
version: "0.1.0",
|
||||
source: "unom",
|
||||
tier: "verified",
|
||||
platforms: [],
|
||||
compatible: true,
|
||||
update_available: false,
|
||||
categories: ["library"],
|
||||
...over,
|
||||
}) as Parameters<typeof SourcesCard>[0]["available"][number];
|
||||
|
||||
const sourcesArgs = {
|
||||
available: [],
|
||||
running: new Set<string>(),
|
||||
busyId: null,
|
||||
installBusy: false,
|
||||
activeFilter: null,
|
||||
onToggle: noop,
|
||||
onFilter: noop,
|
||||
onSettings: noop,
|
||||
onPurge: noop,
|
||||
onInstall: noop,
|
||||
};
|
||||
|
||||
/** The bridge-release shape: built-in scanners only, one turned off. */
|
||||
export const Sources: Story = {
|
||||
render: () => (
|
||||
<SourceToggles
|
||||
// A Linux host's scanner set, one turned off — the widest built-in list.
|
||||
scanners={[
|
||||
{ id: "steam", label: "Steam", enabled: true },
|
||||
{ id: "lutris", label: "Lutris", enabled: false },
|
||||
{ id: "heroic", label: "Heroic (Epic / GOG / Amazon)", enabled: true },
|
||||
<SourcesCard
|
||||
{...sourcesArgs}
|
||||
sources={[
|
||||
{ id: "steam", label: "Steam", enabled: true, origin: "builtin" },
|
||||
{ id: "lutris", label: "Lutris", enabled: false, origin: "builtin" },
|
||||
{
|
||||
id: "heroic",
|
||||
label: "Heroic (Epic / GOG / Amazon)",
|
||||
enabled: true,
|
||||
origin: "builtin",
|
||||
},
|
||||
]}
|
||||
busyId={null}
|
||||
onToggle={noop}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Mid-migration: a claimed plugin source beside the remaining built-ins, one plugin stopped. */
|
||||
export const SourcesWithPlugins: Story = {
|
||||
render: () => (
|
||||
<SourcesCard
|
||||
{...sourcesArgs}
|
||||
sources={[
|
||||
{
|
||||
id: "steam",
|
||||
label: "Steam",
|
||||
enabled: true,
|
||||
origin: "plugin",
|
||||
provider: "steam",
|
||||
entries: 214,
|
||||
},
|
||||
{
|
||||
id: "lutris",
|
||||
label: "Lutris",
|
||||
enabled: false,
|
||||
origin: "plugin",
|
||||
provider: "lutris",
|
||||
entries: 12,
|
||||
},
|
||||
{
|
||||
id: "heroic",
|
||||
label: "Heroic (Epic / GOG / Amazon)",
|
||||
enabled: true,
|
||||
origin: "builtin",
|
||||
},
|
||||
]}
|
||||
running={new Set(["steam"])}
|
||||
available={[
|
||||
catalogEntry({ pkg: "@punktfunk/plugin-heroic", title: "Heroic" }),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** A fresh host after extraction: nothing installed, two launchers detected on this box. */
|
||||
export const SourcesEmptyWithDetected: Story = {
|
||||
render: () => (
|
||||
<SourcesCard
|
||||
{...sourcesArgs}
|
||||
sources={[]}
|
||||
available={[
|
||||
catalogEntry({ detected: true }),
|
||||
catalogEntry({
|
||||
pkg: "@punktfunk/plugin-lutris",
|
||||
title: "Lutris",
|
||||
detected: true,
|
||||
}),
|
||||
catalogEntry({
|
||||
pkg: "@punktfunk/plugin-heroic",
|
||||
title: "Heroic",
|
||||
detected: false,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** The bridge-release nudge — one button per still-built-in scanner, never an auto-install. */
|
||||
export const Migration: Story = {
|
||||
render: () => (
|
||||
<MigrationBanner
|
||||
rows={[
|
||||
{
|
||||
source: {
|
||||
id: "steam",
|
||||
label: "Steam",
|
||||
enabled: true,
|
||||
origin: "builtin",
|
||||
},
|
||||
entry: catalogEntry({}),
|
||||
},
|
||||
{
|
||||
source: {
|
||||
id: "lutris",
|
||||
label: "Lutris",
|
||||
enabled: true,
|
||||
origin: "builtin",
|
||||
},
|
||||
entry: catalogEntry({
|
||||
pkg: "@punktfunk/plugin-lutris",
|
||||
id: "lutris",
|
||||
title: "Lutris",
|
||||
}),
|
||||
},
|
||||
]}
|
||||
busy={false}
|
||||
onInstall={noop}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user