feat(host/library): external provider API — declarative reconcile (M4)
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m37s
windows-host / package (push) Successful in 8m57s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 58s
ci / bench (push) Successful in 5m51s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
arch / build-publish (push) Successful in 16m51s
android / android (push) Successful in 17m6s
deb / build-publish (push) Successful in 17m13s
ci / rust (push) Successful in 18m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m31s
docker / deploy-docs (push) Successful in 10s
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m37s
windows-host / package (push) Successful in 8m57s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 58s
ci / bench (push) Successful in 5m51s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
arch / build-publish (push) Successful in 16m51s
android / android (push) Successful in 17m6s
deb / build-publish (push) Successful in 17m13s
ci / rust (push) Successful in 18m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m31s
docker / deploy-docs (push) Successful in 10s
External game-library providers become first-class (RFC §8): a plugin
computes its desired title list and PUTs it — the host owns the diff.
- CustomEntry gains `provider` + `external_id` (API-set only; never on
manual entries). GameEntry surfaces `provider` for console attribution
and the new `GET /library?provider=` filter.
- PUT /api/v1/library/provider/{p}: atomic declarative reconcile keyed
on the provider's `external_id` — host ids stay stable across syncs,
orphans drop, manual entries and other providers are never touched,
an empty array clears the set. Validated: provider id [a-z0-9._-]
(`manual` reserved), unique non-empty external_ids.
- DELETE /api/v1/library/provider/{p}: clean uninstall, returns the
removed count.
- Ownership is unambiguous both ways: manual CRUD now returns 409 for a
provider-owned entry (MutateOutcome::ProviderOwned) instead of letting
an edit be silently clobbered at the next sync.
- library.changed now carries the mutating source (`manual` or the
provider id) — hooks and the SDK filter on it.
- Spec + SDK schemas regenerated; sdk/examples/provider-sync.ts is the
provider-plugin skeleton.
347 host tests green (pure reconcile: stable ids, orphan drop,
idempotence, bystanders untouched; name/payload validation; route 400s)
+ 11 SDK tests. Live-verified end to end THROUGH the SDK against a real
host: sync → filtered list → manual-delete 409 → re-sync with stable id
+ orphan drop → uninstall (removed=2), with three
library.changed(source=romm) events observed on the live stream.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
// The external game-library provider pattern (RFC §8): compute your desired title list and
|
||||
// declaratively PUT it — the host diffs by your `external_id`, keeps host ids stable across
|
||||
// syncs, drops orphans, and never touches manual entries. Uninstall = one DELETE.
|
||||
import { connect } from "../src/index.js";
|
||||
|
||||
const PROVIDER = "romm"; // your punktfunk-plugin-* name
|
||||
|
||||
const pf = await connect();
|
||||
pf.events.on("library.changed", (e) => {
|
||||
if (e.source === PROVIDER) console.log("library synced");
|
||||
});
|
||||
|
||||
// Fetch your source of truth (a ROM manager, itch.io, a curated list…), then reconcile:
|
||||
const desired = [
|
||||
{ external_id: "rom-1", title: "Chrono Trigger", launch: { command: "retroarch ..." } },
|
||||
{ external_id: "rom-2", title: "Super Metroid", launch: { command: "retroarch ..." } },
|
||||
];
|
||||
const entries = (await pf.request("PUT", `/library/provider/${PROVIDER}`, desired)) as {
|
||||
id: string;
|
||||
title: string;
|
||||
}[];
|
||||
console.log(`synced ${entries.length} titles:`, entries.map((e) => `${e.title} (custom:${e.id})`));
|
||||
|
||||
// …run on a schedule, or keep watching your source. Clean uninstall:
|
||||
// await pf.request("DELETE", `/library/provider/${PROVIDER}`);
|
||||
pf.close();
|
||||
+82
-1
@@ -963,9 +963,14 @@ export const GetHostInfoResponse = S.Struct({
|
||||
/**
|
||||
* Every installed-store title (Steam, read from the host's local files — no Steam API key)
|
||||
* merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
|
||||
* fetches directly (the public Steam CDN for Steam titles).
|
||||
* fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
|
||||
* entries a given external provider owns.
|
||||
* @summary List the game library
|
||||
*/
|
||||
export const GetLibraryQueryParams = S.Struct({
|
||||
"provider": S.optional(S.String).annotations({ description: 'Only entries owned by this external provider' })
|
||||
})
|
||||
|
||||
export const GetLibraryResponseItem = S.Struct({
|
||||
"art": S.Struct({
|
||||
"header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }),
|
||||
@@ -978,6 +983,7 @@ export const GetLibraryResponseItem = S.Struct({
|
||||
"kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }),
|
||||
"value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' })
|
||||
}).annotations({ description: 'How the host would launch it, when known.' }))),
|
||||
"provider": S.optional(S.NullOr(S.String)).annotations({ 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.' }),
|
||||
"store": S.String.annotations({ description: 'Which store surfaced it: `\"steam\"` or `\"custom\"`.' }),
|
||||
"title": S.String
|
||||
}).annotations({ description: 'One title in the unified library, regardless of which store it came from.' })
|
||||
@@ -1055,6 +1061,7 @@ export const UpdateCustomGameResponse = S.Struct({
|
||||
"logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }),
|
||||
"portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' })
|
||||
})).annotations({ description: 'Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).' }),
|
||||
"external_id": S.optional(S.NullOr(S.String)).annotations({ description: 'The provider\'s own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is.' }),
|
||||
"id": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }),
|
||||
"launch": S.optional(S.Union(S.Null, S.Struct({
|
||||
"kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }),
|
||||
@@ -1064,6 +1071,7 @@ export const UpdateCustomGameResponse = S.Struct({
|
||||
"do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }),
|
||||
"undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' })
|
||||
}).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`]).' }),
|
||||
"provider": S.optional(S.NullOr(S.String)).annotations({ 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).' }),
|
||||
"title": S.String
|
||||
}).annotations({ description: 'A user-added title, persisted in `~\/.config\/punktfunk\/library.json`. Same shape the API\nreturns and the web console edits.' })
|
||||
|
||||
@@ -1076,6 +1084,79 @@ export const DeleteCustomGameParams = S.Struct({
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
|
||||
* provider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each
|
||||
* 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`.
|
||||
* @summary Replace a provider's library entries (declarative reconcile)
|
||||
*/
|
||||
export const ReconcileProviderEntriesParams = S.Struct({
|
||||
"provider": S.String.annotations({ description: 'The provider id ([a-z0-9._-], `manual` reserved)' })
|
||||
})
|
||||
|
||||
export const ReconcileProviderEntriesBodyItem = S.Struct({
|
||||
"art": S.optional(S.Struct({
|
||||
"header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }),
|
||||
"hero": S.optional(S.NullOr(S.String)).annotations({ description: 'Wide background (Steam `library_hero.jpg`).' }),
|
||||
"logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }),
|
||||
"portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' })
|
||||
})).annotations({ description: 'Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).' }),
|
||||
"external_id": S.String.annotations({ description: 'The provider\'s stable id for this title (the reconcile diff key).' }),
|
||||
"launch": S.optional(S.Union(S.Null, S.Struct({
|
||||
"kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }),
|
||||
"value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' })
|
||||
}).annotations({ description: 'How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam:\/\/rungameid\/<value>`;\n`command` → run `<value>` nested in a gamescope session.' }))),
|
||||
"prep": S.optional(S.Array(S.Struct({
|
||||
"do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }),
|
||||
"undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' })
|
||||
}).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps — commands run as the host user; operator-privileged config.' }),
|
||||
"title": S.String
|
||||
}).annotations({ description: 'One title in a provider\'s declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider\'s required stable key.' })
|
||||
export const ReconcileProviderEntriesBody = S.Array(ReconcileProviderEntriesBodyItem)
|
||||
|
||||
export const ReconcileProviderEntriesResponseItem = S.Struct({
|
||||
"art": S.optional(S.Struct({
|
||||
"header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }),
|
||||
"hero": S.optional(S.NullOr(S.String)).annotations({ description: 'Wide background (Steam `library_hero.jpg`).' }),
|
||||
"logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }),
|
||||
"portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' })
|
||||
})).annotations({ description: 'Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).' }),
|
||||
"external_id": S.optional(S.NullOr(S.String)).annotations({ description: 'The provider\'s own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is.' }),
|
||||
"id": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }),
|
||||
"launch": S.optional(S.Union(S.Null, S.Struct({
|
||||
"kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }),
|
||||
"value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' })
|
||||
}).annotations({ description: 'How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam:\/\/rungameid\/<value>`;\n`command` → run `<value>` nested in a gamescope session.' }))),
|
||||
"prep": S.optional(S.Array(S.Struct({
|
||||
"do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }),
|
||||
"undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' })
|
||||
}).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`]).' }),
|
||||
"provider": S.optional(S.NullOr(S.String)).annotations({ 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).' }),
|
||||
"title": S.String
|
||||
}).annotations({ description: 'A user-added title, persisted in `~\/.config\/punktfunk\/library.json`. Same shape the API\nreturns and the web console edits.' })
|
||||
export const ReconcileProviderEntriesResponse = S.Array(ReconcileProviderEntriesResponseItem)
|
||||
|
||||
|
||||
/**
|
||||
* Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin
|
||||
* (RFC §8). Emits `library.changed` when anything was removed.
|
||||
* @summary Remove a provider's library entries
|
||||
*/
|
||||
export const DeleteProviderEntriesParams = S.Struct({
|
||||
"provider": S.String.annotations({ description: 'The provider id' })
|
||||
})
|
||||
|
||||
export const deleteProviderEntriesResponseRemovedMin = 0;
|
||||
|
||||
|
||||
|
||||
export const DeleteProviderEntriesResponse = S.Struct({
|
||||
"removed": S.Number.pipe(S.greaterThanOrEqualTo(deleteProviderEntriesResponseRemovedMin)).annotations({ description: 'How many entries the provider owned (and were removed).' })
|
||||
}).annotations({ description: 'The count envelope a provider uninstall returns.' })
|
||||
|
||||
|
||||
/**
|
||||
* Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device
|
||||
* names). Unauthenticated, but served to loopback peers only.
|
||||
|
||||
Reference in New Issue
Block a user