diff --git a/api/openapi.json b/api/openapi.json index cc52098b..ff9f4a57 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -2613,6 +2613,640 @@ } } } + }, + "/api/v1/store/catalog": { + "get": { + "tags": [ + "store" + ], + "summary": "Browse the plugin catalog", + "description": "The merged shelf across every configured source, annotated with what this host already has and\nwhat it can run. Sources past their freshness window are refreshed first; a source that can't be\nreached keeps serving its last good copy, marked `stale` (a LAN-only host still has a working\nstore — an entry's pin travelled with the entry).", + "operationId": "getPluginCatalog", + "responses": { + "200": { + "description": "The merged catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CatalogResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/install": { + "post": { + "tags": [ + "store" + ], + "summary": "Install a plugin", + "description": "Either `{source, id}` — a catalogued entry, installed at its pinned version after its integrity\nis re-checked against the registry — or `{spec, accept_unverified: true}`, which installs an\nunreviewed package the operator takes responsibility for. Returns `202` with a job id; watch it\nat `GET /store/jobs/{id}`.\n\nOne package operation runs at a time (`409` otherwise): `bun` operations share a lockfile and a\n`node_modules` tree.", + "operationId": "installPlugin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Install job started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobRef" + } + } + } + }, + "400": { + "description": "Unknown entry, bad spec, or missing acknowledgement", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Another package operation is in flight", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/installed": { + "get": { + "tags": [ + "store" + ], + "summary": "List installed plugins", + "description": "What's actually in the plugins directory, joined with how it got there (the provenance manifest)\nand whether it is registered right now. A package with no provenance record was installed with\nthe CLI and reports `tier: \"cli\"` — absence is the answer, not a gap.", + "operationId": "listInstalledPlugins", + "responses": { + "200": { + "description": "Installed plugin packages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InstalledView" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/jobs": { + "get": { + "tags": [ + "store" + ], + "summary": "List recent package jobs", + "operationId": "listPluginJobs", + "responses": { + "200": { + "description": "Recent install/uninstall jobs, oldest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/jobs/{id}": { + "get": { + "tags": [ + "store" + ], + "summary": "Follow one package job", + "description": "Poll this while `state` is `running`; `log` carries the tail of the package manager's output.", + "operationId": "getPluginJob", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The job id returned by install/uninstall", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The job", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Job" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such job (they are kept for a bounded history)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/refresh": { + "post": { + "tags": [ + "store" + ], + "summary": "Refresh every catalog now", + "description": "Bypasses the freshness window and re-fetches all sources, then returns the merged catalog.", + "operationId": "refreshPluginCatalog", + "responses": { + "200": { + "description": "The freshly-fetched catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CatalogResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/runtime": { + "get": { + "tags": [ + "store" + ], + "summary": "Plugin runner state", + "description": "Installed plugins only run while the runner is on, and the runner discovers units at startup —\nso this is both the \"is anything running\" answer and the explanation for a freshly installed\nplugin that hasn't appeared yet.", + "operationId": "getPluginRuntime", + "responses": { + "200": { + "description": "Runner state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeView" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "post": { + "tags": [ + "store" + ], + "summary": "Turn the plugin runner on or off", + "operationId": "setPluginRuntime", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The resulting runner state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeView" + } + } + } + }, + "400": { + "description": "The runner could not be switched", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/sources": { + "get": { + "tags": [ + "store" + ], + "summary": "List catalog sources", + "operationId": "listPluginSources", + "responses": { + "200": { + "description": "Configured sources, built-in first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SourceView" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/sources/{name}": { + "put": { + "tags": [ + "store" + ], + "summary": "Add or update a catalog source", + "description": "Adding a source is a trust decision: its entries become installable on this host. They are\nattributed to it in the console and never carry the \"verified\" badge, which belongs to the\nbuilt-in source alone.", + "operationId": "putPluginSource", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Source slug (`[a-z][a-z0-9-]*`)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceInput" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Source saved" + }, + "400": { + "description": "Invalid name, url or key — or the reserved built-in name", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "store" + ], + "summary": "Remove a catalog source", + "operationId": "deletePluginSource", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Source slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Removed (or already absent)" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "The built-in source cannot be removed, or the plugin token is not authorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/store/uninstall": { + "post": { + "tags": [ + "store" + ], + "summary": "Uninstall a plugin", + "description": "Removes the package and forgets its provenance, then restarts the runner. Only names the runner\nwould actually supervise are accepted, so this can't be used to rip a shared dependency out of\nthe tree.", + "operationId": "uninstallPlugin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UninstallRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Uninstall job started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobRef" + } + } + } + }, + "400": { + "description": "Not a plugin package name", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not authorized for the plugin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Another package operation is in flight", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } } }, "components": { @@ -3007,6 +3641,147 @@ } } }, + "CatalogEntry": { + "type": "object", + "description": "One row on the shelf.", + "required": [ + "id", + "pkg", + "title", + "description", + "author", + "version", + "source", + "tier", + "platforms", + "compatible", + "update_available" + ], + "properties": { + "author": { + "type": "string" + }, + "blocked": { + "type": [ + "string", + "null" + ], + "description": "A revocation covering the catalogued version — do not offer this without shouting." + }, + "compatible": { + "type": "boolean", + "description": "Can this host install it?" + }, + "description": { + "type": "string" + }, + "homepage": { + "type": [ + "string", + "null" + ] + }, + "icon": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "incompatible_reason": { + "type": [ + "string", + "null" + ] + }, + "installed_version": { + "type": [ + "string", + "null" + ], + "description": "The version installed right now, if any." + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "min_host": { + "type": [ + "string", + "null" + ] + }, + "pkg": { + "type": "string" + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + } + }, + "reviewed_at": { + "type": [ + "string", + "null" + ], + "description": "When unom reviewed this exact tarball (built-in source only)." + }, + "source": { + "type": "string", + "description": "Which source listed it." + }, + "tier": { + "type": "string", + "description": "`verified` (built-in source) or `external` (an operator-added source). Never `unverified`:\nunverified installs come from a raw spec and are never listed (D7)." + }, + "title": { + "type": "string" + }, + "update_available": { + "type": "boolean", + "description": "Installed, but at a different version than the catalog pins." + }, + "version": { + "type": "string", + "description": "The one installable version this entry pins." + } + } + }, + "CatalogResponse": { + "type": "object", + "required": [ + "host", + "sources", + "plugins", + "busy" + ], + "properties": { + "busy": { + "type": "boolean", + "description": "True while a package operation is in flight — the console disables install buttons." + }, + "host": { + "$ref": "#/components/schemas/HostFacts" + }, + "plugins": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CatalogEntry" + } + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SourceView" + } + } + } + }, "ClientRef": { "type": "object", "description": "The connecting/disconnecting client's identity.", @@ -3602,6 +4377,21 @@ } } }, + { + "type": "object", + "description": "The set of installed plugins, or what the store knows about them, changed — an install or\nuninstall finished, or a catalog refresh brought in new rows. A consumer re-reads\n`GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer\nis a join over several sources of truth, so \"go look again\" is the only honest signal.", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "store.changed" + ] + } + } + }, { "type": "object", "required": [ @@ -3935,6 +4725,23 @@ ], "description": "One host lifecycle event, as it will appear on the wire (`data:` of one SSE frame)." }, + "HostFacts": { + "type": "object", + "description": "Facts about this host, so the console can grey out rows it can't install.", + "required": [ + "version", + "platform" + ], + "properties": { + "platform": { + "type": "string", + "description": "`linux` / `windows` / `macos`." + }, + "version": { + "type": "string" + } + } + }, "HostInfo": { "type": "object", "description": "Host identity and advertised capabilities (static for the life of the process).", @@ -4005,6 +4812,182 @@ "per-client-mode" ] }, + "InstallRequest": { + "type": "object", + "description": "`POST /store/install` — either a catalogued entry, or a raw spec the operator owns.", + "properties": { + "accept_unverified": { + "type": "boolean", + "description": "Required with [`Self::spec`]: the operator's explicit acknowledgement that this installs\nunreviewed code with operator privileges. The console collects it behind a typed\nconfirmation; the API refuses without it so no other caller can skip the decision." + }, + "id": { + "type": [ + "string", + "null" + ], + "description": "Catalog entry id (with [`Self::source`])." + }, + "source": { + "type": [ + "string", + "null" + ], + "description": "Catalog source name (with [`Self::id`])." + }, + "spec": { + "type": [ + "string", + "null" + ], + "description": "A raw package spec (`@scope/name`, `@scope/name@1.2.3`, an https tarball or git+https URL).\nNothing reviewed it and nothing pins it." + } + } + }, + "InstalledView": { + "type": "object", + "description": "An installed plugin package, joined with its provenance and whether it's actually running.", + "required": [ + "pkg", + "tier", + "running" + ], + "properties": { + "blocked": { + "type": [ + "string", + "null" + ], + "description": "A revocation covering the *installed* version. Reported, never auto-removed: silently\ndeleting running code is its own hazard, so the operator decides." + }, + "entry_id": { + "type": [ + "string", + "null" + ], + "description": "The catalog entry this maps to, when it is on a shelf we know." + }, + "installed_at": { + "type": [ + "string", + "null" + ] + }, + "pkg": { + "type": "string" + }, + "plugin_id": { + "type": [ + "string", + "null" + ], + "description": "The plugin id it registers under — the key into `GET /plugins`." + }, + "running": { + "type": "boolean", + "description": "Is it registered in the live lease registry right now?" + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "tier": { + "type": "string", + "description": "`verified` / `external` / `unverified` / `cli` — remembered from install time, so an\nunverified plugin stays visibly unverified long after the dialog is forgotten." + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "update_available": { + "type": [ + "string", + "null" + ], + "description": "The catalog's version, when it's newer than what's installed." + }, + "version": { + "type": [ + "string", + "null" + ] + } + } + }, + "Job": { + "type": "object", + "description": "A job as the console sees it. Field names are snake_case like the rest of the management API\n(the *file* formats — index, sources, manifest — follow npm's camelCase instead).", + "required": [ + "id", + "kind", + "target", + "state", + "phase", + "log", + "started_at" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "finished_at": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "`install` or `uninstall`." + }, + "log": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tail of the runner's combined stdout/stderr." + }, + "phase": { + "type": "string", + "description": "Coarse step name, for a progress line the operator can read." + }, + "started_at": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/State" + }, + "target": { + "type": "string", + "description": "What the operator asked for — a package name, or the raw spec they typed." + } + } + }, + "JobRef": { + "type": "object", + "description": "202 body: where to watch the work.", + "required": [ + "job" + ], + "properties": { + "job": { + "type": "string" + } + } + }, "KeepAlive": { "oneOf": [ { @@ -4723,6 +5706,17 @@ } } }, + "RuntimeRequest": { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "RuntimeStatus": { "type": "object", "description": "Live host status (changes as clients launch/end sessions).", @@ -4782,6 +5776,44 @@ } } }, + "RuntimeView": { + "type": "object", + "required": [ + "installed", + "enabled", + "running", + "unit" + ], + "properties": { + "detail": { + "type": [ + "string", + "null" + ] + }, + "enabled": { + "type": "boolean" + }, + "installed": { + "type": "boolean", + "description": "Is the runner payload/unit present at all?" + }, + "principal": { + "type": [ + "string", + "null" + ], + "description": "Windows: the account the task runs as." + }, + "running": { + "type": "boolean" + }, + "unit": { + "type": "string", + "description": "systemd unit or scheduled-task name." + } + } + }, "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.", @@ -4897,6 +5929,83 @@ } } }, + "SourceInput": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "public_key": { + "type": [ + "string", + "null" + ], + "description": "`ed25519:`. Omitted ⇒ an unsigned source (accepted, flagged everywhere)." + }, + "url": { + "type": "string" + } + } + }, + "SourceView": { + "type": "object", + "description": "A configured catalog source and how its last refresh went.", + "required": [ + "name", + "url", + "builtin", + "signed", + "stale", + "entry_count" + ], + "properties": { + "builtin": { + "type": "boolean", + "description": "The built-in `unom` source: not editable, not removable, and the only source whose entries\nmay carry the \"verified\" tier." + }, + "entry_count": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "error": { + "type": [ + "string", + "null" + ], + "description": "Why the last refresh failed, if it did." + }, + "fetched_at": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Unix seconds of the data we hold, when we hold any.", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "public_key": { + "type": [ + "string", + "null" + ] + }, + "signed": { + "type": "boolean", + "description": "Whether we check a signature on this source's index. An unsigned source still works; the\nconsole marks it." + }, + "stale": { + "type": "boolean", + "description": "The catalog we're serving is older than the last refresh attempt (offline, or the last\nfetch failed) — entries still install, because the pin travelled with the entry." + }, + "url": { + "type": "string" + } + } + }, "StageTiming": { "type": "object", "description": "One pipeline stage's latency in an aggregation window (microseconds).", @@ -4920,6 +6029,14 @@ } } }, + "State": { + "type": "string", + "enum": [ + "running", + "done", + "failed" + ] + }, "StatsSample": { "type": "object", "description": "One aggregated sample (~ every 2 s native, ~ every 1 s GameStream).", @@ -5184,6 +6301,17 @@ "type": "string" } } + }, + "UninstallRequest": { + "type": "object", + "required": [ + "pkg" + ], + "properties": { + "pkg": { + "type": "string" + } + } } }, "securitySchemes": { @@ -5250,6 +6378,10 @@ { "name": "plugins", "description": "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav" + }, + { + "name": "store", + "description": "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on" } ] } diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 9a380ddd..b1e85b83 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -434,6 +434,46 @@ fn plugin_allowlist_excludes_escalation_routes() { &Method::GET, "/api/v1/plugins/x/ui-credential" )); + + // The plugin STORE, wholesale. Installing a plugin runs new code with operator privileges, so a + // plugin able to do it could install a helper that isn't constrained the way it is — and + // `POST /store/runtime` would let it switch its own supervisor. Denied by whole-prefix so a + // route added here later is denied by default rather than by remembering to list it. + for path in [ + "/api/v1/store/catalog", + "/api/v1/store/installed", + "/api/v1/store/sources", + "/api/v1/store/jobs", + "/api/v1/store/jobs/job-1", + "/api/v1/store/runtime", + "/api/v1/store/some-route-that-does-not-exist-yet", + ] { + assert!( + !auth::plugin_may_access(&Method::GET, path), + "plugin token must not reach {path}" + ); + } + for path in [ + "/api/v1/store/install", + "/api/v1/store/uninstall", + "/api/v1/store/refresh", + "/api/v1/store/runtime", + ] { + assert!( + !auth::plugin_may_access(&Method::POST, path), + "plugin token must not reach {path}" + ); + } + assert!(!auth::plugin_may_access( + &Method::PUT, + "/api/v1/store/sources/evil" + )); + assert!(!auth::plugin_may_access( + &Method::DELETE, + "/api/v1/store/sources/unom" + )); + // …but a route that merely starts with the same letters is unaffected. + assert!(auth::plugin_may_access(&Method::GET, "/api/v1/status")); } /// The plugin bearer lane end-to-end: scoped 403s on the carve-outs, 200s on the plugin surface, @@ -484,6 +524,12 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() { (Method::GET, "/api/v1/native/pending"), (Method::DELETE, "/api/v1/clients/aabbcc"), (Method::GET, "/api/v1/plugins/x/ui-credential"), + // The plugin store: a plugin must not be able to install plugins or switch its own runner. + (Method::GET, "/api/v1/store/catalog"), + (Method::POST, "/api/v1/store/install"), + (Method::POST, "/api/v1/store/uninstall"), + (Method::POST, "/api/v1/store/runtime"), + (Method::PUT, "/api/v1/store/sources/evil"), ] { let (status, body) = send(&app, plugin_req(method.clone(), path)).await; assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}"); diff --git a/crates/punktfunk-host/src/store.rs b/crates/punktfunk-host/src/store.rs index 89cccae6..30279201 100644 --- a/crates/punktfunk-host/src/store.rs +++ b/crates/punktfunk-host/src/store.rs @@ -25,7 +25,7 @@ pub(crate) mod jobs; pub(crate) mod manifest; pub(crate) mod sources; -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use index::{Advisory, Entry, Index}; use sources::Source; use std::path::{Path, PathBuf}; @@ -50,6 +50,30 @@ pub(crate) struct InstalledPkg { pub version: Option, } +/// The packages the operator actually asked for: the `dependencies` of the plugins dir's own +/// `package.json`, which `bun add` maintains. `None` only when there is no readable `package.json` +/// at all. +/// +/// This is what separates a plugin from a plugin's *library*. `@punktfunk/plugin-kit` is the +/// framework every kit-built plugin depends on — it matches the `plugin-*` naming convention +/// exactly, lands in `node_modules` as a transitive dependency, and is emphatically not something +/// the operator installed or can meaningfully uninstall. The convention alone cannot tell the two +/// apart; the top-level dependency list can. +/// +/// A `package.json` with **no** `dependencies` key returns an empty list, not `None`: `bun remove` +/// drops the key entirely when the last plugin goes, and orphaned transitive packages can outlive +/// it in `node_modules`. Falling back to the naming convention there resurrects a plugin's library +/// as an installed plugin the moment you uninstall the last real one (seen on-glass). If bun is +/// managing this tree at all, its answer is the answer — including when the answer is "nothing". +fn top_level_deps(dir: &Path) -> Option> { + let bytes = std::fs::read(dir.join("package.json")).ok()?; + let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + Some(match v.get("dependencies").and_then(|d| d.as_object()) { + Some(deps) => deps.keys().cloned().collect(), + None => Vec::new(), + }) +} + /// Enumerate installed plugin packages under `/node_modules`. /// /// Mirrors the runner's own discovery so the store never claims something is installed that the @@ -57,8 +81,14 @@ pub(crate) struct InstalledPkg { /// `plugin-*` (`@punktfunk/plugin-rom-manager`, `@retro-hub/plugin-x`). Scoped-any is what makes a /// third-party catalog entry work at all — a scoped name is required for the registry mapping /// (D8), so discovery must not be limited to the first-party scope. +/// +/// Then narrowed to [`top_level_deps`] when a dependency list exists, so a plugin's *dependencies* +/// (notably `@punktfunk/plugin-kit`) aren't reported as installed plugins. A tree with no readable +/// `package.json` — hand-assembled, or an older layout — falls back to the convention alone rather +/// than reporting nothing. pub(crate) fn installed_packages(dir: &Path) -> Vec { let modules = dir.join("node_modules"); + let top_level = top_level_deps(dir); let mut out = Vec::new(); let version_of = |pkg_dir: &Path| -> Option { let bytes = std::fs::read(pkg_dir.join("package.json")).ok()?; @@ -101,9 +131,74 @@ pub(crate) fn installed_packages(dir: &Path) -> Vec { } } } + if let Some(top) = top_level { + out.retain(|p| top.iter().any(|d| d == &p.pkg)); + } out } +/// Point a package scope at its registry in the plugins dir's `bunfig.toml`. +/// +/// The runner CLI can do this too (`--registry @scope=URL`), but the store must **not** depend on +/// that: `runner_command()` resolves whatever scripting package is installed on the box, which can +/// predate the host binary (the packaged runner and the host ship separately). An older runner +/// treats an unknown flag's *value* as a package name and the install dies with +/// "unrecognised dependency format" — found on-glass. Writing the mapping here keeps a +/// catalog-driven install working against every runner that has ever shipped. +/// +/// Idempotent and non-destructive, matching `sdk/src/plugins.ts::ensureBunfig`: a scope already +/// mapped to this URL is left alone, one mapped elsewhere is rewritten, unrelated content survives. +pub(crate) fn ensure_bunfig_scope(dir: &Path, scope: &str, url: &str) -> Result<()> { + // The scope and URL both come from a signature-verified, field-validated index entry + // (`@`-prefixed, `[a-z0-9._-]`, https), so neither can smuggle a quote or newline into the TOML. + if !index::valid_scoped_pkg(&format!("{scope}/x")) || !url.starts_with("https://") { + bail!("refusing to map scope `{scope}` to `{url}`"); + } + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + let path = dir.join("bunfig.toml"); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + let wanted = format!("\"{scope}\" = \"{url}\""); + + let is_mapping_for_scope = |line: &str| { + let t = line.trim_start(); + t.starts_with(&format!("\"{scope}\"")) || t.starts_with(&format!("{scope} ")) + }; + if existing.lines().any(|l| l.trim() == wanted) { + return Ok(()); // already correct + } + let updated = if existing.lines().any(is_mapping_for_scope) { + existing + .lines() + .map(|l| { + if is_mapping_for_scope(l) { + wanted.clone() + } else { + l.to_string() + } + }) + .collect::>() + .join("\n") + + "\n" + } else if let Some(pos) = existing + .lines() + .position(|l| l.trim() == "[install.scopes]") + { + let mut lines: Vec = existing.lines().map(str::to_string).collect(); + lines.insert(pos + 1, wanted); + lines.join("\n") + "\n" + } else if existing.trim().is_empty() { + format!("[install.scopes]\n{wanted}\n") + } else { + format!( + "{}{}\n[install.scopes]\n{wanted}\n", + existing, + if existing.ends_with('\n') { "" } else { "\n" } + ) + }; + std::fs::write(&path, updated).with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + /// Is `pkg` a name the runner would supervise? Guards the uninstall route so a stray /// `POST /store/uninstall {"pkg": "effect"}` can't rip a shared dependency out of the tree. pub(crate) fn valid_installed_pkg(pkg: &str) -> Result<()> { @@ -366,6 +461,62 @@ mod tests { assert!(installed_packages(dir.path()).is_empty()); } + /// A plugin's LIBRARY is not an installed plugin. + /// + /// Regression from a live install: `@punktfunk/plugin-kit` is the framework every kit-built + /// plugin depends on. It matches the `plugin-*` convention exactly and lands in `node_modules` + /// transitively, so a convention-only scan reported the framework as an installed plugin the + /// operator could uninstall. The plugins dir's own `dependencies` is the authority. + #[test] + fn transitive_plugin_named_dependencies_are_not_installed_plugins() { + let dir = tempfile::tempdir().unwrap(); + touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.1"); + touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // a dependency of the above + touch_pkg(dir.path(), "@punktfunk/host", "0.1.2"); + std::fs::write( + dir.path().join("package.json"), + r#"{"dependencies":{"@punktfunk/plugin-rom-manager":"^0.3.1"}}"#, + ) + .unwrap(); + + let found = installed_packages(dir.path()); + assert_eq!( + found.iter().map(|p| p.pkg.as_str()).collect::>(), + vec!["@punktfunk/plugin-rom-manager"], + "only the top-level install counts" + ); + } + + #[test] + fn a_tree_with_no_package_json_falls_back_to_the_convention() { + // Hand-assembled or older layouts must still be discovered, not silently reported empty. + let dir = tempfile::tempdir().unwrap(); + touch_pkg(dir.path(), "punktfunk-plugin-legacy", "0.1.0"); + assert_eq!(installed_packages(dir.path()).len(), 1); + } + + /// Uninstalling the last plugin must not resurrect its library as an installed plugin. + /// + /// `bun remove` drops the `dependencies` key entirely once it empties, while orphaned + /// transitive packages can linger in `node_modules`. Treating "package.json exists but has no + /// dependencies" as "no authority, fall back to the naming convention" made + /// `@punktfunk/plugin-kit` pop back into the installed list right after the operator removed + /// the only real plugin — seen on-glass. + #[test] + fn an_emptied_dependency_list_means_nothing_is_installed() { + let dir = tempfile::tempdir().unwrap(); + touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // orphan left behind + std::fs::write(dir.path().join("package.json"), r#"{"name":"plugins"}"#).unwrap(); + assert!(installed_packages(dir.path()).is_empty()); + + std::fs::write( + dir.path().join("package.json"), + r#"{"name":"plugins","dependencies":{}}"#, + ) + .unwrap(); + assert!(installed_packages(dir.path()).is_empty()); + } + #[test] fn uninstall_target_must_be_a_plugin_package() { assert!(valid_installed_pkg("@punktfunk/plugin-rom-manager").is_ok()); @@ -378,6 +529,48 @@ mod tests { assert!(valid_installed_pkg("").is_err()); } + #[test] + fn bunfig_scope_mapping_is_idempotent_and_preserves_other_scopes() { + let dir = tempfile::tempdir().unwrap(); + let read = || std::fs::read_to_string(dir.path().join("bunfig.toml")).unwrap(); + + ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap(); + assert!(read().contains("[install.scopes]")); + assert!(read().contains("\"@retro-hub\" = \"https://retro.example/npm/\"")); + + // Idempotent — no duplicate line. + ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap(); + assert_eq!(read().matches("@retro-hub").count(), 1); + + // A second scope joins the same table. + ensure_bunfig_scope( + dir.path(), + "@punktfunk", + "https://git.unom.io/api/packages/unom/npm/", + ) + .unwrap(); + assert!(read().contains("@punktfunk")); + assert!(read().contains("@retro-hub")); + + // A changed registry rewrites in place rather than duplicating. + ensure_bunfig_scope(dir.path(), "@retro-hub", "https://new.example/npm/").unwrap(); + assert_eq!(read().matches("@retro-hub").count(), 1); + assert!(read().contains("https://new.example/npm/")); + assert!(!read().contains("retro.example")); + assert!(read().contains("@punktfunk"), "unrelated scope survives"); + } + + #[test] + fn bunfig_scope_mapping_refuses_junk() { + let dir = tempfile::tempdir().unwrap(); + // Only https, only a well-formed scope — both already guaranteed by index validation, held + // here as the second line of defence for the one place we format TOML by hand. + assert!(ensure_bunfig_scope(dir.path(), "@x", "http://insecure/").is_err()); + assert!(ensure_bunfig_scope(dir.path(), "no-at-sign", "https://e/").is_err()); + assert!(ensure_bunfig_scope(dir.path(), "@bad\"quote", "https://e/").is_err()); + assert!(!dir.path().join("bunfig.toml").exists()); + } + #[test] fn plugin_id_derivation() { assert_eq!( diff --git a/crates/punktfunk-host/src/store/catalog.rs b/crates/punktfunk-host/src/store/catalog.rs index b675c406..81ebf52b 100644 --- a/crates/punktfunk-host/src/store/catalog.rs +++ b/crates/punktfunk-host/src/store/catalog.rs @@ -54,9 +54,13 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched { req = req.set("If-None-Match", tag); } let resp = match req.call() { + // `ureq` only turns status >= 400 into `Err(Status)`, so a conditional request's 304 + // arrives here as **Ok with an empty body** — not as an error. Reading it as an error arm + // (the intuitive reading) means every refresh after the first one verifies a signature + // over zero bytes and "fails"; the catalog then sits permanently stale, serving cache and + // never picking up a new entry. Found on-glass; pinned by `ureq_returns_304_as_ok`. + Ok(r) if r.status() == 304 => return Fetched::NotModified, Ok(r) => r, - // 304 is the happy "nothing changed" path, not a failure. - Err(ureq::Error::Status(304, _)) => return Fetched::NotModified, Err(ureq::Error::Status(code, _)) => { return Fetched::Failed(format!("index fetch returned HTTP {code}")) } @@ -243,6 +247,37 @@ mod tests { assert!(!meta_path(dir.path(), "s").exists()); } + /// Pins the HTTP-client assumption that [`fetch`]'s conditional-request handling rests on. + /// + /// `ureq` converts only status >= 400 into `Err(Status)`. A 304 — which is exactly what a + /// successful `If-None-Match` produces, i.e. the *steady state* of a healthy catalog — comes + /// back as `Ok` with an empty body. Treating it as an error arm compiles, looks right, and + /// silently breaks every refresh after the first: the empty body fails the signature check and + /// the source sits stale forever. If a `ureq` upgrade ever changes this, fail here rather than + /// on someone's host. + #[test] + fn ureq_returns_304_as_ok() { + use std::io::Write as _; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + if let Ok((mut sock, _)) = listener.accept() { + let _ = sock.write_all(b"HTTP/1.1 304 Not Modified\r\nETag: \"x\"\r\n\r\n"); + let _ = sock.flush(); + } + }); + + let resp = ureq::get(&format!("http://{addr}/index.json")) + .set("If-None-Match", "\"x\"") + .call(); + let _ = server.join(); + + match resp { + Ok(r) => assert_eq!(r.status(), 304, "304 must arrive as Ok, and be checked for"), + Err(e) => panic!("ureq now reports 304 as an error ({e}) — fetch() must be updated"), + } + } + #[test] fn non_https_source_never_reaches_the_network() { let s = Source { diff --git a/crates/punktfunk-host/src/store/index.rs b/crates/punktfunk-host/src/store/index.rs index dfab07ab..9dfa41bc 100644 --- a/crates/punktfunk-host/src/store/index.rs +++ b/crates/punktfunk-host/src/store/index.rs @@ -561,6 +561,56 @@ mod tests { assert!(verify_signature(body, &sig, &[]).is_err()); } + /// The real published index must parse here, field for field. + /// + /// This fixture is a snapshot of `unom/punktfunk-plugin-index`'s `v1/index.json`. The index + /// repo's validator reimplements this module's rules in TypeScript (it has to — it gates PRs + /// before anything is signed), and two hand-written parsers of the same grammar drift. This + /// test is the seam that catches the drift from our side: if a document the publisher accepts + /// stops being one we accept, an entry silently disappears from every operator's store. + #[test] + fn the_published_seed_index_parses() { + let bytes = include_bytes!("testdata/seed-index.json"); + let idx = Index::parse(bytes).expect("the published index must parse"); + assert_eq!(idx.plugins.len(), 2, "no entry may be silently dropped"); + + let rom = idx + .plugins + .iter() + .find(|e| e.id == "rom-manager") + .expect("rom-manager entry"); + assert_eq!(rom.pkg, "@punktfunk/plugin-rom-manager"); + assert!(rom.integrity.starts_with("sha512-")); + assert!( + semver::Version::parse(&rom.version).is_ok(), + "exact version" + ); + assert_eq!( + rom.verification.as_ref().map(|v| v.reviewed_at.as_str()), + Some("2026-07-20"), + "camelCase `reviewedAt` must decode" + ); + assert_eq!( + rom.min_host.as_deref(), + Some("0.15.0"), + "camelCase `minHost`" + ); + assert_eq!(scope_of(&rom.pkg).unwrap(), "@punktfunk"); + + // A windows-only entry is listed everywhere but only *installable* where it can run. + let playnite = idx + .plugins + .iter() + .find(|e| e.id == "playnite") + .expect("playnite entry"); + assert_eq!(playnite.platforms, vec!["windows"]); + if HOST_PLATFORM == "windows" { + assert!(playnite.incompatible_reason().is_none()); + } else { + assert!(playnite.incompatible_reason().is_some()); + } + } + #[test] fn public_key_parsing_rejects_junk() { assert!(PublicKey::parse("nope").is_err()); diff --git a/crates/punktfunk-host/src/store/jobs.rs b/crates/punktfunk-host/src/store/jobs.rs index 89c737be..960dcba8 100644 --- a/crates/punktfunk-host/src/store/jobs.rs +++ b/crates/punktfunk-host/src/store/jobs.rs @@ -354,16 +354,21 @@ fn run_install(id: &str, plan: Plan) -> Result<()> { // ---- install ------------------------------------------------------------------------------ set_phase(id, "installing"); let before = super::installed_packages(&dir); + // Map the entry's scope to its registry ourselves rather than through a runner flag: the + // installed scripting package can be older than this binary, and an older runner would read an + // unknown flag's value as a package name (see `ensure_bunfig_scope`). + if let Some((scope, url)) = &plan.registry { + super::ensure_bunfig_scope(&dir, scope, url) + .with_context(|| format!("map {scope} to {url}"))?; + } let mut args = vec!["add".to_string(), plan.spec.clone()]; if plan.version.is_some() { // Pin the dependency range too, so a later `bun install` in this tree can't drift off the - // reviewed version. + // reviewed version. Safely ignored by a runner too old to know it (an unknown `-`-prefixed + // flag is skipped, not misread) — the version we install is exact either way, so the worst + // case is a caret range recorded in package.json. args.push("--exact".into()); } - if let Some((scope, url)) = &plan.registry { - args.push("--registry".into()); - args.push(format!("{scope}={url}")); - } if !plan.spec.starts_with("@punktfunk/") { // The runner CLI's supply-chain gate refuses anything resolving off Punktfunk's own // registry unless told otherwise. Here the operator already made that decision — either by diff --git a/crates/punktfunk-host/src/store/manifest.rs b/crates/punktfunk-host/src/store/manifest.rs index 9b7a7a59..021685e0 100644 --- a/crates/punktfunk-host/src/store/manifest.rs +++ b/crates/punktfunk-host/src/store/manifest.rs @@ -193,7 +193,7 @@ mod tests { assert_eq!(r.tier, Tier::Verified); assert_eq!(r.version.as_deref(), Some("0.2.1")); // A package we never recorded has no entry — the caller reports it as CLI-installed. - assert!(m.get("punktfunk-plugin-other").is_none()); + assert!(!m.contains_key("punktfunk-plugin-other")); } #[test] @@ -211,8 +211,8 @@ mod tests { record(dir.path(), "@x/z", rec(Tier::External)).unwrap(); forget(dir.path(), "@x/y").unwrap(); let m = load(dir.path()); - assert!(m.get("@x/y").is_none()); - assert!(m.get("@x/z").is_some()); + assert!(!m.contains_key("@x/y")); + assert!(m.contains_key("@x/z")); forget(dir.path(), "@not/here").unwrap(); // no-op, no error } @@ -241,6 +241,6 @@ mod tests { assert_eq!(&s[4..5], "-"); assert_eq!(&s[10..11], "T"); // A known epoch value, to catch the civil-date arithmetic drifting. - assert!(s > "2026-01-01T00:00:00Z".to_string(), "{s}"); + assert!(s.as_str() > "2026-01-01T00:00:00Z", "{s}"); } } diff --git a/crates/punktfunk-host/src/store/sources.rs b/crates/punktfunk-host/src/store/sources.rs index 216d1163..88896573 100644 --- a/crates/punktfunk-host/src/store/sources.rs +++ b/crates/punktfunk-host/src/store/sources.rs @@ -17,7 +17,17 @@ use serde::{Deserialize, Serialize}; pub(crate) const OFFICIAL_NAME: &str = "unom"; /// The built-in source's index URL. -pub(crate) const OFFICIAL_URL: &str = "https://plugins.punktfunk.unom.io/v1/index.json"; +/// +/// Served straight out of the index repo over Gitea's anonymous raw endpoint: real HTTPS with a +/// real certificate, no new vhost to stand up, and "merged to main" *is* "published". The document +/// is signed, so the transport is not what we're trusting — swapping this for a dedicated static +/// host later is a one-constant change with no protocol impact. +/// +/// One consequence worth knowing: CI signs *after* the merge, so between a merge and the signature +/// commit the index is newer than its `.sig`. That window fails **closed** — the signature check +/// rejects the document and hosts keep serving their last good copy, marked stale. +pub(crate) const OFFICIAL_URL: &str = + "https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json"; /// Pinned signing keys for the built-in source. **Two slots** so a key rotation is "sign with the /// new key, ship a host that trusts both, retire the old one" instead of a flag day where old diff --git a/crates/punktfunk-host/src/store/testdata/seed-index.json b/crates/punktfunk-host/src/store/testdata/seed-index.json new file mode 100644 index 00000000..0b76a5a7 --- /dev/null +++ b/crates/punktfunk-host/src/store/testdata/seed-index.json @@ -0,0 +1,49 @@ +{ + "schema": 1, + "name": "unom official", + "generated": "2026-07-20T18:00:00Z", + "plugins": [ + { + "id": "rom-manager", + "pkg": "@punktfunk/plugin-rom-manager", + "registry": "https://git.unom.io/api/packages/unom/npm/", + "title": "ROM Manager", + "description": "Scans your ROM directories, maps them to emulators, fetches box art, and reconciles everything into the host game library as a provider. Includes a console-hosted web UI for mapping and cleanup.", + "icon": "gamepad-2", + "author": "unom", + "homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager", + "license": "MIT OR Apache-2.0", + "version": "0.3.1", + "integrity": "sha512-SGqMriqQPOQobXMYiT20w0rcTMNdYfZc8No3fPu57njMN+2eTVBVlQjyn1t3KoRFxfoRYGwuumN4X3aNmS0Tpw==", + "verification": { + "reviewedAt": "2026-07-20" + }, + "minHost": "0.15.0", + "platforms": [ + "linux", + "windows" + ] + }, + { + "id": "playnite", + "pkg": "@punktfunk/plugin-playnite", + "registry": "https://git.unom.io/api/packages/unom/npm/", + "title": "Playnite", + "description": "Bridges your Playnite library on Windows into the host game library, keeping installed games, metadata, and launch commands in sync as a library provider.", + "icon": "library", + "author": "unom", + "homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite", + "license": "MIT OR Apache-2.0", + "version": "0.1.1", + "integrity": "sha512-H40QEcUN7g0wHTy5CFCOTA4+j/FypbVzWSZ0OlW5Ej5+FnnT1fMUDVOx8KEH34mavTy70fd1zx3zDucu9hNQuQ==", + "verification": { + "reviewedAt": "2026-07-20" + }, + "minHost": "0.15.0", + "platforms": [ + "windows" + ] + } + ], + "security": [] +} diff --git a/docs-site/content/docs/automation.md b/docs-site/content/docs/automation.md index 8e87c6b8..4f354c29 100644 --- a/docs-site/content/docs/automation.md +++ b/docs-site/content/docs/automation.md @@ -30,6 +30,7 @@ and nothing you configure here runs anywhere near the streaming path. | `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count | | `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) | | `plugins.changed` | a plugin's registration changes (registered, restarted, deregistered, or its lease expired) | plugin id | +| `store.changed` | an install or uninstall finished, or a plugin catalog was refreshed | none — re-read `GET /api/v1/store/catalog` / `…/installed` | | `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled | Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema` diff --git a/docs-site/content/docs/plugins.mdx b/docs-site/content/docs/plugins.mdx index b5e3fdce..52a4e3d6 100644 --- a/docs-site/content/docs/plugins.mdx +++ b/docs-site/content/docs/plugins.mdx @@ -14,7 +14,49 @@ Two first-party plugins today: | **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. | | **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. | -## Installing a plugin +## Installing from the console + +Open the [web console](/docs/web-console) → **Plugins**. The **Browse** tab lists the plugin +catalog; pick one, confirm, and the host installs it and restarts the runner for you. **Installed** +shows what you have (and switches the runner on and off), **Sources** is where the catalog comes +from. + +That is the whole install. The rest of this page covers the CLI, which does the same thing, and the +trust model behind the badges — worth reading once, because a plugin runs with the same privileges +as the host. + +### What "Verified" means + +Every catalogued plugin pins **one exact version** and that version's package hash. A verified entry +means somebody at unom reviewed *that exact package* — not the project in general, and not whatever +it publishes next. When a plugin releases a new version, the store keeps offering the reviewed one +until the new release is reviewed too. Before anything is downloaded, the host re-checks the pinned +hash against the registry, so a package that was quietly republished under the same version number +is refused rather than installed. + +Three things a plugin can be: + +| Badge | Where it came from | +|---|---| +| **Verified** | The built-in catalog. unom reviewed this exact package. | +| **Unverified**, *from <source>* | A catalog **you** added. Still pinned and hash-checked, but curated by somebody else — unom has not looked at the code. | +| **Unverified** | Installed by hand from a package spec. Nobody reviewed it and nothing pins it; the console asks you to type the name to confirm, and the plugin stays marked this way for as long as it is installed. | + +### Adding another catalog + +**Sources** → *Add a catalog source*: a name and the URL of its index. Optionally paste the source's +`ed25519:…` public key — with a key set, the host refuses any index from that source that isn't +correctly signed, rather than falling back to an unsigned one. + +Adding a source is a trust decision you make once: its plugins become installable on this host. +They are always attributed to it and never carry the Verified badge, which belongs to the built-in +catalog alone. + +To publish a plugin to the built-in catalog, open a pull request against +[`punktfunk-plugin-index`](https://git.unom.io/unom/punktfunk-plugin-index) — its README covers the +format and what review looks for. + +## Installing from the CLI Two commands: install the plugin, then turn the runner on. The host CLI handles the rest — creating the plugins directory, pointing it at the package registry, and starting the supervisor. @@ -50,7 +92,13 @@ Open the [web console](/docs/web-console) and the plugin's page appears in the n that's the whole install. The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need -`enable` once — plugins you add later are picked up automatically. +`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. + +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 +and it carries its catalog badge instead. ### The rest of the commands diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index 14a73b89..a40a8eb3 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -27,6 +27,8 @@ export type AvailableCompositor = { readonly "available": boolean, readonly "def export const AvailableCompositor = Schema.Struct({ "available": Schema.Boolean.annotate({ "description": "Usable on this host right now: the live session's own compositor, or gamescope wherever\nits binary is installed." }), "default": Schema.Boolean.annotate({ "description": "True for the backend an `Auto` (unspecified) request resolves to right now." }), "id": Schema.String.annotate({ "description": "Stable identifier (`\"kwin\"` | `\"wlroots\"` | `\"mutter\"` | `\"gamescope\"`) — pass this to a\nclient's `--compositor` flag." }), "label": Schema.String.annotate({ "description": "Human-readable label for UIs." }) }).annotate({ "description": "A compositor backend the host can drive a virtual output on, and whether it's usable now." }) export type CaptureMeta = { readonly "client": string, readonly "codec": string, readonly "duration_ms": number, readonly "fps": number, readonly "height": number, readonly "id": string, readonly "kind": string, readonly "sample_count": number, readonly "started_unix_ms": number, readonly "width": number } export const CaptureMeta = Schema.Struct({ "client": Schema.String.annotate({ "description": "Short label / fingerprint prefix, or `\"\"` if unknown." }), "codec": Schema.String.annotate({ "description": "`\"h264\" | \"hevc\" | \"av1\"`." }), "duration_ms": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "id": Schema.String.annotate({ "description": "e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem." }), "kind": Schema.String.annotate({ "description": "`\"native\" | \"gamestream\"`." }), "sample_count": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "started_unix_ms": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "Capture summary — the filename stem plus the negotiated mode/codec/client. Stored at the head\nof each on-disk recording and listed standalone (without the sample body) by\n[`StatsRecorder::list`]." }) +export type CatalogEntry = { readonly "author": string, readonly "blocked"?: string | null, readonly "compatible": boolean, readonly "description": string, readonly "homepage"?: string | null, readonly "icon"?: string | null, readonly "id": string, readonly "incompatible_reason"?: string | null, readonly "installed_version"?: string | null, readonly "license"?: string | null, readonly "min_host"?: string | null, readonly "pkg": string, readonly "platforms": ReadonlyArray, readonly "reviewed_at"?: string | null, readonly "source": string, readonly "tier": string, readonly "title": string, readonly "update_available": boolean, readonly "version": string } +export const CatalogEntry = Schema.Struct({ "author": Schema.String, "blocked": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "A revocation covering the catalogued version — do not offer this without shouting." })), "compatible": Schema.Boolean.annotate({ "description": "Can this host install it?" }), "description": Schema.String, "homepage": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "id": Schema.String, "incompatible_reason": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "installed_version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The version installed right now, if any." })), "license": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "min_host": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "pkg": Schema.String, "platforms": Schema.Array(Schema.String), "reviewed_at": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "When unom reviewed this exact tarball (built-in source only)." })), "source": Schema.String.annotate({ "description": "Which source listed it." }), "tier": Schema.String.annotate({ "description": "`verified` (built-in source) or `external` (an operator-added source). Never `unverified`:\nunverified installs come from a raw spec and are never listed (D7)." }), "title": Schema.String, "update_available": Schema.Boolean.annotate({ "description": "Installed, but at a different version than the catalog pins." }), "version": Schema.String.annotate({ "description": "The one installable version this entry pins." }) }).annotate({ "description": "One row on the shelf." }) export type DisconnectReason = "quit" | "timeout" | "error" export const DisconnectReason = Schema.Literals(["quit", "timeout", "error"]).annotate({ "description": "Why a client went away. `Quit` is a deliberate user \"stop\" (the typed close code);\n`Timeout` is a transport idle timeout (the client vanished); `Error` is everything else." }) export type GameSession = "auto" | "dedicated" @@ -35,8 +37,16 @@ export type Health = { readonly "abi_version": number, readonly "status": string export const Health = Schema.Struct({ "abi_version": Schema.Number.annotate({ "description": "`punktfunk-core` C ABI version.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "status": Schema.String.annotate({ "description": "Always `\"ok\"` when the host responds." }), "version": Schema.String.annotate({ "description": "`punktfunk-host` crate version." }) }).annotate({ "description": "Liveness + version probe." }) export type HookEntry = { readonly "debounce_ms"?: number, readonly "filter"?: null | { readonly "app"?: string | null, readonly "client"?: string | null, readonly "fingerprint"?: string | null, readonly "plane"?: null | "native" | "gamestream" }, readonly "hmac_secret_file"?: string | null, readonly "on": string, readonly "run"?: string | null, readonly "timeout_s"?: number, readonly "webhook"?: string | null } export const HookEntry = Schema.Struct({ "debounce_ms": Schema.optionalKey(Schema.Number.annotate({ "description": "Minimum interval between firings of this hook, in milliseconds. 0 = fire every time.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "filter": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "app": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Launched app id/title (`stream.*` events)." })), "client": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Client/device name (for `session.*`: the short client label the Dashboard shows)." })), "fingerprint": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Certificate fingerprint (hex, case-insensitive)." })), "plane": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Literals(["native", "gamestream"]).annotate({ "description": "Protocol plane (`native` / `gamestream`)." })], { mode: "oneOf" })) }).annotate({ "description": "Exact-match constraints on the event's fields; every present field must match." })], { mode: "oneOf" })), "hmac_secret_file": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "File holding the webhook HMAC secret (`X-Punktfunk-Signature: sha256=`). The file\nshould be operator-owned and private; a world-readable secret is warned about." })), "on": Schema.String.annotate({ "description": "Which events fire this hook: an exact kind (`stream.started`) or a `domain.*` prefix\n(`pairing.*`) — the same vocabulary as the SSE `?kinds=` filter." }), "run": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Shell command to execute (detached, event JSON on stdin + `PF_EVENT_*` env)." })), "timeout_s": Schema.optionalKey(Schema.Number.annotate({ "description": "Exec timeout in seconds (1–600, default 30); the process group is killed on expiry.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "webhook": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "URL to POST the event JSON to." })) }).annotate({ "description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs." }) +export type HostFacts = { readonly "platform": string, readonly "version": string } +export const HostFacts = Schema.Struct({ "platform": Schema.String.annotate({ "description": "`linux` / `windows` / `macos`." }), "version": Schema.String }).annotate({ "description": "Facts about this host, so the console can grey out rows it can't install." }) export type Identity = "shared" | "per-client" | "per-client-mode" export const Identity = Schema.Literals(["shared", "per-client", "per-client-mode"]).annotate({ "description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage." }) +export type InstallRequest = { readonly "accept_unverified"?: boolean, readonly "id"?: string | null, readonly "source"?: string | null, readonly "spec"?: string | null } +export const InstallRequest = Schema.Struct({ "accept_unverified": Schema.optionalKey(Schema.Boolean.annotate({ "description": "Required with [`Self::spec`]: the operator's explicit acknowledgement that this installs\nunreviewed code with operator privileges. The console collects it behind a typed\nconfirmation; the API refuses without it so no other caller can skip the decision." })), "id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Catalog entry id (with [`Self::source`])." })), "source": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Catalog source name (with [`Self::id`])." })), "spec": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "A raw package spec (`@scope/name`, `@scope/name@1.2.3`, an https tarball or git+https URL).\nNothing reviewed it and nothing pins it." })) }).annotate({ "description": "`POST /store/install` — either a catalogued entry, or a raw spec the operator owns." }) +export type InstalledView = { readonly "blocked"?: string | null, readonly "entry_id"?: string | null, readonly "installed_at"?: string | null, readonly "pkg": string, readonly "plugin_id"?: string | null, readonly "running": boolean, readonly "source"?: string | null, readonly "tier": string, readonly "title"?: string | null, readonly "update_available"?: string | null, readonly "version"?: string | null } +export const InstalledView = Schema.Struct({ "blocked": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "A revocation covering the *installed* version. Reported, never auto-removed: silently\ndeleting running code is its own hazard, so the operator decides." })), "entry_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The catalog entry this maps to, when it is on a shelf we know." })), "installed_at": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "pkg": Schema.String, "plugin_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The plugin id it registers under — the key into `GET /plugins`." })), "running": Schema.Boolean.annotate({ "description": "Is it registered in the live lease registry right now?" }), "source": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "tier": Schema.String.annotate({ "description": "`verified` / `external` / `unverified` / `cli` — remembered from install time, so an\nunverified plugin stays visibly unverified long after the dialog is forgotten." }), "title": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "update_available": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The catalog's version, when it's newer than what's installed." })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])) }).annotate({ "description": "An installed plugin package, joined with its provenance and whether it's actually running." }) +export type JobRef = { readonly "job": string } +export const JobRef = Schema.Struct({ "job": Schema.String }).annotate({ "description": "202 body: where to watch the work." }) export type KeepAlive = { readonly "mode": "off" } | { readonly "mode": "duration", readonly "seconds": number } | { readonly "mode": "forever" } export const KeepAlive = Schema.Union([Schema.Struct({ "mode": Schema.Literal("off") }).annotate({ "description": "Tear the display down at session end (today's default on every backend but Windows, which\nlingers 10 s)." }), Schema.Struct({ "mode": Schema.Literal("duration"), "seconds": Schema.Number.annotate({ "description": "Linger window in seconds.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it." }), Schema.Struct({ "mode": Schema.Literal("forever") }).annotate({ "description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0." })], { mode: "oneOf" }).annotate({ "description": "How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)\nsurvives after the last client session detaches. Serialized as an object tagged on `mode`\n(`{\"mode\":\"off\"}` / `{\"mode\":\"duration\",\"seconds\":300}` / `{\"mode\":\"forever\"}`) so the web form\nand the OpenAPI schema stay simple." }) export type LaunchSpec = { readonly "kind": string, readonly "value": string } @@ -79,12 +89,26 @@ export type ReleaseDisplayRequest = { readonly "slot"?: never } export const ReleaseDisplayRequest = Schema.Struct({ "slot": Schema.optionalKey(Schema.Never) }).annotate({ "description": "Request body for `releaseDisplay`." }) export type ReleaseDisplayResult = { readonly "released": number } export const ReleaseDisplayResult = Schema.Struct({ "released": Schema.Number.annotate({ "description": "Number of kept displays torn down." }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "Result of a `/display/release`." }) +export type RuntimeRequest = { readonly "enabled": boolean } +export const RuntimeRequest = Schema.Struct({ "enabled": Schema.Boolean }) +export type RuntimeView = { readonly "detail"?: string | null, readonly "enabled": boolean, readonly "installed": boolean, readonly "principal"?: string | null, readonly "running": boolean, readonly "unit": string } +export const RuntimeView = Schema.Struct({ "detail": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "enabled": Schema.Boolean, "installed": Schema.Boolean.annotate({ "description": "Is the runner payload/unit present at all?" }), "principal": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Windows: the account the task runs as." })), "running": Schema.Boolean, "unit": Schema.String.annotate({ "description": "systemd unit or scheduled-task name." }) }) +export type ScannerInfo = { readonly "enabled": boolean, readonly "id": string, readonly "label": string } +export const ScannerInfo = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether this host runs the scanner (default true)." }), "id": Schema.String.annotate({ "description": "Stable scanner id — the same string the scanner's entries carry in their `store` field." }), "label": Schema.String.annotate({ "description": "Human-facing name for the console toggle." }) }).annotate({ "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." }) +export type ScannerToggle = { readonly "enabled": boolean } +export const ScannerToggle = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether the scanner should run on this host." }) }).annotate({ "description": "Request body for `setLibraryScanner`." }) export type SessionRef = { readonly "client": string, readonly "hdr": boolean, readonly "id": number, readonly "mode": string } export const SessionRef = Schema.Struct({ "client": Schema.String.annotate({ "description": "Short client label (cert-fingerprint prefix, or peer IP for an anonymous client)." }), "hdr": Schema.Boolean, "id": Schema.Number.annotate({ "description": "Host-local session id (unique within this host process).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode": Schema.String.annotate({ "description": "Negotiated mode, `WxH@Hz` (e.g. `\"3840x2160@120\"`)." }) }).annotate({ "description": "A live A/V session (the plane-neutral notion the Dashboard shows)." }) export type SetGpuPreference = { readonly "gpu_id"?: string | null, readonly "mode": string } export const SetGpuPreference = Schema.Struct({ "gpu_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Required when `mode` is `manual`: the stable `id` of a currently listed GPU\n(see `listGpus`)." })), "mode": Schema.String.annotate({ "description": "`auto` (env pin, else max dedicated VRAM — the default) or `manual`." }) }).annotate({ "description": "Request body for `setGpuPreference`." }) +export type SourceInput = { readonly "public_key"?: string | null, readonly "url": string } +export const SourceInput = Schema.Struct({ "public_key": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`ed25519:`. Omitted ⇒ an unsigned source (accepted, flagged everywhere)." })), "url": Schema.String }) +export type SourceView = { readonly "builtin": boolean, readonly "entry_count": number, readonly "error"?: string | null, readonly "fetched_at"?: never, readonly "name": string, readonly "public_key"?: string | null, readonly "signed": boolean, readonly "stale": boolean, readonly "url": string } +export const SourceView = Schema.Struct({ "builtin": Schema.Boolean.annotate({ "description": "The built-in `unom` source: not editable, not removable, and the only source whose entries\nmay carry the \"verified\" tier." }), "entry_count": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Why the last refresh failed, if it did." })), "fetched_at": Schema.optionalKey(Schema.Never), "name": Schema.String, "public_key": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "signed": Schema.Boolean.annotate({ "description": "Whether we check a signature on this source's index. An unsigned source still works; the\nconsole marks it." }), "stale": Schema.Boolean.annotate({ "description": "The catalog we're serving is older than the last refresh attempt (offline, or the last\nfetch failed) — entries still install, because the pin travelled with the entry." }), "url": Schema.String }).annotate({ "description": "A configured catalog source and how its last refresh went." }) export type StageTiming = { readonly "name": string, readonly "p50_us": number, readonly "p99_us": number } export const StageTiming = Schema.Struct({ "name": Schema.String.annotate({ "description": "`\"capture\" | \"submit\" | \"encode\" | \"packetize\" | \"send\"` (path-dependent)." }), "p50_us": Schema.Number.annotate({ "format": "float" }).check(Schema.isFinite()), "p99_us": Schema.Number.annotate({ "format": "float" }).check(Schema.isFinite()) }).annotate({ "description": "One pipeline stage's latency in an aggregation window (microseconds)." }) +export type State = "running" | "done" | "failed" +export const State = Schema.Literals(["running", "done", "failed"]) export type StatsStatus = { readonly "armed": boolean, readonly "elapsed_ms": number, readonly "kind": string, readonly "sample_count": number, readonly "started_unix_ms": number } export const StatsStatus = Schema.Struct({ "armed": Schema.Boolean.annotate({ "description": "Capture currently running." }), "elapsed_ms": Schema.Number.annotate({ "description": "Host-measured elapsed time of the in-progress capture, in ms (`0` if idle). Computed from the\nhost's MONOTONIC clock, so a console can show elapsed time without subtracting `started_unix_ms`\nfrom its own (possibly skewed) wall clock.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.String.annotate({ "description": "Path of the in-progress capture (`\"\"` if idle)." }), "sample_count": Schema.Number.annotate({ "description": "Samples in the in-progress capture.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "started_unix_ms": Schema.Number.annotate({ "description": "Unix start time of the in-progress capture (`0` if idle).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "Snapshot of the in-progress capture for the management API." }) export type SubmitPin = { readonly "pin": string } @@ -93,6 +117,8 @@ export type Topology = "auto" | "extend" | "primary" | "exclusive" export const Topology = Schema.Literals(["auto", "extend", "primary", "exclusive"]).annotate({ "description": "What the host does to the box's display topology while managed virtual displays are up." }) export type UiCredential = { readonly "port": number, readonly "secret": string } export const UiCredential = Schema.Struct({ "port": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "secret": Schema.String }).annotate({ "description": "`GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).\nThis is the only endpoint that returns a secret; the console BFF denylists it from the browser." }) +export type UninstallRequest = { readonly "pkg": string } +export const UninstallRequest = Schema.Struct({ "pkg": Schema.String }) export type RuntimeStatus = { readonly "active_sessions": number, readonly "audio_streaming": boolean, readonly "paired_clients": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "stream"?: null | { readonly "bitrate_kbps": number, readonly "codec": ApiCodec, readonly "fps": number, readonly "height": number, readonly "last_resize_ms"?: never, readonly "min_fec": number, readonly "packet_size": number, readonly "time_to_first_frame_ms"?: never, readonly "width": number }, readonly "video_streaming": boolean } export const RuntimeStatus = Schema.Struct({ "active_sessions": Schema.Number.annotate({ "description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a pairing handshake is parked waiting for the user's PIN\n(submit it via `POST /api/v1/pair/pin`)." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "stream": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "codec": ApiCodec, "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "last_resize_ms": Schema.optionalKey(Schema.Never), "min_fec": Schema.Number.annotate({ "description": "Client's parity floor per FEC block (`minRequiredFecPackets`).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "packet_size": Schema.Number.annotate({ "description": "Video payload size per packet (bytes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "time_to_first_frame_ms": Schema.optionalKey(Schema.Never), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming." })], { mode: "oneOf" })), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Live host status (changes as clients launch/end sessions)." }) export type DisplayStateResponse = { readonly "displays": ReadonlyArray } @@ -125,10 +151,14 @@ export type CustomInput = { readonly "art"?: Artwork, readonly "launch"?: null | export const CustomInput = Schema.Struct({ "art": Schema.optionalKey(Artwork), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Request body to create or replace a custom entry (no `id` — the host owns it)." }) export type ProviderEntryInput = { readonly "art"?: Artwork, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "title": string } export const ProviderEntryInput = Schema.Struct({ "art": Schema.optionalKey(Artwork), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key." }) +export type CatalogResponse = { readonly "busy": boolean, readonly "host": HostFacts, readonly "plugins": ReadonlyArray, readonly "sources": ReadonlyArray } +export const CatalogResponse = Schema.Struct({ "busy": Schema.Boolean.annotate({ "description": "True while a package operation is in flight — the console disables install buttons." }), "host": HostFacts, "plugins": Schema.Array(CatalogEntry), "sources": Schema.Array(SourceView) }) export type StatsSample = { readonly "bitrate_kbps": number, readonly "fec_recovered": number, readonly "fps": number, readonly "frames_dropped": number, readonly "mbps": number, readonly "packets_dropped": number, readonly "repeat_fps": number, readonly "send_dropped": number, readonly "session_id": number, readonly "stages": ReadonlyArray, readonly "t_ms": number } export const StatsSample = Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "description": "Configured target bitrate.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fec_recovered": Schema.Number.annotate({ "description": "FEC shards recovered this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fps": Schema.Number.annotate({ "description": "Genuine NEW frames/s from the source.", "format": "float" }).check(Schema.isFinite()), "frames_dropped": Schema.Number.annotate({ "description": "Frames dropped this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mbps": Schema.Number.annotate({ "description": "Transmit goodput (Mb/s).", "format": "float" }).check(Schema.isFinite()), "packets_dropped": Schema.Number.annotate({ "description": "Packets dropped this window (receiver-side / reassembler, where known).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "repeat_fps": Schema.Number.annotate({ "description": "Re-encoded holds/s (source-starvation indicator).", "format": "float" }).check(Schema.isFinite()), "send_dropped": Schema.Number.annotate({ "description": "Host send-buffer overflow / EAGAIN this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "session_id": Schema.Number.annotate({ "description": "Disambiguates concurrent sessions (usually constant).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stages": Schema.Array(StageTiming).annotate({ "description": "Ordered pipeline stages for this path." }), "t_ms": Schema.Number.annotate({ "description": "Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One aggregated sample (~ every 2 s native, ~ every 1 s GameStream)." }) -export type HostEvent = { readonly "client": ClientRef, readonly "kind": "client.connected", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "client": ClientRef, readonly "kind": "client.disconnected", readonly "reason": DisconnectReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.started", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.ended", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.started", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.stopped", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.pending", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.completed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.denied", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "backend": string, readonly "kind": "display.created", readonly "mode": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "count": number, readonly "kind": "display.released", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "library.changed", readonly "source": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "id": string, readonly "kind": "plugins.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "gamestream": boolean, readonly "kind": "host.started", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "host.stopping", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } -export const HostEvent = Schema.Union([Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.connected"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.disconnected"), "reason": DisconnectReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.started"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.ended"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.started"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.stopped"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.pending"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.completed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.denied"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "backend": Schema.String.annotate({ "description": "The virtual-display backend that minted it (`VirtualDisplay::name`)." }), "kind": Schema.Literal("display.created"), "mode": Schema.String.annotate({ "description": "`WxH@Hz`." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "count": Schema.Number.annotate({ "description": "How many kept displays this release retired.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("display.released"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("library.changed"), "source": Schema.String.annotate({ "description": "What mutated the library: `\"manual\"` today; a provider id once the provider\nAPI (RFC §8) lands." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "id": Schema.String.annotate({ "description": "The plugin whose registration changed (registered, restarted, deregistered, or\nlease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set." }), "kind": Schema.Literal("plugins.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "gamestream": Schema.Boolean.annotate({ "description": "Whether the GameStream/Moonlight compat plane is enabled." }), "kind": Schema.Literal("host.started"), "version": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("host.stopping"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) })], { mode: "oneOf" }).annotate({ "description": "The event kind + payload, flattened: `\"kind\": \"stream.started\", …payload…`." }) +export type Job = { readonly "error"?: string | null, readonly "finished_at"?: never, readonly "id": string, readonly "kind": string, readonly "log": ReadonlyArray, readonly "phase": string, readonly "started_at": number, readonly "state": State, readonly "target": string } +export const Job = Schema.Struct({ "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "finished_at": Schema.optionalKey(Schema.Never), "id": Schema.String, "kind": Schema.String.annotate({ "description": "`install` or `uninstall`." }), "log": Schema.Array(Schema.String).annotate({ "description": "Tail of the runner's combined stdout/stderr." }), "phase": Schema.String.annotate({ "description": "Coarse step name, for a progress line the operator can read." }), "started_at": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "state": State, "target": Schema.String.annotate({ "description": "What the operator asked for — a package name, or the raw spec they typed." }) }).annotate({ "description": "A job as the console sees it. Field names are snake_case like the rest of the management API\n(the *file* formats — index, sources, manifest — follow npm's camelCase instead)." }) +export type HostEvent = { readonly "client": ClientRef, readonly "kind": "client.connected", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "client": ClientRef, readonly "kind": "client.disconnected", readonly "reason": DisconnectReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.started", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.ended", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.started", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.stopped", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.pending", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.completed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.denied", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "backend": string, readonly "kind": "display.created", readonly "mode": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "count": number, readonly "kind": "display.released", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "library.changed", readonly "source": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "id": string, readonly "kind": "plugins.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "store.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "gamestream": boolean, readonly "kind": "host.started", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "host.stopping", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } +export const HostEvent = Schema.Union([Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.connected"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.disconnected"), "reason": DisconnectReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.started"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.ended"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.started"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.stopped"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.pending"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.completed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.denied"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "backend": Schema.String.annotate({ "description": "The virtual-display backend that minted it (`VirtualDisplay::name`)." }), "kind": Schema.Literal("display.created"), "mode": Schema.String.annotate({ "description": "`WxH@Hz`." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "count": Schema.Number.annotate({ "description": "How many kept displays this release retired.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("display.released"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("library.changed"), "source": Schema.String.annotate({ "description": "What mutated the library: `\"manual\"` today; a provider id once the provider\nAPI (RFC §8) lands." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "id": Schema.String.annotate({ "description": "The plugin whose registration changed (registered, restarted, deregistered, or\nlease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set." }), "kind": Schema.Literal("plugins.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("store.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The set of installed plugins, or what the store knows about them, changed — an install or\nuninstall finished, or a catalog refresh brought in new rows. A consumer re-reads\n`GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer\nis a join over several sources of truth, so \"go look again\" is the only honest signal." }), Schema.Struct({ "gamestream": Schema.Boolean.annotate({ "description": "Whether the GameStream/Moonlight compat plane is enabled." }), "kind": Schema.Literal("host.started"), "version": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("host.stopping"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) })], { mode: "oneOf" }).annotate({ "description": "The event kind + payload, flattened: `\"kind\": \"stream.started\", …payload…`." }) export type CustomPreset = { readonly "fields": { readonly "identity": Identity, readonly "keep_alive": KeepAlive, readonly "layout": Layout, readonly "max_displays": number, readonly "mode_conflict": ModeConflict, readonly "topology": Topology }, readonly "game_session"?: "auto" | "dedicated", readonly "id": string, readonly "name": string } export const CustomPreset = Schema.Struct({ "fields": Schema.Struct({ "identity": Identity, "keep_alive": KeepAlive, "layout": Layout, "max_displays": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode_conflict": ModeConflict, "topology": Topology }).annotate({ "description": "The six display-behavior axes this preset applies (the same shape a built-in preset expands to)." }), "game_session": Schema.optionalKey(Schema.Literals(["auto", "dedicated"]).annotate({ "description": "The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).\nA custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying\none does set this axis." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "name": Schema.String.annotate({ "description": "User-facing name shown on the preset card; editable." }) }).annotate({ "description": "A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a\nbuilt-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names\nand applies from the console.\n\nUnlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in\n`/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these\nfields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and\nthe built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:\nediting or deleting a preset never mutates the running policy (re-apply to adopt a change)." }) export type DisplayPolicy = { readonly "ddc_power_off"?: boolean, readonly "game_session"?: "auto" | "dedicated", readonly "identity"?: Identity, readonly "keep_alive"?: KeepAlive, readonly "layout"?: Layout, readonly "max_displays"?: number, readonly "mode_conflict"?: ModeConflict, readonly "pnp_disable_monitors"?: boolean, readonly "preset"?: Preset, readonly "topology"?: Topology, readonly "version"?: number } @@ -320,6 +350,20 @@ export type DeleteProviderEntries401 = ApiError export const DeleteProviderEntries401 = ApiError export type DeleteProviderEntries500 = ApiError export const DeleteProviderEntries500 = ApiError +export type ListLibraryScanners200 = ReadonlyArray +export const ListLibraryScanners200 = Schema.Array(ScannerInfo) +export type ListLibraryScanners401 = ApiError +export const ListLibraryScanners401 = ApiError +export type SetLibraryScannerRequestJson = ScannerToggle +export const SetLibraryScannerRequestJson = ScannerToggle +export type SetLibraryScanner200 = ReadonlyArray +export const SetLibraryScanner200 = Schema.Array(ScannerInfo) +export type SetLibraryScanner401 = ApiError +export const SetLibraryScanner401 = ApiError +export type SetLibraryScanner404 = ApiError +export const SetLibraryScanner404 = ApiError +export type SetLibraryScanner500 = ApiError +export const SetLibraryScanner500 = ApiError export type GetLocalSummary200 = LocalSummary export const GetLocalSummary200 = LocalSummary export type GetLocalSummary401 = ApiError @@ -460,6 +504,96 @@ export type GetStatus200 = RuntimeStatus export const GetStatus200 = RuntimeStatus export type GetStatus401 = ApiError export const GetStatus401 = ApiError +export type GetPluginCatalog200 = CatalogResponse +export const GetPluginCatalog200 = CatalogResponse +export type GetPluginCatalog401 = ApiError +export const GetPluginCatalog401 = ApiError +export type GetPluginCatalog403 = ApiError +export const GetPluginCatalog403 = ApiError +export type InstallPluginRequestJson = InstallRequest +export const InstallPluginRequestJson = InstallRequest +export type InstallPlugin202 = JobRef +export const InstallPlugin202 = JobRef +export type InstallPlugin400 = ApiError +export const InstallPlugin400 = ApiError +export type InstallPlugin401 = ApiError +export const InstallPlugin401 = ApiError +export type InstallPlugin403 = ApiError +export const InstallPlugin403 = ApiError +export type InstallPlugin409 = ApiError +export const InstallPlugin409 = ApiError +export type ListInstalledPlugins200 = ReadonlyArray +export const ListInstalledPlugins200 = Schema.Array(InstalledView) +export type ListInstalledPlugins401 = ApiError +export const ListInstalledPlugins401 = ApiError +export type ListInstalledPlugins403 = ApiError +export const ListInstalledPlugins403 = ApiError +export type ListPluginJobs200 = ReadonlyArray +export const ListPluginJobs200 = Schema.Array(Job) +export type ListPluginJobs401 = ApiError +export const ListPluginJobs401 = ApiError +export type ListPluginJobs403 = ApiError +export const ListPluginJobs403 = ApiError +export type GetPluginJob200 = Job +export const GetPluginJob200 = Job +export type GetPluginJob401 = ApiError +export const GetPluginJob401 = ApiError +export type GetPluginJob403 = ApiError +export const GetPluginJob403 = ApiError +export type GetPluginJob404 = ApiError +export const GetPluginJob404 = ApiError +export type RefreshPluginCatalog200 = CatalogResponse +export const RefreshPluginCatalog200 = CatalogResponse +export type RefreshPluginCatalog401 = ApiError +export const RefreshPluginCatalog401 = ApiError +export type RefreshPluginCatalog403 = ApiError +export const RefreshPluginCatalog403 = ApiError +export type GetPluginRuntime200 = RuntimeView +export const GetPluginRuntime200 = RuntimeView +export type GetPluginRuntime401 = ApiError +export const GetPluginRuntime401 = ApiError +export type GetPluginRuntime403 = ApiError +export const GetPluginRuntime403 = ApiError +export type SetPluginRuntimeRequestJson = RuntimeRequest +export const SetPluginRuntimeRequestJson = RuntimeRequest +export type SetPluginRuntime200 = RuntimeView +export const SetPluginRuntime200 = RuntimeView +export type SetPluginRuntime400 = ApiError +export const SetPluginRuntime400 = ApiError +export type SetPluginRuntime401 = ApiError +export const SetPluginRuntime401 = ApiError +export type SetPluginRuntime403 = ApiError +export const SetPluginRuntime403 = ApiError +export type ListPluginSources200 = ReadonlyArray +export const ListPluginSources200 = Schema.Array(SourceView) +export type ListPluginSources401 = ApiError +export const ListPluginSources401 = ApiError +export type ListPluginSources403 = ApiError +export const ListPluginSources403 = ApiError +export type PutPluginSourceRequestJson = SourceInput +export const PutPluginSourceRequestJson = SourceInput +export type PutPluginSource400 = ApiError +export const PutPluginSource400 = ApiError +export type PutPluginSource401 = ApiError +export const PutPluginSource401 = ApiError +export type PutPluginSource403 = ApiError +export const PutPluginSource403 = ApiError +export type DeletePluginSource401 = ApiError +export const DeletePluginSource401 = ApiError +export type DeletePluginSource403 = ApiError +export const DeletePluginSource403 = ApiError +export type UninstallPluginRequestJson = UninstallRequest +export const UninstallPluginRequestJson = UninstallRequest +export type UninstallPlugin202 = JobRef +export const UninstallPlugin202 = JobRef +export type UninstallPlugin400 = ApiError +export const UninstallPlugin400 = ApiError +export type UninstallPlugin401 = ApiError +export const UninstallPlugin401 = ApiError +export type UninstallPlugin403 = ApiError +export const UninstallPlugin403 = ApiError +export type UninstallPlugin409 = ApiError +export const UninstallPlugin409 = ApiError export interface OperationConfig { /** @@ -776,6 +910,23 @@ export const make = ( "500": decodeError("DeleteProviderEntries500", DeleteProviderEntries500), orElse: unexpectedStatus })) + ), + "listLibraryScanners": (options) => HttpClientRequest.get(`/api/v1/library/scanners`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ListLibraryScanners200), + "401": decodeError("ListLibraryScanners401", ListLibraryScanners401), + orElse: unexpectedStatus + })) + ), + "setLibraryScanner": (id, options) => HttpClientRequest.put(`/api/v1/library/scanners/${id}`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(SetLibraryScanner200), + "401": decodeError("SetLibraryScanner401", SetLibraryScanner401), + "404": decodeError("SetLibraryScanner404", SetLibraryScanner404), + "500": decodeError("SetLibraryScanner500", SetLibraryScanner500), + orElse: unexpectedStatus + })) ), "getLocalSummary": (options) => HttpClientRequest.get(`/api/v1/local/summary`).pipe( withResponse(options?.config)(HttpClientResponse.matchStatus({ @@ -986,6 +1137,113 @@ export const make = ( "401": decodeError("GetStatus401", GetStatus401), orElse: unexpectedStatus })) + ), + "getPluginCatalog": (options) => HttpClientRequest.get(`/api/v1/store/catalog`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(GetPluginCatalog200), + "401": decodeError("GetPluginCatalog401", GetPluginCatalog401), + "403": decodeError("GetPluginCatalog403", GetPluginCatalog403), + orElse: unexpectedStatus + })) + ), + "installPlugin": (options) => HttpClientRequest.post(`/api/v1/store/install`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(InstallPlugin202), + "400": decodeError("InstallPlugin400", InstallPlugin400), + "401": decodeError("InstallPlugin401", InstallPlugin401), + "403": decodeError("InstallPlugin403", InstallPlugin403), + "409": decodeError("InstallPlugin409", InstallPlugin409), + orElse: unexpectedStatus + })) + ), + "listInstalledPlugins": (options) => HttpClientRequest.get(`/api/v1/store/installed`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ListInstalledPlugins200), + "401": decodeError("ListInstalledPlugins401", ListInstalledPlugins401), + "403": decodeError("ListInstalledPlugins403", ListInstalledPlugins403), + orElse: unexpectedStatus + })) + ), + "listPluginJobs": (options) => HttpClientRequest.get(`/api/v1/store/jobs`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ListPluginJobs200), + "401": decodeError("ListPluginJobs401", ListPluginJobs401), + "403": decodeError("ListPluginJobs403", ListPluginJobs403), + orElse: unexpectedStatus + })) + ), + "getPluginJob": (id, options) => HttpClientRequest.get(`/api/v1/store/jobs/${id}`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(GetPluginJob200), + "401": decodeError("GetPluginJob401", GetPluginJob401), + "403": decodeError("GetPluginJob403", GetPluginJob403), + "404": decodeError("GetPluginJob404", GetPluginJob404), + orElse: unexpectedStatus + })) + ), + "refreshPluginCatalog": (options) => HttpClientRequest.post(`/api/v1/store/refresh`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(RefreshPluginCatalog200), + "401": decodeError("RefreshPluginCatalog401", RefreshPluginCatalog401), + "403": decodeError("RefreshPluginCatalog403", RefreshPluginCatalog403), + orElse: unexpectedStatus + })) + ), + "getPluginRuntime": (options) => HttpClientRequest.get(`/api/v1/store/runtime`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(GetPluginRuntime200), + "401": decodeError("GetPluginRuntime401", GetPluginRuntime401), + "403": decodeError("GetPluginRuntime403", GetPluginRuntime403), + orElse: unexpectedStatus + })) + ), + "setPluginRuntime": (options) => HttpClientRequest.post(`/api/v1/store/runtime`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(SetPluginRuntime200), + "400": decodeError("SetPluginRuntime400", SetPluginRuntime400), + "401": decodeError("SetPluginRuntime401", SetPluginRuntime401), + "403": decodeError("SetPluginRuntime403", SetPluginRuntime403), + orElse: unexpectedStatus + })) + ), + "listPluginSources": (options) => HttpClientRequest.get(`/api/v1/store/sources`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ListPluginSources200), + "401": decodeError("ListPluginSources401", ListPluginSources401), + "403": decodeError("ListPluginSources403", ListPluginSources403), + orElse: unexpectedStatus + })) + ), + "putPluginSource": (name, options) => HttpClientRequest.put(`/api/v1/store/sources/${name}`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "400": decodeError("PutPluginSource400", PutPluginSource400), + "401": decodeError("PutPluginSource401", PutPluginSource401), + "403": decodeError("PutPluginSource403", PutPluginSource403), + "204": () => Effect.void, + orElse: unexpectedStatus + })) + ), + "deletePluginSource": (name, options) => HttpClientRequest.delete(`/api/v1/store/sources/${name}`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "401": decodeError("DeletePluginSource401", DeletePluginSource401), + "403": decodeError("DeletePluginSource403", DeletePluginSource403), + "204": () => Effect.void, + orElse: unexpectedStatus + })) + ), + "uninstallPlugin": (options) => HttpClientRequest.post(`/api/v1/store/uninstall`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(UninstallPlugin202), + "400": decodeError("UninstallPlugin400", UninstallPlugin400), + "401": decodeError("UninstallPlugin401", UninstallPlugin401), + "403": decodeError("UninstallPlugin403", UninstallPlugin403), + "409": decodeError("UninstallPlugin409", UninstallPlugin409), + orElse: unexpectedStatus + })) ) } } @@ -1157,6 +1415,21 @@ readonly "reconcileProviderEntries": (provider: */ readonly "deleteProviderEntries": (provider: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"DeleteProviderEntries400", typeof DeleteProviderEntries400.Type> | PunktfunkError<"DeleteProviderEntries401", typeof DeleteProviderEntries401.Type> | PunktfunkError<"DeleteProviderEntries500", typeof DeleteProviderEntries500.Type>> /** +* The installed-store scanners this host supports — the list is platform-dependent (Steam +* everywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console +* renders a toggle only for scanners that can do anything here. Scanners default to enabled; +* disabling one hides its titles from every library surface from the next read. The user-curated +* custom store is not a scanner and is always on. +*/ +readonly "listLibraryScanners": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListLibraryScanners401", typeof ListLibraryScanners401.Type>> + /** +* Persists the toggle and applies it from the next library read (no restart). Disabling a scanner +* hides its titles everywhere — the console grid, native clients, and the GameStream app list — +* and re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits +* `library.changed` with the scanner id as `source` when the state changed. +*/ +readonly "setLibraryScanner": (id: string, options: { readonly payload: typeof SetLibraryScannerRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"SetLibraryScanner401", typeof SetLibraryScanner401.Type> | PunktfunkError<"SetLibraryScanner404", typeof SetLibraryScanner404.Type> | PunktfunkError<"SetLibraryScanner500", typeof SetLibraryScanner500.Type>> + /** * Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device * names). Unauthenticated, but served to loopback peers only. */ @@ -1287,6 +1560,71 @@ readonly "statsRecordingDelete": (id: string, op * Live host status */ readonly "getStatus": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetStatus401", typeof GetStatus401.Type>> + /** +* The merged shelf across every configured source, annotated with what this host already has and +* what it can run. Sources past their freshness window are refreshed first; a source that can't be +* reached keeps serving its last good copy, marked `stale` (a LAN-only host still has a working +* store — an entry's pin travelled with the entry). +*/ +readonly "getPluginCatalog": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetPluginCatalog401", typeof GetPluginCatalog401.Type> | PunktfunkError<"GetPluginCatalog403", typeof GetPluginCatalog403.Type>> + /** +* Either `{source, id}` — a catalogued entry, installed at its pinned version after its integrity +* is re-checked against the registry — or `{spec, accept_unverified: true}`, which installs an +* unreviewed package the operator takes responsibility for. Returns `202` with a job id; watch it +* at `GET /store/jobs/{id}`. +* +* One package operation runs at a time (`409` otherwise): `bun` operations share a lockfile and a +* `node_modules` tree. +*/ +readonly "installPlugin": (options: { readonly payload: typeof InstallPluginRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"InstallPlugin400", typeof InstallPlugin400.Type> | PunktfunkError<"InstallPlugin401", typeof InstallPlugin401.Type> | PunktfunkError<"InstallPlugin403", typeof InstallPlugin403.Type> | PunktfunkError<"InstallPlugin409", typeof InstallPlugin409.Type>> + /** +* What's actually in the plugins directory, joined with how it got there (the provenance manifest) +* and whether it is registered right now. A package with no provenance record was installed with +* the CLI and reports `tier: "cli"` — absence is the answer, not a gap. +*/ +readonly "listInstalledPlugins": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListInstalledPlugins401", typeof ListInstalledPlugins401.Type> | PunktfunkError<"ListInstalledPlugins403", typeof ListInstalledPlugins403.Type>> + /** +* List recent package jobs +*/ +readonly "listPluginJobs": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListPluginJobs401", typeof ListPluginJobs401.Type> | PunktfunkError<"ListPluginJobs403", typeof ListPluginJobs403.Type>> + /** +* Poll this while `state` is `running`; `log` carries the tail of the package manager's output. +*/ +readonly "getPluginJob": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetPluginJob401", typeof GetPluginJob401.Type> | PunktfunkError<"GetPluginJob403", typeof GetPluginJob403.Type> | PunktfunkError<"GetPluginJob404", typeof GetPluginJob404.Type>> + /** +* Bypasses the freshness window and re-fetches all sources, then returns the merged catalog. +*/ +readonly "refreshPluginCatalog": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"RefreshPluginCatalog401", typeof RefreshPluginCatalog401.Type> | PunktfunkError<"RefreshPluginCatalog403", typeof RefreshPluginCatalog403.Type>> + /** +* Installed plugins only run while the runner is on, and the runner discovers units at startup — +* so this is both the "is anything running" answer and the explanation for a freshly installed +* plugin that hasn't appeared yet. +*/ +readonly "getPluginRuntime": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetPluginRuntime401", typeof GetPluginRuntime401.Type> | PunktfunkError<"GetPluginRuntime403", typeof GetPluginRuntime403.Type>> + /** +* Turn the plugin runner on or off +*/ +readonly "setPluginRuntime": (options: { readonly payload: typeof SetPluginRuntimeRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"SetPluginRuntime400", typeof SetPluginRuntime400.Type> | PunktfunkError<"SetPluginRuntime401", typeof SetPluginRuntime401.Type> | PunktfunkError<"SetPluginRuntime403", typeof SetPluginRuntime403.Type>> + /** +* List catalog sources +*/ +readonly "listPluginSources": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListPluginSources401", typeof ListPluginSources401.Type> | PunktfunkError<"ListPluginSources403", typeof ListPluginSources403.Type>> + /** +* Adding a source is a trust decision: its entries become installable on this host. They are +* attributed to it in the console and never carry the "verified" badge, which belongs to the +* built-in source alone. +*/ +readonly "putPluginSource": (name: string, options: { readonly payload: typeof PutPluginSourceRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"PutPluginSource400", typeof PutPluginSource400.Type> | PunktfunkError<"PutPluginSource401", typeof PutPluginSource401.Type> | PunktfunkError<"PutPluginSource403", typeof PutPluginSource403.Type>> + /** +* Remove a catalog source +*/ +readonly "deletePluginSource": (name: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"DeletePluginSource401", typeof DeletePluginSource401.Type> | PunktfunkError<"DeletePluginSource403", typeof DeletePluginSource403.Type>> + /** +* Removes the package and forgets its provenance, then restarts the runner. Only names the runner +* would actually supervise are accepted, so this can't be used to rip a shared dependency out of +* the tree. +*/ +readonly "uninstallPlugin": (options: { readonly payload: typeof UninstallPluginRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"UninstallPlugin400", typeof UninstallPlugin400.Type> | PunktfunkError<"UninstallPlugin401", typeof UninstallPlugin401.Type> | PunktfunkError<"UninstallPlugin403", typeof UninstallPlugin403.Type> | PunktfunkError<"UninstallPlugin409", typeof UninstallPlugin409.Type>> } export interface PunktfunkError { diff --git a/sdk/src/runner.ts b/sdk/src/runner.ts index b2ab539d..9ccad6e6 100644 --- a/sdk/src/runner.ts +++ b/sdk/src/runner.ts @@ -302,8 +302,27 @@ export const discoverUnits = ( // no scripts dir — fine } const modules = path.join(pluginsDir, "node_modules"); + // The packages the operator actually installed — `bun add` records them as the plugins dir's + // own `dependencies`. This is what separates a plugin from a plugin's LIBRARY: + // `@punktfunk/plugin-kit` matches the `plugin-*` naming convention exactly but arrives as a + // transitive dependency of every kit-built plugin, and running it as a unit is nonsense. + // `undefined` only when there is no readable `package.json` at all — a hand-assembled tree then + // falls back to the naming convention rather than discovering nothing. A package.json with no + // `dependencies` key yields an EMPTY set, not `undefined`: `bun remove` drops the key when the + // last plugin goes, and orphaned transitive packages can outlive it, so falling back there + // would start running a plugin's library the moment you uninstall the last real plugin. + let topLevel: Set | undefined; + try { + const root = JSON.parse( + fs.readFileSync(path.join(pluginsDir, "package.json"), "utf8"), + ) as { dependencies?: Record }; + topLevel = new Set(Object.keys(root.dependencies ?? {})); + } catch { + // no package.json — fall back to the convention + } // Read a plugin package's manifest (`module`/`main` entry) and add it as a unit. const addPlugin = (dir: string, name: string): void => { + if (topLevel && !topLevel.has(name)) return; // a dependency, not an installed plugin try { const manifest = JSON.parse( fs.readFileSync(path.join(dir, "package.json"), "utf8"), diff --git a/sdk/test/runner.test.ts b/sdk/test/runner.test.ts index ed529775..96ab5d7f 100644 --- a/sdk/test/runner.test.ts +++ b/sdk/test/runner.test.ts @@ -107,6 +107,83 @@ describe("discovery", () => { const units = discoverUnits(d); expect(units.map((u) => u.name)).toEqual(["@punktfunk/plugin-y"]); }); + + test("discovers plugins in ANY scope, not just @punktfunk", () => { + // Plugin-store catalog entries must be scoped so the scope can map to that entry's + // registry, so a third-party plugin necessarily arrives under its own scope. Discovery + // limited to @punktfunk would let it install and then never run. + const d = mkdirs("discover-foreign-scope"); + write( + path.join(d.pluginsDir, "node_modules", "@retro-hub", "plugin-z", "package.json"), + JSON.stringify({ name: "@retro-hub/plugin-z", main: "index.js" }), + ); + write( + path.join(d.pluginsDir, "node_modules", "@retro-hub", "plugin-z", "index.js"), + "export default { name: 'z', main: async () => {} };", + ); + expect(discoverUnits(d).map((u) => u.name)).toEqual(["@retro-hub/plugin-z"]); + }); + + test("a plugin's LIBRARY is not a unit — top-level dependencies win", () => { + // Regression from a live install: `@punktfunk/plugin-kit` is the framework kit-built + // plugins depend on. It matches the `plugin-*` convention exactly and lands in + // node_modules transitively, so a convention-only scan would import and run the framework + // as if it were a plugin. + const d = mkdirs("discover-toplevel"); + for (const name of ["plugin-real", "plugin-kit"]) { + write( + path.join(d.pluginsDir, "node_modules", "@punktfunk", name, "package.json"), + JSON.stringify({ name: `@punktfunk/${name}`, main: "index.js" }), + ); + write( + path.join(d.pluginsDir, "node_modules", "@punktfunk", name, "index.js"), + "export default { name: 'p', main: async () => {} };", + ); + } + write( + path.join(d.pluginsDir, "package.json"), + JSON.stringify({ dependencies: { "@punktfunk/plugin-real": "^1.0.0" } }), + ); + expect(discoverUnits(d).map((u) => u.name)).toEqual(["@punktfunk/plugin-real"]); + }); + + test("no package.json at all falls back to the naming convention", () => { + // Hand-assembled or older trees must still run, not silently discover nothing. + const d = mkdirs("discover-nodeps"); + write( + path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-legacy", "package.json"), + JSON.stringify({ name: "punktfunk-plugin-legacy", main: "index.js" }), + ); + write( + path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-legacy", "index.js"), + "export default { name: 'l', main: async () => {} };", + ); + expect(discoverUnits(d).map((u) => u.name)).toEqual(["punktfunk-plugin-legacy"]); + }); + + test("an emptied dependency list means nothing to run", () => { + // `bun remove` drops the `dependencies` key when the last plugin goes, while orphaned + // transitive packages linger in node_modules. Falling back to the naming convention there + // would start running a plugin's LIBRARY right after the operator removed the only real + // plugin — seen on-glass. + const d = mkdirs("discover-emptied"); + write( + path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-kit", "package.json"), + JSON.stringify({ name: "@punktfunk/plugin-kit", main: "index.js" }), + ); + write( + path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-kit", "index.js"), + "export default {};", + ); + write(path.join(d.pluginsDir, "package.json"), JSON.stringify({ name: "plugins" })); + expect(discoverUnits(d)).toEqual([]); + + write( + path.join(d.pluginsDir, "package.json"), + JSON.stringify({ name: "plugins", dependencies: {} }), + ); + expect(discoverUnits(d)).toEqual([]); + }); }); describe("windowsSddlUnsafeReason (the sshd rule's Windows half, pure)", () => { diff --git a/web/messages/de.json b/web/messages/de.json index 1eb25958..975b24af 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -267,5 +267,112 @@ "stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.", "stats_detail_title": "Aufnahme-Details", "stats_close": "Schließen", - "stats_no_samples": "Diese Aufnahme enthält keine Proben." + "stats_no_samples": "Diese Aufnahme enthält keine Proben.", + "store_title": "Plugins", + "store_subtitle": "Durchstöbere den Plugin-Katalog, installiere und entferne Plugins und lege fest, welchen Katalogen dieser Host vertraut.", + "store_tab_browse": "Entdecken", + "store_tab_installed": "Installiert", + "store_tab_sources": "Quellen", + "store_tier_verified": "Geprüft", + "store_tier_verified_hint": "Aus dem eingebauten unom-Katalog — unom hat genau dieses Paket geprüft.", + "store_tier_external": "Externe Quelle", + "store_tier_external_hint": "Festgepinnt und auf Integrität geprüft, aber von jemand anderem als unom kuratiert. Niemand bei unom hat diesen Code geprüft.", + "store_tier_unverified": "Ungeprüft", + "store_tier_unverified_hint": "Aus einer rohen Paketangabe installiert. Kein Katalog, keine Prüfung — diesen Code hat nie jemand kontrolliert.", + "store_tier_cli": "Über die CLI installiert", + "store_tier_cli_hint": "Von der Kommandozeile installiert — der Host weiß daher nicht, woher es stammt.", + "store_from_source": "von", + "store_search_placeholder": "Plugins durchsuchen…", + "store_filter_all": "Alle Quellen", + "store_empty": "Noch keine Plugins im Katalog.", + "store_no_match": "Kein Plugin passt zur Suche.", + "store_by_author": "von {author}", + "store_homepage": "Website", + "store_install": "Installieren", + "store_installed_label": "Installiert", + "store_update_to": "Auf {version} aktualisieren", + "store_incompatible": "Nicht kompatibel mit diesem Host.", + "store_blocked": "Vom Host blockiert: {reason}", + "store_spec_open": "Aus Paketangabe installieren…", + "store_runner_title": "Plugin-Runner", + "store_runner_help": "Der Dienst, in dem jedes Plugin läuft. Ihn abzuschalten stoppt alle Plugins auf einmal, ohne etwas zu deinstallieren.", + "store_runner_not_installed": "Der Plugin-Runner ist auf diesem Host nicht installiert — Plugins können daher nicht starten.", + "store_runner_banner": "Der Plugin-Runner ist ausgeschaltet — Plugins starten erst, wenn du ihn aktivierst.", + "store_runner_enable": "Runner aktivieren", + "store_runner_disable": "Runner deaktivieren", + "store_runner_state_running": "Läuft", + "store_runner_state_stopped": "Gestoppt", + "store_runner_state_disabled": "Deaktiviert", + "store_runner_state_missing": "Nicht installiert", + "store_runner_unit": "Dienst", + "store_runner_principal": "Läuft als", + "store_runner_failed": "Der Plugin-Runner konnte nicht umgeschaltet werden.", + "store_installed_title": "Installierte Plugins", + "store_installed_empty": "Noch keine Plugins installiert.", + "store_running": "Läuft", + "store_stopped": "Läuft nicht", + "store_uninstall": "Deinstallieren", + "store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.", + "store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.", + "store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.", + "store_sources_title": "Katalogquellen", + "store_sources_help": "Wo dieser Host nach Plugins sucht. Der eingebaute unom-Katalog ist immer dabei; jede weitere Quelle hast du selbst hinzugefügt und stehst selbst dafür ein.", + "store_refresh_all": "Alle aktualisieren", + "store_refresh_failed": "Die Kataloge konnten nicht aktualisiert werden.", + "store_source_builtin": "Eingebaut", + "store_source_signed": "Signiert", + "store_source_unsigned": "Unsigniert", + "store_source_stale": "Veraltet", + "store_source_entries": "{count} Plugins", + "store_source_fetched": "zuletzt geladen {when}", + "store_source_never": "nie", + "store_source_remove": "Quelle entfernen", + "store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.", + "store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.", + "store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.", + "store_add_source_title": "Katalogquelle hinzufügen", + "store_field_source_name": "Name", + "store_field_source_url": "Index-URL", + "store_field_source_key": "Öffentlicher Schlüssel (optional)", + "store_field_source_key_help": "Ein ed25519:…-Schlüssel. Ist er gesetzt, akzeptiert der Host von dieser Quelle nur einen signierten Index.", + "store_add_source": "Quelle hinzufügen", + "store_add_source_failed": "Die Quelle konnte nicht gespeichert werden — prüfe Name und URL.", + "store_source_trust_title": "Dieser Quelle vertrauen?", + "store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.", + "store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.", + "store_source_trust_confirm": "Verstanden — Quelle hinzufügen", + "store_install_title": "{title} installieren?", + "store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.", + "store_install_confirm": "Installieren", + "store_install_external_title": "{title} aus einer externen Quelle installieren?", + "store_install_external_body": "Version {version} stammt aus „{source}“ — einem Katalog, den du selbst hinzugefügt hast.", + "store_install_external_note": "unom hat diesen Code nicht geprüft. Das Paket ist festgepinnt und auf Integrität geprüft, läuft auf diesem Host aber mit den Rechten des Plugin-Runners.", + "store_install_external_confirm": "Trotzdem installieren", + "store_install_failed": "Die Installation konnte nicht gestartet werden.", + "store_busy": "Der Host installiert oder entfernt gerade schon ein Plugin. Versuche es, sobald das durch ist.", + "store_spec_title": "Aus einer Paketangabe installieren", + "store_spec_lead": "Das installiert Code direkt aus einer Paket-Registry. Er steht in keinem Katalog, niemand hat ihn geprüft, und er läuft auf diesem Host mit den Rechten des Plugin-Runners — mit demselben Zugriff auf deine Dateien und deine Sitzung.", + "store_spec_permanent": "Ein so installiertes Plugin bleibt als „Ungeprüft“ markiert, solange es installiert ist.", + "store_spec_field": "Paketangabe", + "store_spec_field_help": "Zum Beispiel @scope/plugin-name@1.2.3.", + "store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein", + "store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.", + "store_spec_confirm": "Ungeprüft installieren", + "store_job_install": "{target} wird installiert", + "store_job_uninstall": "{target} wird entfernt", + "store_job_done_install": "Installiert.", + "store_job_done_uninstall": "Entfernt.", + "store_job_failed": "Der Vorgang ist fehlgeschlagen.", + "store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.", + "store_job_log": "Log anzeigen", + "store_job_dismiss": "Ausblenden", + "store_phase_queued": "In der Warteschlange", + "store_phase_verifying": "Paket wird verifiziert", + "store_phase_installing": "Wird installiert", + "store_phase_removing": "Wird entfernt", + "store_phase_checking": "Installation wird geprüft", + "store_phase_rolling_back": "Wird zurückgerollt", + "store_phase_recording": "Herkunft wird vermerkt", + "store_phase_restarting": "Plugin-Runner startet neu", + "store_phase_done": "Fertig" } diff --git a/web/messages/en.json b/web/messages/en.json index 01d3ff16..4a07ddba 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -267,5 +267,112 @@ "stats_delete_confirm": "Delete this recording? This can't be undone.", "stats_detail_title": "Recording detail", "stats_close": "Close", - "stats_no_samples": "This recording has no samples." + "stats_no_samples": "This recording has no samples.", + "store_title": "Plugins", + "store_subtitle": "Browse the plugin catalog, install and remove plugins, and choose which catalogs this host trusts.", + "store_tab_browse": "Browse", + "store_tab_installed": "Installed", + "store_tab_sources": "Sources", + "store_tier_verified": "Verified", + "store_tier_verified_hint": "From the built-in unom catalog — unom reviewed this exact package.", + "store_tier_external": "External source", + "store_tier_external_hint": "Pinned and integrity-checked, but curated by someone other than unom. Nobody at unom reviewed this code.", + "store_tier_unverified": "Unverified", + "store_tier_unverified_hint": "Installed from a raw package spec. No catalog, no review — nobody checked this code.", + "store_tier_cli": "Installed via CLI", + "store_tier_cli_hint": "Installed from the command line, so the host has no record of where it came from.", + "store_from_source": "from", + "store_search_placeholder": "Search plugins…", + "store_filter_all": "All sources", + "store_empty": "No plugins in the catalog yet.", + "store_no_match": "No plugin matches your search.", + "store_by_author": "by {author}", + "store_homepage": "Homepage", + "store_install": "Install", + "store_installed_label": "Installed", + "store_update_to": "Update to {version}", + "store_incompatible": "Not compatible with this host.", + "store_blocked": "Blocked by the host: {reason}", + "store_spec_open": "Install from package spec…", + "store_runner_title": "Plugin runner", + "store_runner_help": "The service every plugin runs inside. Disabling it stops all plugins at once without uninstalling anything.", + "store_runner_not_installed": "The plugin runner isn't installed on this host, so plugins can't start.", + "store_runner_banner": "The plugin runner is switched off — plugins won't start until you enable it.", + "store_runner_enable": "Enable runner", + "store_runner_disable": "Disable runner", + "store_runner_state_running": "Running", + "store_runner_state_stopped": "Stopped", + "store_runner_state_disabled": "Disabled", + "store_runner_state_missing": "Not installed", + "store_runner_unit": "Service", + "store_runner_principal": "Runs as", + "store_runner_failed": "Could not change the plugin runner.", + "store_installed_title": "Installed plugins", + "store_installed_empty": "No plugins installed yet.", + "store_running": "Running", + "store_stopped": "Not running", + "store_uninstall": "Uninstall", + "store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.", + "store_uninstall_failed": "Could not start the removal.", + "store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.", + "store_sources_title": "Catalog sources", + "store_sources_help": "Where this host looks for plugins. The built-in unom catalog is always present; every other source is one you added and vouch for yourself.", + "store_refresh_all": "Refresh all", + "store_refresh_failed": "Could not refresh the catalogs.", + "store_source_builtin": "Built in", + "store_source_signed": "Signed", + "store_source_unsigned": "Unsigned", + "store_source_stale": "Out of date", + "store_source_entries": "{count} plugins", + "store_source_fetched": "last fetched {when}", + "store_source_never": "never", + "store_source_remove": "Remove source", + "store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.", + "store_source_remove_failed": "Could not remove the source.", + "store_source_builtin_locked": "The built-in unom catalog can't be removed.", + "store_add_source_title": "Add a catalog source", + "store_field_source_name": "Name", + "store_field_source_url": "Index URL", + "store_field_source_key": "Public key (optional)", + "store_field_source_key_help": "An ed25519:… key. With a key set, the host only accepts a signed index from this source.", + "store_add_source": "Add source", + "store_add_source_failed": "Could not save the source — check the name and the URL.", + "store_source_trust_title": "Trust this source?", + "store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.", + "store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.", + "store_source_trust_confirm": "I understand — add the source", + "store_install_title": "Install {title}?", + "store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.", + "store_install_confirm": "Install", + "store_install_external_title": "Install {title} from an external source?", + "store_install_external_body": "Version {version} comes from “{source}”, a catalog you added yourself.", + "store_install_external_note": "unom has not reviewed this code. The package is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.", + "store_install_external_confirm": "Install anyway", + "store_install_failed": "Could not start the install.", + "store_busy": "The host is already installing or removing a plugin. Try again once it's finished.", + "store_spec_title": "Install from a package spec", + "store_spec_lead": "This installs code straight from a package registry. It is in no catalog, nobody has reviewed it, and it will run on this host with the plugin runner's privileges — the same access it has to your files and your session.", + "store_spec_permanent": "A plugin installed this way stays marked Unverified for as long as it is installed.", + "store_spec_field": "Package spec", + "store_spec_field_help": "For example @scope/plugin-name@1.2.3.", + "store_spec_confirm_field": "Type the package spec again to confirm", + "store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.", + "store_spec_confirm": "Install unverified", + "store_job_install": "Installing {target}", + "store_job_uninstall": "Removing {target}", + "store_job_done_install": "Installed.", + "store_job_done_uninstall": "Removed.", + "store_job_failed": "The job failed.", + "store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.", + "store_job_log": "Show log", + "store_job_dismiss": "Dismiss", + "store_phase_queued": "Queued", + "store_phase_verifying": "Verifying the package", + "store_phase_installing": "Installing", + "store_phase_removing": "Removing", + "store_phase_checking": "Checking the install", + "store_phase_rolling_back": "Rolling back", + "store_phase_recording": "Recording provenance", + "store_phase_restarting": "Restarting the plugin runner", + "store_phase_done": "Done" } diff --git a/web/src/api/store.ts b/web/src/api/store.ts new file mode 100644 index 00000000..8079d8b1 --- /dev/null +++ b/web/src/api/store.ts @@ -0,0 +1,282 @@ +// The plugin store: catalog, install/uninstall jobs, catalog sources, and the scripting runner. +// Like `api/plugins.ts` this is a hand-written client rather than an orval-generated one — the +// OpenAPI document is regenerated on a Linux box (punktfunk-host doesn't build on macOS), so the +// console must not wait on a regen to talk to these endpoints. It rides the same `/api` BFF path as +// every other call, so the bearer token is injected server-side and the browser only ever sends its +// session cookie. +import { + type QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { apiFetch } from "@/api/fetcher"; + +/** + * How much a plugin's provenance is worth, from most to least trustworthy: + * + * - `verified` — from the built-in `unom` source; unom reviewed that exact tarball. + * - `external` — from an operator-added source; pinned and integrity-checked, but curated by + * somebody else. It is NOT reviewed by unom and never wears the verified badge. + * - `unverified` — installed from a raw package spec through the high-friction dialog. No catalog, + * no review, no pinning. Stays badged unverified forever. + * - `cli` — installed with the CLI, so the host holds no provenance record at all. + */ +export type StoreTier = "verified" | "external" | "unverified" | "cli"; + +/** The tier a *catalog* entry can carry — the raw-spec/CLI tiers never appear in a catalog. */ +export type CatalogTier = Extract; + +export interface StoreHostInfo { + version: string; + platform: string; +} + +export interface StoreSource { + name: string; + url: string; + /** The `unom` source ships with the host: it can't be edited or removed. */ + builtin: boolean; + signed: boolean; + /** The last fetch failed or is too old — entries may be out of date. */ + stale: boolean; + /** Unix seconds of the last successful fetch. */ + fetched_at: number; + error?: string; + entry_count: number; + public_key?: string; +} + +export interface StoreEntry { + id: string; + pkg: string; + title: string; + description: string; + icon?: string; + author: string; + homepage?: string; + license?: string; + version: string; + /** Name of the source this entry came from — the attribution an external entry shows. */ + source: string; + tier: CatalogTier; + reviewed_at?: string; + platforms: string[]; + min_host?: string; + compatible: boolean; + incompatible_reason?: string; + installed_version?: string; + update_available: boolean; + blocked?: string; +} + +export interface StoreCatalog { + host: StoreHostInfo; + sources: StoreSource[]; + plugins: StoreEntry[]; + /** An install/uninstall is already running — the host takes one at a time. */ + busy: boolean; +} + +export interface InstalledPlugin { + pkg: string; + version: string; + tier: StoreTier; + source?: string; + entry_id?: string; + /** The plugin's runtime id — how it's keyed in the plugin directory (`api/plugins.ts`). */ + plugin_id?: string; + title?: string; + installed_at?: string; + running: boolean; + /** The version available to update to, if any. */ + update_available?: string; + blocked?: string; +} + +export type JobKind = "install" | "uninstall"; +export type JobState = "running" | "done" | "failed"; + +export interface StoreJob { + id: string; + kind: JobKind; + target: string; + state: JobState; + phase: string; + log: string[]; + error?: string; + started_at: number; + finished_at?: number; +} + +export interface RuntimeStatus { + installed: boolean; + enabled: boolean; + running: boolean; + unit: string; + principal?: string; + detail?: string; +} + +/** What `POST /store/install` and `POST /store/uninstall` answer with (202). */ +export interface JobAccepted { + job: string; +} + +/** Install a curated catalog entry, or — deliberately awkward — a raw package spec. */ +export type InstallBody = + | { source: string; id: string } + | { spec: string; accept_unverified: true }; + +export interface SourceBody { + url: string; + public_key?: string; +} + +const BASE = "/api/v1/store"; + +/** Query keys, in one place so any mutation can invalidate precisely. */ +export const storeKeys = { + all: ["store"] as const, + catalog: ["store", "catalog"] as const, + installed: ["store", "installed"] as const, + sources: ["store", "sources"] as const, + runtime: ["store", "runtime"] as const, + job: (id: string) => ["store", "job", id] as const, +}; + +const json = (method: string, body: unknown): RequestInit => ({ + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), +}); + +/** + * Refresh everything a completed install/uninstall touches — the catalog (installed markers), the + * installed list, the runner state (it restarts), and the plugin directory the nav is built from. + */ +export function invalidateStore(qc: QueryClient): Promise { + return Promise.all([ + qc.invalidateQueries({ queryKey: storeKeys.catalog }), + qc.invalidateQueries({ queryKey: storeKeys.installed }), + qc.invalidateQueries({ queryKey: storeKeys.sources }), + qc.invalidateQueries({ queryKey: storeKeys.runtime }), + qc.invalidateQueries({ queryKey: ["plugins"] }), + ]).then(() => undefined); +} + +/** The merged catalog across every source, plus the sources' own health. */ +export function useStoreCatalog() { + return useQuery({ + queryKey: storeKeys.catalog, + queryFn: () => apiFetch(`${BASE}/catalog`), + }); +} + +/** What's installed right now, with each plugin's permanent provenance tier. */ +export function useInstalledPlugins() { + return useQuery({ + queryKey: storeKeys.installed, + queryFn: () => apiFetch(`${BASE}/installed`), + refetchInterval: 30_000, + }); +} + +export function useStoreSources() { + return useQuery({ + queryKey: storeKeys.sources, + queryFn: () => apiFetch(`${BASE}/sources`), + }); +} + +export function useStoreRuntime() { + return useQuery({ + queryKey: storeKeys.runtime, + queryFn: () => apiFetch(`${BASE}/runtime`), + }); +} + +/** + * A single install/uninstall job, polled once a second while it runs and left alone once it + * settles. Pass `null` to park the query (no job in flight). + */ +export function useStoreJob(id: string | null) { + return useQuery({ + queryKey: storeKeys.job(id ?? ""), + queryFn: () => + apiFetch(`${BASE}/jobs/${encodeURIComponent(id ?? "")}`), + enabled: id !== null, + refetchInterval: (q) => (q.state.data?.state === "running" ? 1_000 : false), + }); +} + +/** Re-fetch every source's index; answers with the freshly merged catalog. */ +export function useRefreshCatalog() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => + apiFetch(`${BASE}/refresh`, { method: "POST" }), + onSuccess: (catalog) => { + qc.setQueryData(storeKeys.catalog, catalog); + qc.setQueryData(storeKeys.sources, catalog.sources); + }, + }); +} + +/** Start an install. Answers 202 with the job to poll; 409 means another op is already running. */ +export function useInstallPlugin() { + return useMutation({ + mutationFn: (body: InstallBody) => + apiFetch(`${BASE}/install`, json("POST", body)), + }); +} + +export function useUninstallPlugin() { + return useMutation({ + mutationFn: (pkg: string) => + apiFetch(`${BASE}/uninstall`, json("POST", { pkg })), + }); +} + +/** Add or update an operator source (the built-in one is not editable). */ +export function useSetSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ name, ...body }: SourceBody & { name: string }) => + apiFetch( + `${BASE}/sources/${encodeURIComponent(name)}`, + json("PUT", body), + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: storeKeys.sources }); + qc.invalidateQueries({ queryKey: storeKeys.catalog }); + }, + }); +} + +export function useDeleteSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => + apiFetch(`${BASE}/sources/${encodeURIComponent(name)}`, { + method: "DELETE", + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: storeKeys.sources }); + qc.invalidateQueries({ queryKey: storeKeys.catalog }); + }, + }); +} + +/** Enable or disable the plugin/script runner service. */ +export function useSetRuntime() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (enabled: boolean) => + apiFetch(`${BASE}/runtime`, json("POST", { enabled })), + onSuccess: (status) => { + qc.setQueryData(storeKeys.runtime, status); + qc.invalidateQueries({ queryKey: ["plugins"] }); + }, + }); +} diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index 5a563b5a..0fa71571 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -6,6 +6,7 @@ import { LibraryBig, MonitorPlay, MoreHorizontal, + Puzzle, ScrollText, Server, Settings, @@ -29,9 +30,15 @@ const NAV = [ { to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() }, { to: "/logs", icon: ScrollText, label: () => m.nav_logs() }, { to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() }, + { to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() }, { to: "/settings", icon: Settings, label: () => m.nav_settings() }, ] as const; +// "/plugins" is the store's index route and "/plugins/" a plugin's own UI, so the store entry +// only counts as active on an exact match — otherwise it would light up alongside the plugin's own +// nav entry below. Same reason "/" is exact. +const EXACT: readonly string[] = ["/", "/plugins"]; + // On phones a flat 8-tab bar is too cramped, so the first four are pinned and the rest live behind a // "More" tab that opens a sheet above the bar. Keep it ≤ 5 slots including "More". const MOBILE_PRIMARY = NAV.slice(0, 4); @@ -74,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) { whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} to={to} - activeOptions={{ exact: to === "/" }} + activeOptions={{ exact: EXACT.includes(to) }} className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground" activeProps={{ className: "bg-primary/15 text-foreground font-medium", @@ -200,6 +207,7 @@ function MobileNav() { key={to} to={to} onClick={() => setMoreOpen(false)} + activeOptions={{ exact: EXACT.includes(to) }} className={cn(tab, "rounded-md")} activeProps={{ className: "text-[var(--brand-light)]" }} > @@ -232,7 +240,7 @@ function MobileNav() { key={to} to={to} onClick={() => setMoreOpen(false)} - activeOptions={{ exact: to === "/" }} + activeOptions={{ exact: EXACT.includes(to) }} className={tab} activeProps={{ className: "text-[var(--brand-light)]" }} > diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx new file mode 100644 index 00000000..e1280695 --- /dev/null +++ b/web/src/components/ui/dialog.tsx @@ -0,0 +1,49 @@ +// The console's Dialog IS @unom/ui's radix dialog — brand surface (material gloss + card radius) +// with the shared close/overlay behaviour. @unom/ui ships the SURFACE only and leaves placement to +// the app, so `DialogContent` here is the surface already wrapped in its portal + overlay and +// centred in the viewport; everything else is re-exported unchanged. +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogContent as DialogSurface, + DialogTitle, + DialogTrigger, +} from "@unom/ui/dialog"; +import type { ComponentProps } from "react"; +import { cn } from "@/lib/utils"; + +const DialogContent = ({ + className, + ...props +}: ComponentProps) => ( + + + + +); +DialogContent.displayName = "DialogContent"; + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/web/src/components/ui/tabs.tsx b/web/src/components/ui/tabs.tsx new file mode 100644 index 00000000..2f5afac3 --- /dev/null +++ b/web/src/components/ui/tabs.tsx @@ -0,0 +1,34 @@ +// The console's Tabs ARE @unom/ui's radix tabs, with one correction: the inactive trigger colour. +// +// @unom/ui styles inactive triggers `text-secondary` and the active one `data-[state=active]: +// text-main`. Those tokens come from @unom/ui's own palette; in the console's theme `text-secondary` +// lands on (near) the tab strip's own background, so every inactive tab renders as an invisible +// gap — the tab bar looks like a single lonely label with dead space beside it. Caught in a browser +// pass, not by types: the markup and the a11y roles are entirely correct. +// +// Same shape as the other `components/ui/*` wrappers: adapt the shared primitive to this app's +// tokens rather than restyling it at every call site. +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger as TabsTriggerBase, +} from "@unom/ui/tabs"; +import type { ComponentProps } from "react"; +import { cn } from "@/lib/utils"; + +const TabsTrigger = ({ + className, + ...props +}: ComponentProps) => ( + +); +TabsTrigger.displayName = "TabsTrigger"; + +export { Tabs, TabsContent, TabsList, TabsTrigger }; diff --git a/web/src/routes/plugins.index.tsx b/web/src/routes/plugins.index.tsx new file mode 100644 index 00000000..3ac06e74 --- /dev/null +++ b/web/src/routes/plugins.index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SectionStore } from "@/sections/Store"; + +// The plugin store. Sits at /plugins as an index route, so it coexists with the per-plugin UI route +// (`plugins.$pluginId.$.tsx`) without either shadowing the other. +export const Route = createFileRoute("/plugins/")({ component: SectionStore }); diff --git a/web/src/sections/Plugins/index.tsx b/web/src/sections/Plugins/index.tsx index 6b5fb00d..5394789b 100644 --- a/web/src/sections/Plugins/index.tsx +++ b/web/src/sections/Plugins/index.tsx @@ -8,9 +8,11 @@ import { getRouteApi, useNavigate } from "@tanstack/react-router"; import { ExternalLink, RefreshCw } from "lucide-react"; import { type FC, useEffect, useMemo, useRef } from "react"; import { pluginIcon, usePlugins } from "@/api/plugins"; +import { useInstalledPlugins } from "@/api/store"; import { Button } from "@/components/ui/button"; import { useLocale } from "@/lib/i18n"; import { m } from "@/paraglide/messages"; +import { TierBadge } from "@/sections/Store/TierBadge"; const route = getRouteApi("/plugins/$pluginId/$"); @@ -26,6 +28,12 @@ export const SectionPlugin: FC = () => { const Icon = pluginIcon(meta?.ui?.icon); const title = meta?.title ?? pluginId; + // Provenance follows the plugin into its own page: an unverified plugin must stay visibly + // unverified WHILE you use it, not only in the store listing. The store keys installations by + // package, so the runtime id is matched through `plugin_id`. + const { data: installed } = useInstalledPlugins(); + const provenance = installed?.find((p) => p.plugin_id === pluginId); + // Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the // offline card (the manual Retry re-probes). const health = useQuery({ @@ -77,6 +85,7 @@ export const SectionPlugin: FC = () => { v{meta.version} )} + {provenance && } + f.toLowerCase().includes(q), + ); +} + +/** + * Container: the catalog. Owns the catalog query plus the local search/source filter; installing is + * escalated to the parent, which owns the tier-appropriate dialog and the resulting job — so this + * subsection never installs anything itself. + */ +export const BrowseTab: FC<{ + onInstall: (entry: StoreEntry) => void; + onInstallSpec: () => void; +}> = ({ onInstall, onInstallSpec }) => { + const catalog = useStoreCatalog(); + const [query, setQuery] = useState(""); + const [source, setSource] = useState(null); + + const entries = catalog.data?.plugins ?? []; + const sources = catalog.data?.sources ?? []; + const shown = useMemo( + () => + entries.filter( + (e) => (source === null || e.source === source) && matches(e, query), + ), + [entries, source, query], + ); + + return ( +
+ + +
+
+ + setQuery(e.target.value)} + /> +
+ {/* One chip per source, so an operator can see a third-party catalog's entries alone. */} + {sources.length > 1 && ( +
+ + {sources.map((s) => ( + + ))} +
+ )} +
+ + + {shown.length === 0 ? ( + + + {entries.length === 0 ? m.store_empty() : m.store_no_match()} + + + ) : ( +
+
+ {shown.map((entry) => ( + onInstall(entry)} + /> + ))} +
+
+ )} +
+ + {/* The ONLY way to the raw-spec install. Deliberately a quiet footer link, not a button on + a card: an unverified install should take a decision, never a stray click. */} +
+ +
+
+ ); +}; + +/** One catalog entry. Blocked entries shout; incompatible ones grey out; neither can be installed. */ +export const StoreCard: FC<{ entry: StoreEntry; onInstall: () => void }> = ({ + entry, + onInstall, +}) => { + const Icon = pluginIcon(entry.icon); + const blocked = entry.blocked !== undefined; + const installed = entry.installed_version !== undefined; + const installable = !blocked && entry.compatible; + + return ( + + +
+ + + +
+

+ {entry.title} +

+

+ {m.store_by_author({ author: entry.author })} · v{entry.version} +

+
+
+ +
+ + {/* Attribution, never verification: an external entry names who curated it. */} + {entry.tier === "external" && } +
+ +

+ {entry.description} +

+ +
+ {entry.platforms.map((p) => ( + + {p} + + ))} +
+ + {blocked && ( +

+ + {m.store_blocked({ reason: entry.blocked ?? "" })} +

+ )} + + {!entry.compatible && !blocked && ( +

+ + {entry.incompatible_reason ?? m.store_incompatible()} +

+ )} + + {/* Footer pinned to the bottom so cards in a row line their actions up. */} +
+ + + ); +}; diff --git a/web/src/sections/Store/InstallDialogs.tsx b/web/src/sections/Store/InstallDialogs.tsx new file mode 100644 index 00000000..7665f736 --- /dev/null +++ b/web/src/sections/Store/InstallDialogs.tsx @@ -0,0 +1,191 @@ +import { Checkbox } from "@unom/ui/form/checkbox"; +import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react"; +import { type FC, useState } from "react"; +import type { StoreEntry } from "@/api/store"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { m } from "@/paraglide/messages"; + +// Friction proportional to trust. The three install paths deliberately do NOT share a confirm +// button: a verified install is one click on a plain dialog, an external one makes you read that +// unom didn't review the code and names who did curate it, and a raw package spec makes you retype +// the spec and tick a box. The last one is never reachable by accident — no card, no button on a +// listing, only the small footer link on Browse. + +/** + * Tiers 1 and 2: install a curated catalog entry. Verified entries get an ordinary confirm; an + * entry from an operator-added source gets the warning treatment and names its source, because + * "pinned and integrity-checked" is not the same claim as "reviewed". + */ +export const InstallDialog: FC<{ + /** The entry being confirmed, or null when the dialog is closed. */ + entry: StoreEntry | null; + onCancel: () => void; + onConfirm: (entry: StoreEntry) => void; + isPending: boolean; +}> = ({ entry, onCancel, onConfirm, isPending }) => { + const external = entry?.tier === "external"; + return ( + !open && onCancel()}> + {entry && ( + + + + {external ? ( + + ) : ( + + )} + {external + ? m.store_install_external_title({ title: entry.title }) + : m.store_install_title({ title: entry.title })} + + + {external + ? m.store_install_external_body({ + version: entry.version, + source: entry.source, + }) + : m.store_install_verified_body({ version: entry.version })} + + + +

+ {entry.pkg}@{entry.version} +

+ + {external && ( +

+ {m.store_install_external_note()} +

+ )} + + + + {/* Red is reserved for the raw-spec install; an external one escalates through + its copy and its amber panel, not by borrowing the danger colour. */} + + +
+ )} +
+ ); +}; + +/** + * Tier 3: install a raw package spec. No catalog, no review, no pinning — so the dialog spells out + * exactly what that means and asks for two independent confirmations (retype the spec, tick the + * box) before it will even enable its button. + */ +export const SpecInstallDialog: FC<{ + open: boolean; + onCancel: () => void; + onConfirm: (spec: string) => void; + isPending: boolean; +}> = ({ open, onCancel, onConfirm, isPending }) => { + const [spec, setSpec] = useState(""); + const [echo, setEcho] = useState(""); + const [accepted, setAccepted] = useState(false); + + // Both confirmations are cleared on every exit, cancel AND confirm alike: reopening this dialog + // must never find it pre-armed with the last spec and a ticked box. + const reset = () => { + setSpec(""); + setEcho(""); + setAccepted(false); + }; + const close = () => { + reset(); + onCancel(); + }; + + const wanted = spec.trim(); + // Both gates must pass: the retyped spec matches exactly, AND the box is ticked. + const ready = wanted.length > 0 && echo.trim() === wanted && accepted; + + return ( + !next && close()}> + + + + + {m.store_spec_title()} + + {m.store_spec_lead()} + + +

+ {m.store_spec_permanent()} +

+ +
+ + setSpec(e.target.value)} + /> +

+ {m.store_spec_field_help()} +

+
+ +
+ + setEcho(e.target.value)} + /> +
+ + + + + + + +
+
+ ); +}; diff --git a/web/src/sections/Store/Installed.tsx b/web/src/sections/Store/Installed.tsx new file mode 100644 index 00000000..a76c8a8b --- /dev/null +++ b/web/src/sections/Store/Installed.tsx @@ -0,0 +1,140 @@ +import { ArrowUpCircle, Ban, Circle, Trash2 } from "lucide-react"; +import type { FC } from "react"; +import { type InstalledPlugin, useInstalledPlugins } from "@/api/store"; +import { QueryState } from "@/components/query-state"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import type { Loadable } from "@/lib/query"; +import { m } from "@/paraglide/messages"; +import { RunnerCardSection } from "./Runner"; +import { SourceChip, TierBadge } from "./TierBadge"; + +/** + * Container: what's installed. Owns the installed query; the runner switch above it owns its own. + * Updating and uninstalling are escalated to the parent, which owns the confirm dialogs and the + * resulting job card. + */ +export const InstalledTab: FC<{ + onUpdate: (plugin: InstalledPlugin) => void; + onUninstall: (plugin: InstalledPlugin) => void; + /** Package whose install/uninstall is in flight, or null — only that row's actions disable. */ + busyPkg: string | null; +}> = ({ onUpdate, onUninstall, busyPkg }) => { + const installed = useInstalledPlugins(); + return ( +
+ + +
+ ); +}; + +/** + * One row per installed plugin. The tier badge is permanent and non-negotiable: a plugin installed + * from a raw spec stays marked unverified here for as long as it's on the host, no matter what it + * later reports about itself. + */ +export const InstalledList: FC<{ + installed: Loadable; + onUpdate: (plugin: InstalledPlugin) => void; + onUninstall: (plugin: InstalledPlugin) => void; + busyPkg: string | null; +}> = ({ installed, onUpdate, onUninstall, busyPkg }) => { + const rows = installed.data ?? []; + return ( + + + + {m.store_installed_title()} + + + + {rows.length === 0 ? ( +

+ {m.store_installed_empty()} +

+ ) : ( + + + {rows.map((p) => ( + + +
{p.title ?? p.pkg}
+
+ {p.pkg} +
+ {p.blocked !== undefined && ( +

+ + {m.store_blocked({ reason: p.blocked })} +

+ )} +
+ +
+ + {p.tier === "external" && p.source && ( + + )} +
+
+ + v{p.version} + + + + + {p.running ? m.store_running() : m.store_stopped()} + + + +
+ {p.update_available !== undefined && ( + + )} + +
+
+
+ ))} +
+
+ )} +
+
+
+ ); +}; diff --git a/web/src/sections/Store/JobProgress.tsx b/web/src/sections/Store/JobProgress.tsx new file mode 100644 index 00000000..1b5ec08f --- /dev/null +++ b/web/src/sections/Store/JobProgress.tsx @@ -0,0 +1,134 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, RotateCw, X, XCircle } from "lucide-react"; +import { type FC, useEffect, useRef } from "react"; +import { invalidateStore, type StoreJob, useStoreJob } from "@/api/store"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { m } from "@/paraglide/messages"; + +// The host reports a job's progress as a free-form phase string; these are the ones it emits, in +// order. Anything unrecognised falls through to the raw phase rather than an empty label, so a new +// host phase degrades to English-but-visible instead of disappearing. +const PHASES: Record string> = { + queued: () => m.store_phase_queued(), + verifying: () => m.store_phase_verifying(), + installing: () => m.store_phase_installing(), + removing: () => m.store_phase_removing(), + checking: () => m.store_phase_checking(), + "rolling back": () => m.store_phase_rolling_back(), + recording: () => m.store_phase_recording(), + "restarting runner": () => m.store_phase_restarting(), + done: () => m.store_phase_done(), +}; + +const phaseLabel = (phase: string): string => PHASES[phase]?.() ?? phase; + +/** Keep the tail — an install log can run long and only the end is ever interesting. */ +const LOG_TAIL = 200; + +/** + * Container: the in-flight install/uninstall. Polls the job once a second while it runs (the query + * stops polling itself once the job settles), and refreshes everything the job touched — catalog, + * installed list, runner state, plugin directory — the moment it finishes. + */ +export const JobProgressSection: FC<{ + jobId: string; + onDismiss: () => void; +}> = ({ jobId, onDismiss }) => { + const qc = useQueryClient(); + const job = useStoreJob(jobId); + const settled = job.data?.state === "done" || job.data?.state === "failed"; + // Refresh once per job, not on every re-render while the finished card sits there. + const refreshed = useRef(null); + + useEffect(() => { + if (!settled || refreshed.current === jobId) return; + refreshed.current = jobId; + invalidateStore(qc); + }, [settled, jobId, qc]); + + if (!job.data) return null; + return ; +}; + +/** The progress card: phase (or outcome), a collapsible log tail, and the failure reason if any. */ +export const JobProgressCard: FC<{ + job: StoreJob; + onDismiss: () => void; +}> = ({ job, onDismiss }) => { + const running = job.state === "running"; + const failed = job.state === "failed"; + const log = job.log.slice(-LOG_TAIL); + + return ( + + +
+ {running ? ( + + ) : failed ? ( + + ) : ( + + )} +
+

+ {job.kind === "uninstall" + ? m.store_job_uninstall({ target: job.target }) + : m.store_job_install({ target: job.target })} +

+

+ {running + ? phaseLabel(job.phase) + : failed + ? m.store_job_failed() + : job.kind === "uninstall" + ? m.store_job_done_uninstall() + : m.store_job_done_install()} +

+
+ {!running && ( + + )} +
+ + {failed && job.error && ( +

+ {job.error} +

+ )} + + {/* The runner is bounced at the end of every successful job, so the plugin's nav entry + (and its UI) takes a moment longer to appear than the job's "done". Say so. */} + {job.state === "done" && ( +

+ + {m.store_job_restarting()} +

+ )} + + {log.length > 0 && ( +
+ + {m.store_job_log()} + +
+							{log.join("\n")}
+						
+
+ )} +
+
+ ); +}; diff --git a/web/src/sections/Store/Runner.tsx b/web/src/sections/Store/Runner.tsx new file mode 100644 index 00000000..dcf41afc --- /dev/null +++ b/web/src/sections/Store/Runner.tsx @@ -0,0 +1,125 @@ +import { toast } from "@unom/ui/toast"; +import { Play, Power, PowerOff } from "lucide-react"; +import type { FC } from "react"; +import { + type RuntimeStatus, + useSetRuntime, + useStoreRuntime, +} 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 { m } from "@/paraglide/messages"; + +// The plugin/script runner is the service every plugin actually executes inside. Installing a plugin +// while it's switched off silently gets you nothing running, so Browse carries a banner and the +// Installed tab carries the full switch. + +/** Small helper both surfaces share: fire the toggle, surface a failure as a toast. */ +function useRunnerToggle() { + const set = useSetRuntime(); + const toggle = (enabled: boolean) => { + set.mutate(enabled, { + onError: () => toast.error(m.store_runner_failed()), + }); + }; + return { toggle, isPending: set.isPending }; +} + +/** + * Browse-tab banner: the runner is installed but off, so nothing an operator installs here would + * start. Renders nothing in every other state (including "not installed" — the Installed tab's card + * explains that case properly). + */ +export const RunnerBanner: FC = () => { + const runtime = useStoreRuntime(); + const { toggle, isPending } = useRunnerToggle(); + const s = runtime.data; + if (!s?.installed || s.enabled) return null; + + return ( +
+ +

{m.store_runner_banner()}

+ +
+ ); +}; + +/** + * Container: the runner switch at the top of the Installed tab. Like the library's source toggles + * this is a secondary control — it stays out of the way until the query resolves rather than + * stacking a second error banner on top of the installed list's own. + */ +export const RunnerCardSection: FC = () => { + const runtime = useStoreRuntime(); + const { toggle, isPending } = useRunnerToggle(); + if (!runtime.data) return null; + return ( + + ); +}; + +/** The runner card: what the service is, whether it's up, and the one switch that changes it. */ +export const RunnerCard: FC<{ + status: RuntimeStatus; + busy: boolean; + onToggle: (enabled: boolean) => void; +}> = ({ status, busy, onToggle }) => ( + + + + {m.store_runner_title()} + {!status.installed ? ( + {m.store_runner_state_missing()} + ) : status.running ? ( + {m.store_runner_state_running()} + ) : status.enabled ? ( + {m.store_runner_state_stopped()} + ) : ( + {m.store_runner_state_disabled()} + )} + + + +

+ {status.installed + ? m.store_runner_help() + : m.store_runner_not_installed()} +

+
+
+
{m.store_runner_unit()}
+
{status.unit}
+
+ {status.principal && ( +
+
{m.store_runner_principal()}
+
{status.principal}
+
+ )} +
+ {status.detail && ( +

{status.detail}

+ )} + {status.installed && ( + + )} +
+
+); diff --git a/web/src/sections/Store/Sources.tsx b/web/src/sections/Store/Sources.tsx new file mode 100644 index 00000000..253a529d --- /dev/null +++ b/web/src/sections/Store/Sources.tsx @@ -0,0 +1,347 @@ +import { toast } from "@unom/ui/toast"; +import { + AlertTriangle, + Lock, + RefreshCw, + ShieldCheck, + ShieldOff, + Trash2, +} from "lucide-react"; +import { type FC, type FormEvent, useState } from "react"; +import { ApiError } from "@/api/fetcher"; +import { + type SourceBody, + type StoreSource, + useDeleteSource, + useRefreshCatalog, + useSetSource, + useStoreSources, +} from "@/api/store"; +import { QueryState } from "@/components/query-state"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { m } from "@/paraglide/messages"; + +/** A source the operator has filled in but not yet agreed to trust. */ +type SourceDraft = SourceBody & { name: string }; + +/** Unix seconds → a locale date-time, or "never" for a source that has never fetched. */ +const fmtFetched = (secs: number): string => + secs > 0 ? new Date(secs * 1000).toLocaleString() : m.store_source_never(); + +/** + * Container: the catalog sources. Owns the source listing, the refresh-all action, and add/remove. + * Adding is a two-step: the form collects the source, and a one-time trust dialog states plainly + * what trusting a third-party catalog means before anything is written to the host. + */ +export const SourcesTab: FC = () => { + const sources = useStoreSources(); + const refresh = useRefreshCatalog(); + const save = useSetSource(); + const remove = useDeleteSource(); + // The draft waiting on the trust dialog, and a key that re-mounts (and so clears) the form. + const [draft, setDraft] = useState(null); + const [formKey, setFormKey] = useState(0); + + const onRefresh = () => + refresh.mutate(undefined, { + onError: () => toast.error(m.store_refresh_failed()), + }); + + const onConfirmAdd = async () => { + if (!draft) return; + try { + await save.mutateAsync(draft); + setDraft(null); + setFormKey((k) => k + 1); + } catch { + toast.error(m.store_add_source_failed()); + } + }; + + const onRemove = async (source: StoreSource) => { + if (!confirm(m.store_source_remove_confirm({ name: source.name }))) return; + try { + await remove.mutateAsync(source.name); + } catch (e) { + // 403 is the host refusing to drop its built-in catalog — say exactly that. + toast.error( + e instanceof ApiError && e.status === 403 + ? m.store_source_builtin_locked() + : m.store_source_remove_failed(), + ); + } + }; + + return ( +
+ + + + + setDraft(null)} + onConfirm={onConfirmAdd} + /> +
+ ); +}; + +/** The source table: health per source, with the built-in one locked. */ +export const SourceList: FC<{ + sources: { + data?: StoreSource[]; + isLoading: boolean; + error: unknown; + refetch?: () => void; + }; + /** Name of the source whose delete is in flight, or null. */ + busyName: string | null; + isRefreshing: boolean; + onRefresh: () => void; + onRemove: (source: StoreSource) => void; +}> = ({ sources, busyName, isRefreshing, onRefresh, onRemove }) => { + const rows = sources.data ?? []; + return ( + + + {m.store_sources_title()} + + + +

+ {m.store_sources_help()} +

+ + +
+ {rows.map((s) => ( +
+
+
+ {s.name} + {s.builtin && ( + + + {m.store_source_builtin()} + + )} + {s.signed ? ( + + + {m.store_source_signed()} + + ) : ( + + + {m.store_source_unsigned()} + + )} + {s.stale && ( + + + {m.store_source_stale()} + + )} +
+

+ {s.url} +

+

+ {m.store_source_entries({ count: s.entry_count })} ·{" "} + {m.store_source_fetched({ when: fmtFetched(s.fetched_at) })} +

+ {s.error && ( +

{s.error}

+ )} +
+ {/* The built-in catalog gets no delete button at all — not a disabled one. */} + {!s.builtin && ( + + )} +
+ ))} +
+
+
+
+ ); +}; + +/** The add-source form. Reports a draft; the parent takes it through the trust dialog. */ +export const AddSourceForm: FC<{ + onSubmit: (draft: SourceDraft) => void; + isSaving: boolean; +}> = ({ onSubmit, isSaving }) => { + const [name, setName] = useState(""); + const [url, setUrl] = useState(""); + const [publicKey, setPublicKey] = useState(""); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const key = publicKey.trim(); + if (!name.trim() || !url.trim()) return; + onSubmit({ + name: name.trim(), + url: url.trim(), + public_key: key ? key : undefined, + }); + }; + + return ( + + + {m.store_add_source_title()} + + +
+
+ + setName(e.target.value)} + /> +
+
+ + setUrl(e.target.value)} + /> +
+
+ + setPublicKey(e.target.value)} + /> +

+ {m.store_field_source_key_help()} +

+
+ +
+
+
+ ); +}; + +/** The one-time trust warning shown before a third-party catalog is written to the host. */ +export const TrustSourceDialog: FC<{ + draft: SourceDraft | null; + isSaving: boolean; + onCancel: () => void; + onConfirm: () => void; +}> = ({ draft, isSaving, onCancel, onConfirm }) => ( + !open && onCancel()}> + {draft && ( + + + + + {m.store_source_trust_title()} + + + {m.store_source_trust_body({ name: draft.name })} + + + +

+ {draft.url} +

+ + {!draft.public_key && ( +

+ {m.store_source_trust_unsigned()} +

+ )} + + + + + +
+ )} +
+); diff --git a/web/src/sections/Store/TierBadge.tsx b/web/src/sections/Store/TierBadge.tsx new file mode 100644 index 00000000..5896ef2f --- /dev/null +++ b/web/src/sections/Store/TierBadge.tsx @@ -0,0 +1,89 @@ +import { + BadgeCheck, + ShieldAlert, + ShieldQuestion, + Terminal, +} from "lucide-react"; +import type { FC } from "react"; +import type { StoreTier } from "@/api/store"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { m } from "@/paraglide/messages"; + +// The trust model, rendered. These badges are the only place the console makes a claim about where +// a plugin's code came from, so they are deliberately literal: ONLY the built-in `unom` source earns +// the check mark, everything else says out loud that nobody at unom looked at the code. An entry +// from an operator-added source additionally carries a naming that source — attribution +// instead of a verification it hasn't got. + +/** The short, permanent provenance badge for a tier. */ +export const TierBadge: FC<{ tier: StoreTier; className?: string }> = ({ + tier, + className, +}) => { + switch (tier) { + case "verified": + return ( + + + {m.store_tier_verified()} + + ); + case "external": + return ( + + + {m.store_tier_external()} + + ); + case "unverified": + return ( + + + {m.store_tier_unverified()} + + ); + default: + return ( + + + {m.store_tier_cli()} + + ); + } +}; + +/** "from " — who curated this entry. Shown for anything not from the built-in source. */ +export const SourceChip: FC<{ source: string; className?: string }> = ({ + source, + className, +}) => ( + + {m.store_from_source()} + {source} + +); diff --git a/web/src/sections/Store/index.tsx b/web/src/sections/Store/index.tsx new file mode 100644 index 00000000..b3f2985c --- /dev/null +++ b/web/src/sections/Store/index.tsx @@ -0,0 +1,149 @@ +import Section from "@unom/ui/section"; +import { toast } from "@unom/ui/toast"; +import { type FC, useState } from "react"; +import { ApiError } from "@/api/fetcher"; +import { + type InstallBody, + type InstalledPlugin, + type StoreEntry, + useInstallPlugin, + useStoreCatalog, + useUninstallPlugin, +} from "@/api/store"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useLocale } from "@/lib/i18n"; +import { m } from "@/paraglide/messages"; +import { BrowseTab } from "./Browse"; +import { InstallDialog, SpecInstallDialog } from "./InstallDialogs"; +import { InstalledTab } from "./Installed"; +import { JobProgressSection } from "./JobProgress"; +import { SourcesTab } from "./Sources"; + +type StoreTab = "browse" | "installed" | "sources"; + +/** + * The plugin store: browse a catalog, manage what's installed, and choose which catalogs this host + * trusts. Each tab owns its own queries; this container owns only what genuinely spans them — the + * install/uninstall mutations, the confirm dialogs their trust tier dictates, and the job the host + * hands back (which must stay visible whichever tab you switch to while it runs). + */ +export const SectionStore: FC = () => { + useLocale(); + const [tab, setTab] = useState("browse"); + // The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state. + const [target, setTarget] = useState(null); + const [specOpen, setSpecOpen] = useState(false); + // The job the host is running for us, if any. Cleared by the operator, not by completion — a + // finished job's log is the only record of what happened. + const [jobId, setJobId] = useState(null); + + const catalog = useStoreCatalog(); + const install = useInstallPlugin(); + const uninstall = useUninstallPlugin(); + + /** Turn a failed 202-request into a message: 409 means the host is busy, not that we're broken. */ + const failed = (e: unknown, fallback: string) => + toast.error( + e instanceof ApiError && e.status === 409 ? m.store_busy() : fallback, + ); + + const start = async (body: InstallBody) => { + try { + const { job } = await install.mutateAsync(body); + setJobId(job); + } catch (e) { + failed(e, m.store_install_failed()); + } + }; + + const onConfirmEntry = async (entry: StoreEntry) => { + setTarget(null); + await start({ source: entry.source, id: entry.id }); + }; + + const onConfirmSpec = async (spec: string) => { + setSpecOpen(false); + await start({ spec, accept_unverified: true }); + }; + + // An update from the Installed tab installs the CATALOG version — so it goes through the very + // same tier-appropriate dialog a fresh install would, warning included. + const onUpdate = (plugin: InstalledPlugin) => { + const entry = catalog.data?.plugins.find((e) => e.pkg === plugin.pkg); + if (!entry) { + toast.error(m.store_update_no_entry()); + return; + } + setTarget(entry); + }; + + const onUninstall = async (plugin: InstalledPlugin) => { + if ( + !confirm(m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg })) + ) + return; + try { + const { job } = await uninstall.mutateAsync(plugin.pkg); + setJobId(job); + } catch (e) { + failed(e, m.store_uninstall_failed()); + } + }; + + return ( +
+
+
+

{m.store_title()}

+

{m.store_subtitle()}

+
+ + {jobId && ( + setJobId(null)} /> + )} + + setTab(v as StoreTab)}> + + {m.store_tab_browse()} + + {m.store_tab_installed()} + + {m.store_tab_sources()} + + + + setSpecOpen(true)} + /> + + + + + + + + + + setTarget(null)} + onConfirm={onConfirmEntry} + /> + setSpecOpen(false)} + onConfirm={onConfirmSpec} + /> +
+
+ ); +};