diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 00000000..47c6b647 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,135 @@ +# @punktfunk/host + +TypeScript SDK for the [punktfunk](https://git.unom.io/unom/punktfunk) streaming host: a typed +management-API client plus the host's lifecycle **event stream** (client connect/disconnect, +stream start/stop, pairing, displays, library) — built on [Effect](https://effect.website). + +Two surfaces, one core: + +- **`@punktfunk/host`** — the Promise facade, the front door. `connect()`, `await`, `.on()`. + You never need to know Effect exists. +- **`@punktfunk/host/effect`** — the Effect-native surface for plugins and composed programs: + the `PunktfunkHost` service + layer, `Stream`-based events, typed errors + (`AuthError | ApiError | TransportError | VersionSkew`), and every wire shape as an + `effect/Schema` (REST shapes generated from the host's OpenAPI spec; event shapes mirroring + the host's snapshot-tested wire format). + +## Quickstart + +```ts +import { connect } from "@punktfunk/host"; + +const pf = await connect(); // zero config on the host box + +pf.events.on("stream.started", (e) => { + console.log(`${e.stream.client} started ${e.stream.mode}${e.stream.hdr ? " HDR" : ""}`); +}); +pf.events.on("pairing.pending", async (e) => { + // notify your phone, then decide through the API: + // await pf.request("POST", `/native/pending/${id}/approve`); +}); +``` + +The same, Effect-native: + +```ts +import { Effect, Stream } from "effect"; +import { events, PunktfunkHostLive } from "@punktfunk/host/effect"; + +const program = events().pipe( + Stream.filter((e) => e.kind === "stream.started"), + Stream.runForEach((e) => Effect.log(`stream: ${e.stream.mode}`)), +); +Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive()))); +``` + +## Connection resolution + +`connect()` / `PunktfunkHostLive()` resolve, in order: + +| What | Source | +|---|---| +| URL | `{ url }` → `PUNKTFUNK_MGMT_URL` → `https://127.0.0.1:47990` | +| Token | `{ token }` → `PUNKTFUNK_MGMT_TOKEN` → `/mgmt-token` | +| TLS pin | `{ ca }` → `PUNKTFUNK_MGMT_CA` (path) → `/cert.pem` | + +`` is `~/.config/punktfunk` (Linux/macOS) or `%ProgramData%\punktfunk` (Windows) — +so a script running on the host box needs **zero configuration**. The TLS pin trusts exactly +the host's self-signed identity cert (chain-verified; the hostname check is waived — the cert +is deliberately CN-only, native clients pin its fingerprint). Bun and Node are first-class; +other runtimes fall back to system trust (point your runtime's CA option at `cert.pem`). + +The bearer token is the host's **admin** credential and is honored from loopback only — run +scripts on the host box (or through an SSH tunnel). + +## Events + +- Reconnects automatically (exponential backoff + jitter, capped) and resumes with + `Last-Event-ID` — the host replays what you missed from its ring. +- Default is **live tail only** (a fresh notify script must not re-fire on history); + pass `{ since: 0 }` on the Effect surface to replay the host's full ring, or `since: N` + to resume after a seq you persisted. +- `on()` patterns: exact kinds (`"stream.started"`, typed callback), `"domain.*"` prefixes, + `"*"`, plus `"dropped"` (your cursor fell off the ring — resync via REST) and `"unknown"` + (an event kind newer than this SDK — the additive-only wire at work). +- Effect surface: `events()` is a `Stream`; `eventsRaw()` carries + every SSE frame verbatim. + +## Plugins (`punktfunk-plugin-*`) + +```ts +import { definePlugin } from "@punktfunk/host"; +import { Effect } from "effect"; +import { PunktfunkHost } from "@punktfunk/host/effect"; + +export default definePlugin({ + name: "romm-library", + main: Effect.gen(function* () { + const pf = yield* PunktfunkHost; + // subscribe, sync, reconcile — scoped finalizers run on shutdown/interruption + }), + // …or the simple shape: main: async (pf) => { … } +}); +``` + +In v1 a plugin is a script you run (see below); the managed runner package is a later step. + +## Running as a service + +systemd user unit (`~/.config/systemd/user/punktfunk-myscript.service`): + +```ini +[Unit] +Description=punktfunk automation: myscript +After=punktfunk-host.service + +[Service] +ExecStart=/usr/bin/bun /home/me/punktfunk-scripts/myscript.ts +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +``` + +Windows Task Scheduler: a task triggered *At log on* running +`bun C:\Users\me\punktfunk-scripts\myscript.ts` (the SDK reads +`%ProgramData%\punktfunk\mgmt-token` — run the task as an account that can). + +## Compatibility + +- SDK **majors** track the management-API major; an event `schema` bump or an `effect` major + is an SDK major too. +- The wire is **additive-only** within a major: an older SDK keeps working against a newer + host (unknown response keys are ignored; unknown event kinds ride the `"unknown"` channel). +- A 2xx response that doesn't match its schema surfaces as `VersionSkew` on the Effect + surface — a typed nudge to update, not an `undefined` three frames later. + +## Development + +```sh +bun install +bun run gen # regenerate src/gen/schemas.ts from ../api/openapi.json +bun run typecheck +bun test +``` diff --git a/sdk/bun.lock b/sdk/bun.lock new file mode 100644 index 00000000..29aaaa0b --- /dev/null +++ b/sdk/bun.lock @@ -0,0 +1,303 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@punktfunk/host", + "dependencies": { + "effect": "^3.19.0", + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "orval": "^8.20.0", + "typescript": "^5.9.3", + }, + "optionalDependencies": { + "undici": "^7.0.0", + }, + }, + }, + "packages": { + "@commander-js/extra-typings": ["@commander-js/extra-typings@15.0.0", "", { "peerDependencies": { "commander": "~15.0.0" } }, "sha512-yeJlba62xqmkgELUsn7356MEnzLLu/fw2x4lofFqGnXh6YysRdEs2BaLeLtg1+KU0AXvMeqQvTTp+3hBEBK+EA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="], + + "@orval/angular": ["@orval/angular@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-lSvaNj+VHGIWBQOG104HfPNrk2xGjpArwy67u6ZBPzHo/OtDE5Tm1t+ARYseCOElFr4z3i3A62qjFLFy6zj00Q=="], + + "@orval/axios": ["@orval/axios@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-fFu0UgTbpI9a1ayM7qCs55MMy6MlVgOpE3BBXGukCTBSwNwFLN47WdzQev/x0mAcs58pNKYT0KL4R9MXGnmgfg=="], + + "@orval/core": ["@orval/core@8.22.0", "", { "dependencies": { "@scalar/openapi-types": "0.8.0", "acorn": "^8.15.0", "compare-versions": "^6.1.1", "debug": "^4.4.3", "esbuild": "^0.28.0", "esutils": "2.0.3", "fs-extra": "^11.3.2", "jiti": "^2.6.1", "jsesc": "^3.0.0", "remeda": "^2.33.6", "tinyglobby": "^0.2.16", "typedoc": "^0.28.19" }, "peerDependencies": { "@faker-js/faker": ">=10" }, "optionalPeers": ["@faker-js/faker"] }, "sha512-uKYi7+Smg6oQ2MxE0AS2FNI7bwssoxGLh391Uk0FU+DcTcSv9q2GmvoM8uwd827Hok29kD7AegHE8Mhmsa5uWg=="], + + "@orval/effect": ["@orval/effect@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "remeda": "^2.33.6" } }, "sha512-DACiw2+0ZsPJPKQbYlb9qFFFppaCBaT/HgIq2S1asH9ZCOOTZKm9VrRXpeCWINHC2w1BpdSATpytv+61UT5Y/A=="], + + "@orval/fetch": ["@orval/fetch@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-G0r6hOdZG963H/8S4Vk1kWfU/hkQC/cwpDZL+mzcXgzrdG9NDloshTqmge/jP5lPsFAwfcRUmmts10OmuIE4BQ=="], + + "@orval/hono": ["@orval/hono@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/zod": "8.22.0", "fs-extra": "^11.3.2" }, "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-GMCpGZqCuGYSu10KTt2q3WYzCqEcTGxr18z3HgHv9dX22sv/V76dlLBh3SE72xp8Z7/jmoq3GKBdgU9k98ju0w=="], + + "@orval/mcp": ["@orval/mcp@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0", "@orval/zod": "8.22.0" } }, "sha512-ySa4EenAF8YMCpbmddJGO3lItTPC8Uf/iT+P2ezaowVgxOCPX/fjG7dd0fnVmrr0vaphi53yWx5o5DlT17FzLg=="], + + "@orval/mock": ["@orval/mock@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "remeda": "^2.33.6" } }, "sha512-ZCEFpdi4z+YOCg0Tsgz6DO/M1TSfYxE5urDWJgYNUKeD8XDEHFb2IgTiiaAwJunqWnRQuiMfGp9G81+u10Kx6w=="], + + "@orval/query": ["@orval/query@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0", "remeda": "^2.33.6" } }, "sha512-IH9049z860CLUeGEezGv+yOvUvcAyDnd9doDe1Ryi0WxsDul2Vh3LjCRUEQf4JZiyZezadWYYGzs0lwnCn/afA=="], + + "@orval/solid-start": ["@orval/solid-start@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-Q1ZCWOrA6/VJnyj62XpPxXti9NpAA4lDZVqPL2uQ5gbxv+T+azEaazKqYIpSBJXbl1aEezujdcyg/DVUakWKEw=="], + + "@orval/swr": ["@orval/swr@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0" } }, "sha512-OSeWX3Af8ESapKIo3XpbOaTMaG8oeEtuucFyOUauqg6NyC6/9Ikcf2csBdF0AuIKA0QTcjcXxra/ccj1f7KnRg=="], + + "@orval/zod": ["@orval/zod@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "jsesc": "^3.0.0", "remeda": "^2.33.6" } }, "sha512-briHtUTz79fvCeIFfGW3IbmUIF9DOotUoWFa/sKUjRUG8PMGDWSjQzO8QdLzLoETVGo9g4TfGwp5Xjcs81GZTA=="], + + "@scalar/helpers": ["@scalar/helpers@0.9.1", "", {}, "sha512-UKSLIPfN++f+zzbsZ+F6I0lNrR4yX4PtokjHgGwGiHapxT5VJqAF4zFTIP2KCd27XG7KnAIA7qq62O4xRBxc6w=="], + + "@scalar/json-magic": ["@scalar/json-magic@0.12.18", "", { "dependencies": { "@scalar/helpers": "0.9.1", "pathe": "^2.0.3", "yaml": "^2.8.3" } }, "sha512-3oZr+jUUiwD3C+x2CROBtlyIUnwt9ScRhFfczuOTPZlD7Pnb5cyTTfkiHbxxU4qd66ij0Z3JA4X/Nj0OADyEiA=="], + + "@scalar/openapi-parser": ["@scalar/openapi-parser@0.28.9", "", { "dependencies": { "@scalar/helpers": "0.9.1", "@scalar/json-magic": "0.12.18", "@scalar/openapi-types": "0.9.2", "@scalar/openapi-upgrader": "0.2.10", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.8.3" } }, "sha512-AYmqO7dR6zc1TSVhW5BpwY0nUMfG5JuF85/8YXn5ZXcCvjgoJu8dbxX52vIGK9rRwSn07HXCdKk7KPIF4xfOTA=="], + + "@scalar/openapi-types": ["@scalar/openapi-types@0.8.0", "", {}, "sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g=="], + + "@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.2.10", "", { "dependencies": { "@scalar/openapi-types": "0.9.2" } }, "sha512-FRYwCl4IXRi7yAF5/3Jho0jc2akTZfr/vRTYMjhm+96Fq0LaEuHJAvPcSkkU+j3IWGie6LaCQMZTngcSjoIzgA=="], + + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + + "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "effect": ["effect@3.22.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-jhYFe0zTlIRqYFrKTS+6luhmS/Tm0f+JLo0K9KUxvtFab1SUGEszQi2ehOP6QzAZvy831lDmTwwzvVDZSPNz3g=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "find-up": ["find-up@8.0.0", "", { "dependencies": { "locate-path": "^8.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww=="], + + "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + + "leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="], + + "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], + + "locate-path": ["locate-path@8.0.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg=="], + + "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], + + "markdown-it": ["markdown-it@14.3.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw=="], + + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "orval": ["orval@8.22.0", "", { "dependencies": { "@commander-js/extra-typings": "^15.0.0", "@orval/angular": "8.22.0", "@orval/axios": "8.22.0", "@orval/core": "8.22.0", "@orval/effect": "8.22.0", "@orval/fetch": "8.22.0", "@orval/hono": "8.22.0", "@orval/mcp": "8.22.0", "@orval/mock": "8.22.0", "@orval/query": "8.22.0", "@orval/solid-start": "8.22.0", "@orval/swr": "8.22.0", "@orval/zod": "8.22.0", "@scalar/json-magic": "^0.12.16", "@scalar/openapi-parser": "^0.28.7", "@scalar/openapi-types": "0.8.0", "chokidar": "^5.0.0", "commander": "^15.0.0", "execa": "^9.6.1", "find-up": "8.0.0", "fs-extra": "^11.3.2", "get-tsconfig": "^4.14.0", "jiti": "^2.6.1", "js-yaml": "4.2.0", "remeda": "^2.33.6", "string-argv": "^0.3.2", "typedoc": "^0.28.19", "typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-markdown": "^4.10.0" }, "peerDependencies": { "prettier": ">=3.0.0" }, "optionalPeers": ["prettier"], "bin": { "orval": "dist/bin/orval.mjs" } }, "sha512-N8UmB4DOhW+Z2SzVq4oS/pN3DsRPq+msrRU8NyRldP1i0yiqT6qo8mUxHPk2umqYjyY0FlR2mvPbi/Jf5CiP7A=="], + + "p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], + + "p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "remeda": ["remeda@2.39.0", "", {}, "sha512-3Ki8dU1o3OVu4dwIQ2Pj+yiuP7OnEbmWAGmJ3yDRqopily5jsj8NWzPvbS89H85d6UdONKEcUnrfuHY6jN9vyw=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "typedoc": ["typedoc@0.28.20", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.3.0", "minimatch": "^10.2.5", "yaml": "^2.9.0" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg=="], + + "typedoc-plugin-coverage": ["typedoc-plugin-coverage@4.0.3", "", { "peerDependencies": { "typedoc": "0.28.x" } }, "sha512-baim3wyMkqpX7rBzL/6iZ7wzKJuSr9ffP16RHOsdTUNoHUZeXLIZHSUBtUhXmNHaUNRgfqdmKLBwyggbJjGdeQ=="], + + "typedoc-plugin-markdown": ["typedoc-plugin-markdown@4.12.0", "", { "peerDependencies": { "typedoc": "0.28.x" } }, "sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "@scalar/openapi-parser/@scalar/openapi-types": ["@scalar/openapi-types@0.9.2", "", {}, "sha512-J0SZkNPgCrsFN0Rv8sRqZpDOQcRV44TCT82LsQcyWmcglTr7qtXukDmMlIyXs7JDd+DoPLbT2ea65bEHRs2W2g=="], + + "@scalar/openapi-upgrader/@scalar/openapi-types": ["@scalar/openapi-types@0.9.2", "", {}, "sha512-J0SZkNPgCrsFN0Rv8sRqZpDOQcRV44TCT82LsQcyWmcglTr7qtXukDmMlIyXs7JDd+DoPLbT2ea65bEHRs2W2g=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + } +} diff --git a/sdk/examples/couch-preset.effect.ts b/sdk/examples/couch-preset.effect.ts new file mode 100644 index 00000000..a1ae5738 --- /dev/null +++ b/sdk/examples/couch-preset.effect.ts @@ -0,0 +1,20 @@ +// The RFC §7 quickstart, Effect-native: when the living-room TV starts a stream, apply a +// display preset — a composed, typed, interruptible program. +import { Effect, Stream } from "effect"; +import { events, PunktfunkHost, PunktfunkHostLive } from "../src/effect.js"; + +const program = Effect.gen(function* () { + const pf = yield* PunktfunkHost; + yield* events().pipe( + Stream.filter( + (e) => e.kind === "stream.started" && e.stream.client === "Living Room TV", + ), + Stream.runForEach(() => + pf + .request("PUT", "/display/settings", { mode: "preset", preset: "couch" }) + .pipe(Effect.catchAll((e) => Effect.logWarning(`preset failed: ${e}`))), + ), + ); +}); + +Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive()))).catch(console.error); diff --git a/sdk/examples/notify-pairing.ts b/sdk/examples/notify-pairing.ts new file mode 100644 index 00000000..8631909a --- /dev/null +++ b/sdk/examples/notify-pairing.ts @@ -0,0 +1,15 @@ +// The flagship hook-with-a-decision pattern (Promise facade): watch pairing requests, notify, +// and approve/deny THROUGH the API — asynchronously, the punktfunk way (hooks never veto). +import { connect } from "../src/index.js"; + +const pf = await connect(); +console.log("watching for pairing requests…"); + +pf.events.on("pairing.pending", async (e) => { + console.log(`pairing request: ${e.device.name} (${e.device.fingerprint})`); + // Wire your real notifier here (ntfy, Pushover, Home Assistant, …), then decide: + // const pending = await pf.request("GET", "/native/pending") as { id: number }[]; + // await pf.request("POST", `/native/pending/${pending[0].id}/approve`); +}); +pf.events.on("pairing.completed", (e) => console.log(`paired: ${e.device.name}`)); +pf.events.on("pairing.denied", (e) => console.log(`denied: ${e.device.name}`)); diff --git a/sdk/examples/tail-events.ts b/sdk/examples/tail-events.ts new file mode 100644 index 00000000..d106f38d --- /dev/null +++ b/sdk/examples/tail-events.ts @@ -0,0 +1,13 @@ +// Tail the host's lifecycle events — the SDK "hello world" (Promise facade). +// +// bun examples/tail-events.ts (on the host box: zero config) +// PUNKTFUNK_MGMT_URL=… PUNKTFUNK_MGMT_TOKEN=… bun examples/tail-events.ts +import { connect } from "../src/index.js"; + +const pf = await connect(); +const host = (await pf.request("GET", "/host")) as { hostname?: string }; +console.log(`connected to ${host.hostname ?? "host"} — tailing events (^C to stop)`); + +pf.events.on("*", (e) => console.log(`[${e.seq}] ${e.kind}`, JSON.stringify(e))); +pf.events.on("unknown", (e) => console.log("[unknown kind]", JSON.stringify(e))); +pf.events.on("dropped", () => console.log("[cursor fell off the ring — resync]")); diff --git a/sdk/orval.config.ts b/sdk/orval.config.ts new file mode 100644 index 00000000..ece7faec --- /dev/null +++ b/sdk/orval.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "orval"; + +// Generates the SDK's Effect Schemas from the host's checked-in OpenAPI document — the same +// single source of truth the web console's react-query client is generated from (RFC §7: the +// chain is Rust structs (utoipa) → OpenAPI → Effect Schemas → inferred TS types). Regenerate +// after any management-API change: `bun run gen` (CI drift-tests the output). +export default defineConfig({ + punktfunk: { + input: { + target: "../api/openapi.json", + }, + output: { + mode: "single", + target: "./src/gen/schemas.ts", + client: "effect", + clean: true, + }, + }, +}); diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 00000000..6809bc99 --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,27 @@ +{ + "name": "@punktfunk/host", + "version": "0.1.0", + "description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts", + "./effect": "./src/effect.ts" + }, + "scripts": { + "gen": "orval --config ./orval.config.ts", + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "effect": "^3.19.0" + }, + "devDependencies": { + "orval": "^8.20.0", + "typescript": "^5.9.3", + "@types/bun": "^1.3.0" + }, + "optionalDependencies": { + "undici": "^7.0.0" + } +} diff --git a/sdk/src/client.ts b/sdk/src/client.ts new file mode 100644 index 00000000..0f4aa732 --- /dev/null +++ b/sdk/src/client.ts @@ -0,0 +1,168 @@ +// The Effect-native surface (RFC §7): the `PunktfunkHost` service — a typed management-API +// client plus the lifecycle-event `Stream` — provided by [`PunktfunkHostLive`]. Wire shapes +// are Effect Schemas (generated for REST in ./gen/schemas.ts, hand-mirrored for events in +// ./wire.ts); API responses are validated by default, so host/SDK version skew surfaces as a +// typed [`VersionSkew`] instead of an `undefined` three frames later. +import { + Context, + Data, + Effect, + Layer, + Option, + Schema as S, + Stream, +} from "effect"; +import { + type ConnectOptions, + type ResolvedConfig, + resolveConfig, +} from "./config.js"; +import { HttpStatusError, httpRequest } from "./core.js"; +import { + type EventStreamOptions, + type SseFrame, + SseAuthError, + sseFrames, +} from "./sse.js"; +import { decodeHostEvent, type HostEvent } from "./wire.js"; + +/** Bad credentials — the token (or paired cert) was rejected. */ +export class AuthError extends Data.TaggedError("AuthError")<{ + message: string; +}> {} +/** The host answered with a non-2xx (the message is its `ApiError` envelope). */ +export class ApiError extends Data.TaggedError("ApiError")<{ + status: number; + message: string; +}> {} +/** The request never completed (connection refused, TLS, abort). */ +export class TransportError extends Data.TaggedError("TransportError")<{ + cause: unknown; +}> {} +/** A 2xx body did not match its schema — host and SDK disagree on the wire shape. */ +export class VersionSkew extends Data.TaggedError("VersionSkew")<{ + path: string; + issue: string; +}> {} +/** The event stream failed unrecoverably (auth) — transient trouble self-heals via reconnect. */ +export class EventStreamError extends Data.TaggedError("EventStreamError")<{ + cause: unknown; +}> {} + +export type RequestError = AuthError | ApiError | TransportError; + +export interface PunktfunkHostService { + readonly config: ResolvedConfig; + /** One management-API request under `/api/v1`; the parsed JSON body. */ + readonly request: ( + method: string, + path: string, + body?: unknown, + ) => Effect.Effect; + /** GET + schema-validate (the generated schemas from `@punktfunk/host/effect`'s `api`). */ + readonly get: ( + path: string, + schema: S.Schema, + ) => Effect.Effect; + /** + * The lifecycle-event stream: decoded [`HostEvent`]s with automatic reconnect + + * `Last-Event-ID` resume. Unknown kinds and the `dropped` marker surface on + * [`eventsRaw`] (and the warning callback), never as a failure here. + */ + readonly events: ( + opts?: EventStreamOptions, + ) => Stream.Stream; + /** Every SSE frame verbatim — the `dropped` marker and unknown kinds included. */ + readonly eventsRaw: ( + opts?: EventStreamOptions, + ) => Stream.Stream; +} + +export class PunktfunkHost extends Context.Tag("@punktfunk/host/PunktfunkHost")< + PunktfunkHost, + PunktfunkHostService +>() {} + +const toRequestError = (path: string, cause: unknown): RequestError => { + if (cause instanceof HttpStatusError) { + return cause.status === 401 + ? new AuthError({ message: cause.message }) + : new ApiError({ status: cause.status, message: cause.message }); + } + return new TransportError({ cause }); +}; + +export const makeService = (cfg: ResolvedConfig): PunktfunkHostService => { + const request = (method: string, path: string, body?: unknown) => + Effect.tryPromise({ + try: () => httpRequest(cfg, method, path, body), + catch: (cause) => toRequestError(path, cause), + }); + const get = (path: string, schema: S.Schema) => + request("GET", path).pipe( + Effect.flatMap((body) => + S.decodeUnknown(schema)(body).pipe( + Effect.mapError( + (e) => new VersionSkew({ path, issue: String(e) }), + ), + ), + ), + ); + const eventsRaw = (opts?: EventStreamOptions) => + // suspend: each run must get a FRESH generator (a generator is single-use). + Stream.suspend(() => + Stream.fromAsyncIterable( + sseFrames(cfg, opts), + (cause) => new EventStreamError({ cause }), + ), + ); + const events = (opts?: EventStreamOptions) => { + const warn = + opts?.onWarning ?? ((m: string) => console.warn(`[punktfunk] ${m}`)); + return eventsRaw(opts).pipe( + Stream.filterMap((frame) => { + if (frame.event === "dropped") { + warn( + "event cursor fell off the host's ring — resync via the REST snapshots", + ); + return Option.none(); + } + let json: unknown; + try { + json = JSON.parse(frame.data); + } catch { + warn(`unparseable event frame (${frame.event})`); + return Option.none(); + } + const decoded = decodeHostEvent(json); + if (decoded._tag === "Left") { + // An unknown kind from a NEWER host is expected (additive-only wire) — + // it rides the raw channel; a consumer that wants it uses eventsRaw. + warn(`unknown/undecodable event kind "${frame.event}"`); + return Option.none(); + } + return Option.some(decoded.right); + }), + ); + }; + return { config: cfg, request, get, events, eventsRaw }; +}; + +/** + * The live layer: resolves URL/token/CA (env → host files) and provides [`PunktfunkHost`]. + */ +export const layer = ( + options?: ConnectOptions, +): Layer.Layer => + Layer.effect( + PunktfunkHost, + Effect.tryPromise({ + try: () => resolveConfig(options), + catch: (cause) => new TransportError({ cause }), + }).pipe(Effect.map(makeService)), + ); + +/** RFC-spelled alias of [`layer`]. */ +export const PunktfunkHostLive = layer; + +export { SseAuthError }; diff --git a/sdk/src/config.ts b/sdk/src/config.ts new file mode 100644 index 00000000..a49781ff --- /dev/null +++ b/sdk/src/config.ts @@ -0,0 +1,128 @@ +// Connection resolution (RFC §7): loopback URL + bearer token + the host's self-signed +// identity cert, from the environment with file fallbacks — so `connect()` on the host machine +// needs zero configuration. +// +// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990) +// PUNKTFUNK_MGMT_TOKEN (else /mgmt-token) +// PUNKTFUNK_MGMT_CA (path; else /cert.pem when present) +// +// The CA is the host's own identity certificate — trusting exactly it (not the system roots) +// IS the pin for the loopback hop. Per-runtime plumbing differs: Bun takes `tls.ca` on fetch, +// Node (undici) takes a dispatcher with a CA-carrying TLS connector; anything else falls back +// to plain fetch (document PUNKTFUNK_MGMT_CA + NODE_EXTRA_CA_CERTS there). +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export interface ConnectOptions { + /** Management API base URL (default `https://127.0.0.1:47990`). */ + url?: string; + /** Bearer token (default: `PUNKTFUNK_MGMT_TOKEN`, else the host's `mgmt-token` file). */ + token?: string; + /** PEM of the CA to trust — the host's identity cert (default: `PUNKTFUNK_MGMT_CA`, else `cert.pem`). */ + ca?: string; +} + +export interface ResolvedConfig { + url: string; + token: string; + ca?: string; + /** A fetch honoring `ca` on this runtime. */ + fetch: typeof fetch; +} + +/** The host's config dir — the same resolution the host itself uses. */ +export const configDir = (): string => { + const explicit = process.env.PUNKTFUNK_CONFIG_DIR; + if (explicit) return explicit; + if (process.platform === "win32") { + const base = process.env.ProgramData ?? process.env.APPDATA ?? "."; + return path.join(base, "punktfunk"); + } + const base = + process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); + return path.join(base, "punktfunk"); +}; + +const readIfExists = (p: string): string | undefined => { + try { + return fs.readFileSync(p, "utf8"); + } catch { + return undefined; + } +}; + +/** First token-looking line of the mgmt-token file (tolerates `TOKEN=`-style and blank lines). */ +const parseTokenFile = (raw: string): string | undefined => { + for (const line of raw.split(/\r?\n/)) { + const t = line.trim(); + if (t.length === 0 || t.startsWith("#")) continue; + return t.includes("=") ? t.slice(t.indexOf("=") + 1).trim() : t; + } + return undefined; +}; + +export const resolveConfig = async ( + options?: ConnectOptions, +): Promise => { + const url = ( + options?.url ?? + process.env.PUNKTFUNK_MGMT_URL ?? + "https://127.0.0.1:47990" + ).replace(/\/+$/, ""); + const token = + options?.token ?? + process.env.PUNKTFUNK_MGMT_TOKEN ?? + parseTokenFile(readIfExists(path.join(configDir(), "mgmt-token")) ?? ""); + if (!token) { + throw new Error( + "no management token: set PUNKTFUNK_MGMT_TOKEN, pass { token }, or run where " + + `the host's token file exists (${path.join(configDir(), "mgmt-token")})`, + ); + } + const caPath = process.env.PUNKTFUNK_MGMT_CA; + const ca = + options?.ca ?? + (caPath ? readIfExists(caPath) : undefined) ?? + (url.startsWith("https://") + ? readIfExists(path.join(configDir(), "cert.pem")) + : undefined); + return { url, token, ca, fetch: await makeFetch(ca) }; +}; + +/** + * A fetch that PINS `ca` — the host's self-signed identity cert — on this runtime. + * + * The pin is chain verification against exactly that certificate (nothing else can pass), + * with the HOSTNAME check waived: the host identity cert is deliberately CN-only/no-SAN + * (native clients pin its fingerprint; see `web/nitro-entry/bun-https.mjs` for the same + * finding), so standard SAN matching would always fail — and it adds nothing when the chain + * already admits only the one pinned cert. + */ +const makeFetch = async (ca: string | undefined): Promise => { + if (!ca) return fetch; + const skipHostname = { checkServerIdentity: () => undefined }; + // Bun: fetch takes node-compatible `tls` options. + if (typeof (globalThis as Record).Bun !== "undefined") { + return ((input: Parameters[0], init?: RequestInit) => + fetch(input, { + ...init, + tls: { ca, ...skipHostname }, + } as RequestInit)) as typeof fetch; + } + // Node: global fetch is undici — a per-request dispatcher carries the pin. + try { + // Optional dependency — declared in package.json optionalDependencies; absent on + // runtimes that don't need it (the catch below falls back). + const { Agent } = (await import("undici" as string)) as { + Agent: new (opts: unknown) => unknown; + }; + const dispatcher = new Agent({ connect: { ca, ...skipHostname } }); + return ((input: Parameters[0], init?: RequestInit) => + fetch(input, { ...init, dispatcher } as RequestInit)) as typeof fetch; + } catch { + // Unknown runtime: plain fetch (system trust) — PUNKTFUNK_MGMT_CA via the runtime's + // own CA mechanism (e.g. NODE_EXTRA_CA_CERTS / --cert) is the documented fallback. + return fetch; + } +}; diff --git a/sdk/src/core.ts b/sdk/src/core.ts new file mode 100644 index 00000000..6cbe4ac4 --- /dev/null +++ b/sdk/src/core.ts @@ -0,0 +1,47 @@ +// The one HTTP implementation both surfaces share (RFC §7: two surfaces, one core): the +// Effect service wraps these with typed errors; the Promise facade calls them directly. +import type { ResolvedConfig } from "./config.js"; + +/** A non-2xx response, with the host's `ApiError` envelope message when present. */ +export class HttpStatusError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +/** + * One management-API request under `/api/v1`. Returns the parsed JSON body (or `undefined` + * for 204/empty). Throws [`HttpStatusError`] on a non-2xx (401 included — callers type it). + */ +export const httpRequest = async ( + cfg: ResolvedConfig, + method: string, + apiPath: string, + body?: unknown, +): Promise => { + const headers: Record = { + authorization: `Bearer ${cfg.token}`, + }; + if (body !== undefined) headers["content-type"] = "application/json"; + const resp = await cfg.fetch(`${cfg.url}/api/v1${apiPath}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!resp.ok) { + let message = `HTTP ${resp.status}`; + try { + const err = (await resp.json()) as { error?: string }; + if (typeof err.error === "string") message = err.error; + } catch { + // non-JSON error body — keep the status message + } + throw new HttpStatusError(resp.status, message); + } + if (resp.status === 204) return undefined; + const text = await resp.text(); + return text.length === 0 ? undefined : JSON.parse(text); +}; diff --git a/sdk/src/effect.ts b/sdk/src/effect.ts new file mode 100644 index 00000000..82b5a76a --- /dev/null +++ b/sdk/src/effect.ts @@ -0,0 +1,70 @@ +// `@punktfunk/host/effect` — the Effect-native surface (RFC §7): the `PunktfunkHost` service +// tag + live layer, the wire schemas, typed errors, and Stream accessors. The package root +// (`@punktfunk/host`) is the Promise facade; this entry is for plugins and Effect programs. +// +// import { Effect, Stream } from "effect"; +// import { PunktfunkHost, PunktfunkHostLive, events } from "@punktfunk/host/effect"; +// +// const program = Effect.gen(function* () { +// yield* events().pipe( +// Stream.filter((e) => e.kind === "stream.started"), +// Stream.runForEach((e) => Effect.log(`stream started: ${e.stream.mode}`)), +// ); +// }); +// Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive()))); +import { Effect, type Schema as S, Stream } from "effect"; +import { + EventStreamError, + PunktfunkHost, + type RequestError, + type VersionSkew, +} from "./client.js"; +import type { EventStreamOptions, SseFrame } from "./sse.js"; +import type { HostEvent } from "./wire.js"; + +export { + ApiError, + AuthError, + EventStreamError, + layer, + makeService, + PunktfunkHost, + type PunktfunkHostService, + PunktfunkHostLive, + type RequestError, + SseAuthError, + TransportError, + VersionSkew, +} from "./client.js"; +export { type ConnectOptions, configDir, resolveConfig } from "./config.js"; +export type { EventStreamOptions, SseFrame } from "./sse.js"; +export * from "./wire.js"; +/** The generated REST wire schemas (orval `client: 'effect'` over `api/openapi.json`). */ +export * as api from "./gen/schemas.js"; + +/** The decoded lifecycle-event stream of the ambient [`PunktfunkHost`]. */ +export const events = ( + opts?: EventStreamOptions, +): Stream.Stream => + Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.events(opts))); + +/** Every SSE frame verbatim (the `dropped` marker + unknown kinds included). */ +export const eventsRaw = ( + opts?: EventStreamOptions, +): Stream.Stream => + Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.eventsRaw(opts))); + +/** One management-API request under `/api/v1` on the ambient service. */ +export const request = ( + method: string, + path: string, + body?: unknown, +): Effect.Effect => + Effect.flatMap(PunktfunkHost, (s) => s.request(method, path, body)); + +/** GET + schema-validate on the ambient service (schemas from [`api`]). */ +export const get = ( + path: string, + schema: S.Schema, +): Effect.Effect => + Effect.flatMap(PunktfunkHost, (s) => s.get(path, schema)); diff --git a/sdk/src/gen/schemas.ts b/sdk/src/gen/schemas.ts new file mode 100644 index 00000000..de3bf065 --- /dev/null +++ b/sdk/src/gen/schemas.ts @@ -0,0 +1,1603 @@ +/** + * Generated by orval v8.22.0 🍺 + * Do not edit manually. + * punktfunk management API + * Control-plane API for managing a punktfunk streaming host: host capabilities, runtime status, paired clients, the pairing PIN flow, and session control. Authentication: HTTP bearer token, enforced on every route except `/api/v1/health` when the host is started with a management token (mandatory for non-loopback binds). + * OpenAPI spec version: 0.12.0 + */ +import { + Schema as S +} from 'effect'; + +/** + * @summary List paired clients + */ +export const ListPairedClientsResponseItem = S.Struct({ + "fingerprint": S.String.annotations({ description: 'Lowercase hex SHA-256 of the client certificate DER — the client\'s stable id here.' }), + "not_after_unix": S.optional(S.NullOr(S.Number)).annotations({ description: 'Certificate validity end (unix seconds).' }), + "not_before_unix": S.optional(S.NullOr(S.Number)).annotations({ description: 'Certificate validity start (unix seconds).' }), + "subject": S.optional(S.NullOr(S.String)).annotations({ description: 'Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.' }) +}).annotations({ description: 'A paired (certificate-pinned) Moonlight client.' }) +export const ListPairedClientsResponse = S.Array(ListPairedClientsResponseItem) + + +/** + * Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer + * does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed + * client cert — a planned hardening step), so until that lands this removes the client + * from the listing without severing its ability to reconnect. + * @summary Unpair a client + */ +export const UnpairClientParams = S.Struct({ + "fingerprint": S.String.annotations({ description: 'Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)' }) +}) + + +/** + * Lists every backend the host knows how to drive, flags which are usable right now, and marks + * the one an unspecified (`Auto`) client request resolves to. Clients pass an `id` to their + * `--compositor` flag (or `PUNKTFUNK_COMPOSITOR_*` over the C ABI) to request it. + * @summary Available compositor backends + */ +export const ListCompositorsResponseItem = S.Struct({ + "available": S.Boolean.annotations({ description: 'Usable on this host right now: the live session\'s own compositor, or gamescope wherever\nits binary is installed.' }), + "default": S.Boolean.annotations({ description: 'True for the backend an `Auto` (unspecified) request resolves to right now.' }), + "id": S.String.annotations({ description: 'Stable identifier (`\"kwin\"` | `\"wlroots\"` | `\"mutter\"` | `\"gamescope\"`) — pass this to a\nclient\'s `--compositor` flag.' }), + "label": S.String.annotations({ description: 'Human-readable label for UIs.' }) +}).annotations({ description: 'A compositor backend the host can drive a virtual output on, and whether it\'s usable now.' }) +export const ListCompositorsResponse = S.Array(ListCompositorsResponseItem) + + +/** + * Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor + * group (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block + * and switched to manual mode; applied from the next connect (a live group re-applies on its next + * acquire). Locks in the current effective behavior as explicit fields, so arranging displays never + * silently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2. + * @summary Arrange virtual displays + */ +export const SetDisplayLayoutBody = S.Struct({ + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })).annotations({ description: '`{\"\": {\"x\": …, \"y\": …}}` — where each arranged display\'s top-left sits.' }) +}).annotations({ description: 'Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `\/display\/state` reports as `identity_slot`).' }) + +export const setDisplayLayoutResponseCustomPresetsItemFieldsKeepAliveTwoSecondsMin = 0; + +export const setDisplayLayoutResponseCustomPresetsItemFieldsMaxDisplaysMin = 0; + +export const setDisplayLayoutResponseEffectiveKeepAliveTwoSecondsMin = 0; + +export const setDisplayLayoutResponseEffectiveMaxDisplaysMin = 0; + +export const setDisplayLayoutResponsePresetsItemFieldsKeepAliveTwoSecondsMin = 0; + +export const setDisplayLayoutResponsePresetsItemFieldsMaxDisplaysMin = 0; + +export const setDisplayLayoutResponseSettingsKeepAliveTwoSecondsMin = 0; + +export const setDisplayLayoutResponseSettingsMaxDisplaysMin = 0; + +export const setDisplayLayoutResponseSettingsVersionMin = 0; + + + +export const SetDisplayLayoutResponse = S.Struct({ + "configured": S.Boolean.annotations({ description: 'True once a `display-settings.json` exists (the console has configured this host).' }), + "custom_presets": S.Array(S.Struct({ + "fields": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(setDisplayLayoutResponseCustomPresetsItemFieldsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(setDisplayLayoutResponseCustomPresetsItemFieldsMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The six display-behavior axes this preset applies (the same shape a built-in preset expands to).' }), + "game_session": S.optional(S.Literal('auto', 'dedicated')).annotations({ 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": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }), + "name": S.String.annotations({ description: 'User-facing name shown on the preset card; editable.' }) +}).annotations({ 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).' })).annotations({ description: 'The operator\'s saved custom presets (`display-presets.json`) — named field-bundles rendered\nalongside the built-ins. Managed via `POST\/PUT\/DELETE \/display\/presets`; applied by writing a\n`Custom` policy carrying the preset\'s fields.' }), + "effective": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(setDisplayLayoutResponseEffectiveKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(setDisplayLayoutResponseEffectiveMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The effective (preset-expanded) policy currently in force.' }), + "enforced": S.Array(S.String).annotations({ description: 'Option names this build enforces right now. All five axes are now acted on (keep_alive +\ntopology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console\nreads this to know which controls are live vs. \"coming soon\" (per-backend nuance, e.g. layout\nposition apply being KWin-only, is reported per display in `\/display\/state`).' }), + "presets": S.Array(S.Struct({ + "fields": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(setDisplayLayoutResponsePresetsItemFieldsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(setDisplayLayoutResponsePresetsItemFieldsMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The effective policy this preset expands to (the same fields a `custom` policy carries).' }), + "id": S.String.annotations({ description: 'The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`).' }), + "summary": S.String.annotations({ description: 'One-line story shown next to the option.' }) +}).annotations({ description: 'One preset\'s human-facing description + the fields it expands to, so the console can render a\npreset picker with an accurate \"what this does\" preview without hardcoding the expansion.' })).annotations({ description: 'Every named preset and what it expands to (for the picker\'s preview).' }), + "settings": S.Struct({ + "ddc_power_off": S.optional(S.Boolean).annotations({ description: 'EXPERIMENTAL (Windows): command physical monitors\' panels off over DDC\/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan \/ DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC\/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched.' }), + "game_session": S.optional(S.Literal('auto', 'dedicated')).annotations({ description: 'How a game-launching session is served (`design\/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`\/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched.' }), + "identity": S.optional(S.Literal('shared', 'per-client', 'per-client-mode')).annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.optional(S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(setDisplayLayoutResponseSettingsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' }))).annotations({ 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.' }), + "layout": S.optional(S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +})).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(setDisplayLayoutResponseSettingsMaxDisplaysMin))).annotations({ description: 'Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).' }), + "mode_conflict": S.optional(S.Literal('separate', 'steal', 'join', 'reject')).annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "pnp_disable_monitors": S.optional(S.Boolean).annotations({ description: 'EXPERIMENTAL (Windows): DISABLE physical monitors\' PnP device nodes for the stream\'s\nduration (persistently, so a standby monitor\/TV whose hot-plug events re-arrive stays\ndisabled) and re-enable them at teardown. Two selectors: the monitors an `Exclusive`\nisolate deactivated, plus — in ANY topology — external monitors that are connected but not\npart of the desktop (the standby TV that was never active, whose input auto-scan \/\ninstant-on HPD cycling re-probes the link every few seconds). Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode\'s wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.' }), + "preset": S.optional(S.Literal('custom', 'default', 'gaming-rig', 'shared-desktop', 'hotdesk', 'workstation')).annotations({ description: 'A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).' }), + "topology": S.optional(S.Literal('auto', 'extend', 'primary', 'exclusive')).annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }), + "version": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(setDisplayLayoutResponseSettingsVersionMin))).annotations({ description: 'Schema version (currently 1) — lets a future field addition migrate rather than reject.' }) +}).annotations({ description: 'The stored policy (preset + custom fields), or the built-in default when unconfigured.' }) +}).annotations({ description: 'Full display-management state for the console: the stored policy, every preset\'s expansion, the\nresolved effective policy, and which options this build actually enforces yet (Stage 0 wires\nkeep-alive linger + topology; the rest are stored but not yet acted on).' }) + + +/** + * The operator's named field-bundles (`display-presets.json`). These also ride the + * `GET /display/settings` response (`custom_presets`), so the console rarely needs this directly. + * @summary List the saved custom presets + */ +export const listCustomPresetsResponseFieldsKeepAliveTwoSecondsMin = 0; + +export const listCustomPresetsResponseFieldsMaxDisplaysMin = 0; + + + +export const ListCustomPresetsResponseItem = S.Struct({ + "fields": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(listCustomPresetsResponseFieldsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(listCustomPresetsResponseFieldsMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The six display-behavior axes this preset applies (the same shape a built-in preset expands to).' }), + "game_session": S.optional(S.Literal('auto', 'dedicated')).annotations({ 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": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }), + "name": S.String.annotations({ description: 'User-facing name shown on the preset card; editable.' }) +}).annotations({ 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 const ListCustomPresetsResponse = S.Array(ListCustomPresetsResponseItem) + + +/** + * Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can + * apply later. The host assigns a stable id, returned in the body. Applying a preset is a + * `PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route. + * @summary Save a custom preset + */ +export const createCustomPresetBodyFieldsKeepAliveTwoSecondsMin = 0; + +export const createCustomPresetBodyFieldsMaxDisplaysMin = 0; + + + +export const CreateCustomPresetBody = S.Struct({ + "fields": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(createCustomPresetBodyFieldsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(createCustomPresetBodyFieldsMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The six resolved fields after preset expansion — what the lifecycle\/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].' }), + "game_session": S.optional(S.Literal('auto', 'dedicated')).annotations({ description: 'How a session that \*\*launches a game\*\* (a library id on the Hello \/ apps.json \/ Decky pin) is\nserved (`design\/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset\/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).' }), + "name": S.String +}).annotations({ description: 'Request body to create or replace a custom preset (no `id` — the host owns it).' }) + + +/** + * @summary Update a custom preset + */ +export const UpdateCustomPresetParams = S.Struct({ + "id": S.String.annotations({ description: 'The custom preset id' }) +}) + +export const updateCustomPresetBodyFieldsKeepAliveTwoSecondsMin = 0; + +export const updateCustomPresetBodyFieldsMaxDisplaysMin = 0; + + + +export const UpdateCustomPresetBody = S.Struct({ + "fields": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(updateCustomPresetBodyFieldsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(updateCustomPresetBodyFieldsMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The six resolved fields after preset expansion — what the lifecycle\/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].' }), + "game_session": S.optional(S.Literal('auto', 'dedicated')).annotations({ description: 'How a session that \*\*launches a game\*\* (a library id on the Hello \/ apps.json \/ Decky pin) is\nserved (`design\/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset\/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).' }), + "name": S.String +}).annotations({ description: 'Request body to create or replace a custom preset (no `id` — the host owns it).' }) + +export const updateCustomPresetResponseFieldsKeepAliveTwoSecondsMin = 0; + +export const updateCustomPresetResponseFieldsMaxDisplaysMin = 0; + + + +export const UpdateCustomPresetResponse = S.Struct({ + "fields": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(updateCustomPresetResponseFieldsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(updateCustomPresetResponseFieldsMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The six display-behavior axes this preset applies (the same shape a built-in preset expands to).' }), + "game_session": S.optional(S.Literal('auto', 'dedicated')).annotations({ 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": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }), + "name": S.String.annotations({ description: 'User-facing name shown on the preset card; editable.' }) +}).annotations({ 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).' }) + + +/** + * Removes it from the catalog. The active policy is untouched — if this preset was the one applied, + * the running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled). + * @summary Delete a custom preset + */ +export const DeleteCustomPresetParams = S.Struct({ + "id": S.String.annotations({ description: 'The custom preset id' }) +}) + + +/** + * Tear down lingering/pinned displays now — so a physical-screen user gets their screen back + * without waiting out the linger. `slot` releases one; omit it to release all kept displays. + * Active (streaming) displays are never torn down here (that is session control). + * @summary Release kept virtual displays + */ +export const releaseDisplayBodySlotMin = 0; + + + +export const ReleaseDisplayBody = S.Struct({ + "slot": S.optional(S.NullOr(S.Number.pipe(S.greaterThanOrEqualTo(releaseDisplayBodySlotMin)))).annotations({ description: 'Slot to release (see `state`); omit to release \*\*all\*\* kept displays.' }) +}).annotations({ description: 'Request body for `releaseDisplay`.' }) + +export const releaseDisplayResponseReleasedMin = 0; + + + +export const ReleaseDisplayResponse = S.Struct({ + "released": S.Number.pipe(S.greaterThanOrEqualTo(releaseDisplayResponseReleasedMin)).annotations({ description: 'Number of kept displays torn down.' }) +}).annotations({ description: 'Result of a `\/display\/release`.' }) + + +/** + * The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout), + * every preset's expansion, and which options this build enforces yet. See + * `design/display-management.md`. + * @summary Display-management policy + */ +export const getDisplaySettingsResponseCustomPresetsItemFieldsKeepAliveTwoSecondsMin = 0; + +export const getDisplaySettingsResponseCustomPresetsItemFieldsMaxDisplaysMin = 0; + +export const getDisplaySettingsResponseEffectiveKeepAliveTwoSecondsMin = 0; + +export const getDisplaySettingsResponseEffectiveMaxDisplaysMin = 0; + +export const getDisplaySettingsResponsePresetsItemFieldsKeepAliveTwoSecondsMin = 0; + +export const getDisplaySettingsResponsePresetsItemFieldsMaxDisplaysMin = 0; + +export const getDisplaySettingsResponseSettingsKeepAliveTwoSecondsMin = 0; + +export const getDisplaySettingsResponseSettingsMaxDisplaysMin = 0; + +export const getDisplaySettingsResponseSettingsVersionMin = 0; + + + +export const GetDisplaySettingsResponse = S.Struct({ + "configured": S.Boolean.annotations({ description: 'True once a `display-settings.json` exists (the console has configured this host).' }), + "custom_presets": S.Array(S.Struct({ + "fields": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(getDisplaySettingsResponseCustomPresetsItemFieldsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(getDisplaySettingsResponseCustomPresetsItemFieldsMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The six display-behavior axes this preset applies (the same shape a built-in preset expands to).' }), + "game_session": S.optional(S.Literal('auto', 'dedicated')).annotations({ 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": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }), + "name": S.String.annotations({ description: 'User-facing name shown on the preset card; editable.' }) +}).annotations({ 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).' })).annotations({ description: 'The operator\'s saved custom presets (`display-presets.json`) — named field-bundles rendered\nalongside the built-ins. Managed via `POST\/PUT\/DELETE \/display\/presets`; applied by writing a\n`Custom` policy carrying the preset\'s fields.' }), + "effective": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(getDisplaySettingsResponseEffectiveKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(getDisplaySettingsResponseEffectiveMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The effective (preset-expanded) policy currently in force.' }), + "enforced": S.Array(S.String).annotations({ description: 'Option names this build enforces right now. All five axes are now acted on (keep_alive +\ntopology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console\nreads this to know which controls are live vs. \"coming soon\" (per-backend nuance, e.g. layout\nposition apply being KWin-only, is reported per display in `\/display\/state`).' }), + "presets": S.Array(S.Struct({ + "fields": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(getDisplaySettingsResponsePresetsItemFieldsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(getDisplaySettingsResponsePresetsItemFieldsMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The effective policy this preset expands to (the same fields a `custom` policy carries).' }), + "id": S.String.annotations({ description: 'The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`).' }), + "summary": S.String.annotations({ description: 'One-line story shown next to the option.' }) +}).annotations({ description: 'One preset\'s human-facing description + the fields it expands to, so the console can render a\npreset picker with an accurate \"what this does\" preview without hardcoding the expansion.' })).annotations({ description: 'Every named preset and what it expands to (for the picker\'s preview).' }), + "settings": S.Struct({ + "ddc_power_off": S.optional(S.Boolean).annotations({ description: 'EXPERIMENTAL (Windows): command physical monitors\' panels off over DDC\/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan \/ DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC\/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched.' }), + "game_session": S.optional(S.Literal('auto', 'dedicated')).annotations({ description: 'How a game-launching session is served (`design\/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`\/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched.' }), + "identity": S.optional(S.Literal('shared', 'per-client', 'per-client-mode')).annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.optional(S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(getDisplaySettingsResponseSettingsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' }))).annotations({ 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.' }), + "layout": S.optional(S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +})).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(getDisplaySettingsResponseSettingsMaxDisplaysMin))).annotations({ description: 'Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).' }), + "mode_conflict": S.optional(S.Literal('separate', 'steal', 'join', 'reject')).annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "pnp_disable_monitors": S.optional(S.Boolean).annotations({ description: 'EXPERIMENTAL (Windows): DISABLE physical monitors\' PnP device nodes for the stream\'s\nduration (persistently, so a standby monitor\/TV whose hot-plug events re-arrive stays\ndisabled) and re-enable them at teardown. Two selectors: the monitors an `Exclusive`\nisolate deactivated, plus — in ANY topology — external monitors that are connected but not\npart of the desktop (the standby TV that was never active, whose input auto-scan \/\ninstant-on HPD cycling re-probes the link every few seconds). Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode\'s wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.' }), + "preset": S.optional(S.Literal('custom', 'default', 'gaming-rig', 'shared-desktop', 'hotdesk', 'workstation')).annotations({ description: 'A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).' }), + "topology": S.optional(S.Literal('auto', 'extend', 'primary', 'exclusive')).annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }), + "version": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(getDisplaySettingsResponseSettingsVersionMin))).annotations({ description: 'Schema version (currently 1) — lets a future field addition migrate rather than reject.' }) +}).annotations({ description: 'The stored policy (preset + custom fields), or the built-in default when unconfigured.' }) +}).annotations({ description: 'Full display-management state for the console: the stored policy, every preset\'s expansion, the\nresolved effective policy, and which options this build actually enforces yet (Stage 0 wires\nkeep-alive linger + topology; the rest are stored but not yet acted on).' }) + + +/** + * Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a + * running session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is + * honored (the display is Pinned; free it via `POST /display/release`). + * @summary Set the display-management policy + */ +export const setDisplaySettingsBodyKeepAliveTwoSecondsMin = 0; + +export const setDisplaySettingsBodyMaxDisplaysMin = 0; + +export const setDisplaySettingsBodyVersionMin = 0; + + + +export const SetDisplaySettingsBody = S.Struct({ + "ddc_power_off": S.optional(S.Boolean).annotations({ description: 'EXPERIMENTAL (Windows): command physical monitors\' panels off over DDC\/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan \/ DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC\/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched.' }), + "game_session": S.optional(S.Literal('auto', 'dedicated')).annotations({ description: 'How a game-launching session is served (`design\/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`\/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched.' }), + "identity": S.optional(S.Literal('shared', 'per-client', 'per-client-mode')).annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.optional(S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsBodyKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' }))).annotations({ 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.' }), + "layout": S.optional(S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +})).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsBodyMaxDisplaysMin))).annotations({ description: 'Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).' }), + "mode_conflict": S.optional(S.Literal('separate', 'steal', 'join', 'reject')).annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "pnp_disable_monitors": S.optional(S.Boolean).annotations({ description: 'EXPERIMENTAL (Windows): DISABLE physical monitors\' PnP device nodes for the stream\'s\nduration (persistently, so a standby monitor\/TV whose hot-plug events re-arrive stays\ndisabled) and re-enable them at teardown. Two selectors: the monitors an `Exclusive`\nisolate deactivated, plus — in ANY topology — external monitors that are connected but not\npart of the desktop (the standby TV that was never active, whose input auto-scan \/\ninstant-on HPD cycling re-probes the link every few seconds). Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode\'s wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.' }), + "preset": S.optional(S.Literal('custom', 'default', 'gaming-rig', 'shared-desktop', 'hotdesk', 'workstation')).annotations({ description: 'A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).' }), + "topology": S.optional(S.Literal('auto', 'extend', 'primary', 'exclusive')).annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }), + "version": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsBodyVersionMin))).annotations({ description: 'Schema version (currently 1) — lets a future field addition migrate rather than reject.' }) +}).annotations({ description: 'The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs\/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].' }) + +export const setDisplaySettingsResponseCustomPresetsItemFieldsKeepAliveTwoSecondsMin = 0; + +export const setDisplaySettingsResponseCustomPresetsItemFieldsMaxDisplaysMin = 0; + +export const setDisplaySettingsResponseEffectiveKeepAliveTwoSecondsMin = 0; + +export const setDisplaySettingsResponseEffectiveMaxDisplaysMin = 0; + +export const setDisplaySettingsResponsePresetsItemFieldsKeepAliveTwoSecondsMin = 0; + +export const setDisplaySettingsResponsePresetsItemFieldsMaxDisplaysMin = 0; + +export const setDisplaySettingsResponseSettingsKeepAliveTwoSecondsMin = 0; + +export const setDisplaySettingsResponseSettingsMaxDisplaysMin = 0; + +export const setDisplaySettingsResponseSettingsVersionMin = 0; + + + +export const SetDisplaySettingsResponse = S.Struct({ + "configured": S.Boolean.annotations({ description: 'True once a `display-settings.json` exists (the console has configured this host).' }), + "custom_presets": S.Array(S.Struct({ + "fields": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsResponseCustomPresetsItemFieldsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsResponseCustomPresetsItemFieldsMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The six display-behavior axes this preset applies (the same shape a built-in preset expands to).' }), + "game_session": S.optional(S.Literal('auto', 'dedicated')).annotations({ 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": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }), + "name": S.String.annotations({ description: 'User-facing name shown on the preset card; editable.' }) +}).annotations({ 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).' })).annotations({ description: 'The operator\'s saved custom presets (`display-presets.json`) — named field-bundles rendered\nalongside the built-ins. Managed via `POST\/PUT\/DELETE \/display\/presets`; applied by writing a\n`Custom` policy carrying the preset\'s fields.' }), + "effective": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsResponseEffectiveKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsResponseEffectiveMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The effective (preset-expanded) policy currently in force.' }), + "enforced": S.Array(S.String).annotations({ description: 'Option names this build enforces right now. All five axes are now acted on (keep_alive +\ntopology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console\nreads this to know which controls are live vs. \"coming soon\" (per-backend nuance, e.g. layout\nposition apply being KWin-only, is reported per display in `\/display\/state`).' }), + "presets": S.Array(S.Struct({ + "fields": S.Struct({ + "identity": S.Literal('shared', 'per-client', 'per-client-mode').annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsResponsePresetsItemFieldsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' })).annotations({ 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.' }), + "layout": S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +}).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsResponsePresetsItemFieldsMaxDisplaysMin)), + "mode_conflict": S.Literal('separate', 'steal', 'join', 'reject').annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "topology": S.Literal('auto', 'extend', 'primary', 'exclusive').annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }) +}).annotations({ description: 'The effective policy this preset expands to (the same fields a `custom` policy carries).' }), + "id": S.String.annotations({ description: 'The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`).' }), + "summary": S.String.annotations({ description: 'One-line story shown next to the option.' }) +}).annotations({ description: 'One preset\'s human-facing description + the fields it expands to, so the console can render a\npreset picker with an accurate \"what this does\" preview without hardcoding the expansion.' })).annotations({ description: 'Every named preset and what it expands to (for the picker\'s preview).' }), + "settings": S.Struct({ + "ddc_power_off": S.optional(S.Boolean).annotations({ description: 'EXPERIMENTAL (Windows): command physical monitors\' panels off over DDC\/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan \/ DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC\/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched.' }), + "game_session": S.optional(S.Literal('auto', 'dedicated')).annotations({ description: 'How a game-launching session is served (`design\/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`\/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched.' }), + "identity": S.optional(S.Literal('shared', 'per-client', 'per-client-mode')).annotations({ description: 'Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.' }), + "keep_alive": S.optional(S.Union(S.Struct({ + "mode": S.Literal('off') +}).annotations({ description: 'Tear the display down at session end (today\'s default on every backend but Windows, which\nlingers 10 s).' }), S.Struct({ + "mode": S.Literal('duration'), + "seconds": S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsResponseSettingsKeepAliveTwoSecondsMin)).annotations({ description: 'Linger window in seconds.' }) +}).annotations({ description: 'Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.' }), S.Struct({ + "mode": S.Literal('forever') +}).annotations({ 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.' }))).annotations({ 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.' }), + "layout": S.optional(S.Struct({ + "mode": S.optional(S.Literal('auto-row', 'manual')).annotations({ description: 'How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.' }), + "positions": S.optional(S.Record({ key: S.String, value: S.Struct({ + "x": S.Number, + "y": S.Number +}).annotations({ description: 'A desktop-space offset for a display (top-left origin).' }) })) +})).annotations({ description: 'Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).' }), + "max_displays": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsResponseSettingsMaxDisplaysMin))).annotations({ description: 'Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).' }), + "mode_conflict": S.optional(S.Literal('separate', 'steal', 'join', 'reject')).annotations({ description: 'Admission when a \*different\* client connects while a display\/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.' }), + "pnp_disable_monitors": S.optional(S.Boolean).annotations({ description: 'EXPERIMENTAL (Windows): DISABLE physical monitors\' PnP device nodes for the stream\'s\nduration (persistently, so a standby monitor\/TV whose hot-plug events re-arrive stays\ndisabled) and re-enable them at teardown. Two selectors: the monitors an `Exclusive`\nisolate deactivated, plus — in ANY topology — external monitors that are connected but not\npart of the desktop (the standby TV that was never active, whose input auto-scan \/\ninstant-on HPD cycling re-probes the link every few seconds). Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode\'s wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.' }), + "preset": S.optional(S.Literal('custom', 'default', 'gaming-rig', 'shared-desktop', 'hotdesk', 'workstation')).annotations({ description: 'A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).' }), + "topology": S.optional(S.Literal('auto', 'extend', 'primary', 'exclusive')).annotations({ description: 'What the host does to the box\'s display topology while managed virtual displays are up.' }), + "version": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(setDisplaySettingsResponseSettingsVersionMin))).annotations({ description: 'Schema version (currently 1) — lets a future field addition migrate rather than reject.' }) +}).annotations({ description: 'The stored policy (preset + custom fields), or the built-in default when unconfigured.' }) +}).annotations({ description: 'Full display-management state for the console: the stored policy, every preset\'s expansion, the\nresolved effective policy, and which options this build actually enforces yet (Stage 0 wires\nkeep-alive linger + topology; the rest are stored but not yet acted on).' }) + + +/** + * The host's managed virtual displays right now — active (streaming), lingering (kept after + * disconnect, counting down to teardown), or pinned (kept indefinitely). See + * `design/display-management.md`. + * @summary Live virtual displays + */ +export const getDisplayStateResponseDisplaysItemDisplayIndexMin = 0; + +export const getDisplayStateResponseDisplaysItemExpiresInMsMin = 0; + +export const getDisplayStateResponseDisplaysItemGroupMin = 0; + +export const getDisplayStateResponseDisplaysItemIdentitySlotMin = 0; + +export const getDisplayStateResponseDisplaysItemSessionsMin = 0; + +export const getDisplayStateResponseDisplaysItemSlotMin = 0; + + + +export const GetDisplayStateResponse = S.Struct({ + "displays": S.Array(S.Struct({ + "backend": S.String.annotations({ description: 'Backend name (`pf-vdisplay`, `kwin`, …).' }), + "client": S.optional(S.NullOr(S.String)).annotations({ description: 'Short client label, when the owner tracks it.' }), + "display_index": S.Number.pipe(S.greaterThanOrEqualTo(getDisplayStateResponseDisplaysItemDisplayIndexMin)).annotations({ description: 'This display\'s ordinal within its group, in acquire order (0-based).' }), + "expires_in_ms": S.optional(S.NullOr(S.Number.pipe(S.greaterThanOrEqualTo(getDisplayStateResponseDisplaysItemExpiresInMsMin)))).annotations({ description: 'Milliseconds until a lingering display is torn down (absent when active\/pinned).' }), + "group": S.Number.pipe(S.greaterThanOrEqualTo(getDisplayStateResponseDisplaysItemGroupMin)).annotations({ description: 'Display group (shared desktop) id — several displays with the same group form one desktop (§6A).' }), + "identity_slot": S.optional(S.NullOr(S.Number.pipe(S.greaterThanOrEqualTo(getDisplayStateResponseDisplaysItemIdentitySlotMin)))).annotations({ description: 'Stable per-client identity slot keying persistent config + manual layout (absent = shared\/anonymous).' }), + "mode": S.String.annotations({ description: '`WIDTHxHEIGHT@HZ`.' }), + "sessions": S.Number.pipe(S.greaterThanOrEqualTo(getDisplayStateResponseDisplaysItemSessionsMin)).annotations({ description: 'Live sessions holding the display.' }), + "slot": S.Number.pipe(S.greaterThanOrEqualTo(getDisplayStateResponseDisplaysItemSlotMin)).annotations({ description: 'Stable-enough id for the `\/display\/release` `slot` argument.' }), + "state": S.String.annotations({ description: '`active` | `lingering` | `pinned`.' }), + "topology": S.String.annotations({ description: 'Effective topology for this display\'s group (`extend` | `primary` | `exclusive`).' }), + "x": S.Number.annotations({ description: 'Desktop-space top-left `x` (auto-row or the console\'s manual arrangement, §6.2).' }), + "y": S.Number.annotations({ description: 'Desktop-space top-left `y`.' }) +}).annotations({ description: 'One live or kept virtual display.' })) +}).annotations({ description: 'The host\'s managed virtual displays right now.' }) + + +/** + * Server-Sent Events stream of the host's lifecycle events: client connect/disconnect, session + * and stream start/end, pairing decisions, display create/release, library changes, host + * start/stop — both protocol planes. Frames carry `id:` = the event's monotonic `seq`, + * `event:` = its kind, and `data:` = the event JSON (schema-versioned, additive-only). + * + * Resume: standard `Last-Event-ID` (or `?since=`) replays from the in-memory ring; a consumer + * that fell off the ring receives an `event: dropped` frame first and should resync via the + * REST snapshots. Keep-alive comments are sent every 15 s. + * @summary Stream host lifecycle events (SSE) + */ +export const streamEventsQuerySinceMin = 0; + + + +export const StreamEventsQueryParams = S.Struct({ + "since": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(streamEventsQuerySinceMin))).annotations({ description: 'Resume cursor: only events with `seq` greater than this are sent (the ring keeps the newest ~1024). `Last-Event-ID` takes precedence.' }), + "kinds": S.optional(S.String).annotations({ description: 'Comma-separated server-side kind filter: exact kinds (`pairing.pending`) or `domain.\*` prefixes (`stream.\*`).' }) +}) + +export const streamEventsHeaderLastEventIDMin = 0; + + + +export const StreamEventsHeader = S.Struct({ + "Last-Event-ID": S.optional(S.NullOr(S.Number.pipe(S.greaterThanOrEqualTo(streamEventsHeaderLastEventIDMin)))).annotations({ description: 'SSE auto-reconnect cursor — the `id:` of the last received frame.' }) +}) + + +/** + * Lists the host's hardware GPUs, the persisted auto/manual preference, the GPU the next session + * will use (and why), and the GPU live sessions encode on right now. + * @summary GPU inventory and selection + */ +export const listGpusResponseActiveTwoSessionsMin = 0; + +export const listGpusResponseGpusItemVramMbMin = 0; + + + +export const ListGpusResponse = S.Struct({ + "active": S.optional(S.Union(S.Null, S.Struct({ + "backend": S.String.annotations({ description: 'The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`).' }), + "id": S.String.annotations({ description: 'Stable id matching an entry of `gpus` (empty for the CPU\/software encoder).' }), + "name": S.String, + "sessions": S.Number.pipe(S.greaterThanOrEqualTo(listGpusResponseActiveTwoSessionsMin)).annotations({ description: 'Number of live encode sessions on it.' }), + "vendor": S.String.annotations({ description: '`nvidia` | `amd` | `intel` | `other`.' }) +}).annotations({ description: 'The GPU live sessions use right now (absent while nothing is streaming).' }))), + "env_override": S.optional(S.NullOr(S.String)).annotations({ description: '`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it.' }), + "gpus": S.Array(S.Struct({ + "id": S.String.annotations({ description: 'Stable identifier (`vendorid-deviceid-occurrence`, hex PCI ids) — pass to `setGpuPreference`.\nStable across reboots and driver updates, unlike an adapter index or LUID.' }), + "name": S.String.annotations({ description: 'Adapter\/marketing name.' }), + "vendor": S.String.annotations({ description: '`nvidia` | `amd` | `intel` | `other`.' }), + "vram_mb": S.Number.pipe(S.greaterThanOrEqualTo(listGpusResponseGpusItemVramMbMin)).annotations({ description: 'Dedicated VRAM in MiB (0 where the platform doesn\'t expose it).' }) +}).annotations({ description: 'One hardware GPU on the host (software\/WARP adapters are never listed).' })).annotations({ description: 'The host\'s hardware GPUs.' }), + "mode": S.String.annotations({ description: '`auto` or `manual`.' }), + "preferred_available": S.Boolean.annotations({ description: 'Whether the preferred GPU is currently present.' }), + "preferred_id": S.optional(S.NullOr(S.String)).annotations({ description: 'The manually preferred GPU\'s stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent.' }), + "preferred_name": S.optional(S.NullOr(S.String)).annotations({ description: 'The stored name of the preferred GPU (a usable label even when it is absent).' }), + "selected": S.optional(S.Union(S.Null, S.Struct({ + "id": S.String, + "name": S.String, + "source": S.String.annotations({ description: 'Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM \/ platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming).' }), + "vendor": S.String.annotations({ description: '`nvidia` | `amd` | `intel` | `other`.' }) +}).annotations({ description: 'The GPU the next session will use.' }))) +}).annotations({ description: 'Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.' }) + + +/** + * `auto` restores automatic selection (`PUNKTFUNK_RENDER_ADAPTER` pin, else max dedicated VRAM); + * `manual` pins capture + encode to the given GPU. Persisted across restarts; applies to the + * **next** session (a running session keeps its GPU). If the preferred GPU is absent at session + * start the host falls back to automatic selection rather than failing. + * @summary Set the GPU preference + */ +export const SetGpuPreferenceBody = S.Struct({ + "gpu_id": S.optional(S.NullOr(S.String)).annotations({ description: 'Required when `mode` is `manual`: the stable `id` of a currently listed GPU\n(see `listGpus`).' }), + "mode": S.String.annotations({ description: '`auto` (env pin, else max dedicated VRAM — the default) or `manual`.' }) +}).annotations({ description: 'Request body for `setGpuPreference`.' }) + +export const setGpuPreferenceResponseActiveTwoSessionsMin = 0; + +export const setGpuPreferenceResponseGpusItemVramMbMin = 0; + + + +export const SetGpuPreferenceResponse = S.Struct({ + "active": S.optional(S.Union(S.Null, S.Struct({ + "backend": S.String.annotations({ description: 'The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`).' }), + "id": S.String.annotations({ description: 'Stable id matching an entry of `gpus` (empty for the CPU\/software encoder).' }), + "name": S.String, + "sessions": S.Number.pipe(S.greaterThanOrEqualTo(setGpuPreferenceResponseActiveTwoSessionsMin)).annotations({ description: 'Number of live encode sessions on it.' }), + "vendor": S.String.annotations({ description: '`nvidia` | `amd` | `intel` | `other`.' }) +}).annotations({ description: 'The GPU live sessions use right now (absent while nothing is streaming).' }))), + "env_override": S.optional(S.NullOr(S.String)).annotations({ description: '`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it.' }), + "gpus": S.Array(S.Struct({ + "id": S.String.annotations({ description: 'Stable identifier (`vendorid-deviceid-occurrence`, hex PCI ids) — pass to `setGpuPreference`.\nStable across reboots and driver updates, unlike an adapter index or LUID.' }), + "name": S.String.annotations({ description: 'Adapter\/marketing name.' }), + "vendor": S.String.annotations({ description: '`nvidia` | `amd` | `intel` | `other`.' }), + "vram_mb": S.Number.pipe(S.greaterThanOrEqualTo(setGpuPreferenceResponseGpusItemVramMbMin)).annotations({ description: 'Dedicated VRAM in MiB (0 where the platform doesn\'t expose it).' }) +}).annotations({ description: 'One hardware GPU on the host (software\/WARP adapters are never listed).' })).annotations({ description: 'The host\'s hardware GPUs.' }), + "mode": S.String.annotations({ description: '`auto` or `manual`.' }), + "preferred_available": S.Boolean.annotations({ description: 'Whether the preferred GPU is currently present.' }), + "preferred_id": S.optional(S.NullOr(S.String)).annotations({ description: 'The manually preferred GPU\'s stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent.' }), + "preferred_name": S.optional(S.NullOr(S.String)).annotations({ description: 'The stored name of the preferred GPU (a usable label even when it is absent).' }), + "selected": S.optional(S.Union(S.Null, S.Struct({ + "id": S.String, + "name": S.String, + "source": S.String.annotations({ description: 'Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM \/ platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming).' }), + "vendor": S.String.annotations({ description: '`nvidia` | `amd` | `intel` | `other`.' }) +}).annotations({ description: 'The GPU the next session will use.' }))) +}).annotations({ description: 'Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.' }) + + +/** + * Always available without authentication. + * @summary Liveness probe + */ +export const getHealthResponseAbiVersionMin = 0; + + + +export const GetHealthResponse = S.Struct({ + "abi_version": S.Number.pipe(S.greaterThanOrEqualTo(getHealthResponseAbiVersionMin)).annotations({ description: '`punktfunk-core` C ABI version.' }), + "status": S.String.annotations({ description: 'Always `\"ok\"` when the host responds.' }), + "version": S.String.annotations({ description: '`punktfunk-host` crate version.' }) +}).annotations({ description: 'Liveness + version probe.' }) + + +/** + * The operator's `hooks.json`: commands and webhooks fired on host lifecycle events. Empty + * when unconfigured. + * @summary Get the hook configuration + */ +export const getHooksResponseHooksItemDebounceMsMin = 0; + +export const getHooksResponseHooksItemTimeoutSMin = 0; + + + +export const GetHooksResponse = S.Struct({ + "hooks": S.optional(S.Array(S.Struct({ + "debounce_ms": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(getHooksResponseHooksItemDebounceMsMin))).annotations({ description: 'Minimum interval between firings of this hook, in milliseconds. 0 = fire every time.' }), + "filter": S.optional(S.Union(S.Null, S.Struct({ + "app": S.optional(S.NullOr(S.String)).annotations({ description: 'Launched app id\/title (`stream.\*` events).' }), + "client": S.optional(S.NullOr(S.String)).annotations({ description: 'Client\/device name (for `session.\*`: the short client label the Dashboard shows).' }), + "fingerprint": S.optional(S.NullOr(S.String)).annotations({ description: 'Certificate fingerprint (hex, case-insensitive).' }), + "plane": S.optional(S.Union(S.Null, S.Literal('native', 'gamestream').annotations({ description: 'Protocol plane (`native` \/ `gamestream`).' }))) +}).annotations({ description: 'Exact-match constraints on the event\'s fields; every present field must match.' }))), + "hmac_secret_file": S.optional(S.NullOr(S.String)).annotations({ 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": S.String.annotations({ 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": S.optional(S.NullOr(S.String)).annotations({ description: 'Shell command to execute (detached, event JSON on stdin + `PF_EVENT_\*` env).' }), + "timeout_s": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(getHooksResponseHooksItemTimeoutSMin))).annotations({ description: 'Exec timeout in seconds (1–600, default 30); the process group is killed on expiry.' }), + "webhook": S.optional(S.NullOr(S.String)).annotations({ description: 'URL to POST the event JSON to.' }) +}).annotations({ description: 'One hook: fire `run` and\/or `webhook` when an event matching `on` (+ `filter`) occurs.' }))) +}).annotations({ description: 'The operator\'s hook configuration — the `hooks.json` document and the `\/api\/v1\/hooks` body.' }) + + +/** + * Validates and persists a full `hooks.json` document (this is a whole-document PUT, not a + * patch). Applies from the next event — no restart. Hook commands run as the host user + * (interactive user session on Windows): treat this configuration as operator-privileged. + * @summary Replace the hook configuration + */ +export const setHooksBodyHooksItemDebounceMsMin = 0; + +export const setHooksBodyHooksItemTimeoutSMin = 0; + + + +export const SetHooksBody = S.Struct({ + "hooks": S.optional(S.Array(S.Struct({ + "debounce_ms": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(setHooksBodyHooksItemDebounceMsMin))).annotations({ description: 'Minimum interval between firings of this hook, in milliseconds. 0 = fire every time.' }), + "filter": S.optional(S.Union(S.Null, S.Struct({ + "app": S.optional(S.NullOr(S.String)).annotations({ description: 'Launched app id\/title (`stream.\*` events).' }), + "client": S.optional(S.NullOr(S.String)).annotations({ description: 'Client\/device name (for `session.\*`: the short client label the Dashboard shows).' }), + "fingerprint": S.optional(S.NullOr(S.String)).annotations({ description: 'Certificate fingerprint (hex, case-insensitive).' }), + "plane": S.optional(S.Union(S.Null, S.Literal('native', 'gamestream').annotations({ description: 'Protocol plane (`native` \/ `gamestream`).' }))) +}).annotations({ description: 'Exact-match constraints on the event\'s fields; every present field must match.' }))), + "hmac_secret_file": S.optional(S.NullOr(S.String)).annotations({ 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": S.String.annotations({ 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": S.optional(S.NullOr(S.String)).annotations({ description: 'Shell command to execute (detached, event JSON on stdin + `PF_EVENT_\*` env).' }), + "timeout_s": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(setHooksBodyHooksItemTimeoutSMin))).annotations({ description: 'Exec timeout in seconds (1–600, default 30); the process group is killed on expiry.' }), + "webhook": S.optional(S.NullOr(S.String)).annotations({ description: 'URL to POST the event JSON to.' }) +}).annotations({ description: 'One hook: fire `run` and\/or `webhook` when an event matching `on` (+ `filter`) occurs.' }))) +}).annotations({ description: 'The operator\'s hook configuration — the `hooks.json` document and the `\/api\/v1\/hooks` body.' }) + +export const setHooksResponseHooksItemDebounceMsMin = 0; + +export const setHooksResponseHooksItemTimeoutSMin = 0; + + + +export const SetHooksResponse = S.Struct({ + "hooks": S.optional(S.Array(S.Struct({ + "debounce_ms": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(setHooksResponseHooksItemDebounceMsMin))).annotations({ description: 'Minimum interval between firings of this hook, in milliseconds. 0 = fire every time.' }), + "filter": S.optional(S.Union(S.Null, S.Struct({ + "app": S.optional(S.NullOr(S.String)).annotations({ description: 'Launched app id\/title (`stream.\*` events).' }), + "client": S.optional(S.NullOr(S.String)).annotations({ description: 'Client\/device name (for `session.\*`: the short client label the Dashboard shows).' }), + "fingerprint": S.optional(S.NullOr(S.String)).annotations({ description: 'Certificate fingerprint (hex, case-insensitive).' }), + "plane": S.optional(S.Union(S.Null, S.Literal('native', 'gamestream').annotations({ description: 'Protocol plane (`native` \/ `gamestream`).' }))) +}).annotations({ description: 'Exact-match constraints on the event\'s fields; every present field must match.' }))), + "hmac_secret_file": S.optional(S.NullOr(S.String)).annotations({ 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": S.String.annotations({ 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": S.optional(S.NullOr(S.String)).annotations({ description: 'Shell command to execute (detached, event JSON on stdin + `PF_EVENT_\*` env).' }), + "timeout_s": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(setHooksResponseHooksItemTimeoutSMin))).annotations({ description: 'Exec timeout in seconds (1–600, default 30); the process group is killed on expiry.' }), + "webhook": S.optional(S.NullOr(S.String)).annotations({ description: 'URL to POST the event JSON to.' }) +}).annotations({ description: 'One hook: fire `run` and\/or `webhook` when an event matching `on` (+ `filter`) occurs.' }))) +}).annotations({ description: 'The operator\'s hook configuration — the `hooks.json` document and the `\/api\/v1\/hooks` body.' }) + + +/** + * @summary Host identity and capabilities + */ +export const getHostInfoResponseAbiVersionMin = 0; + +export const getHostInfoResponsePortsAudioMin = 0; + +export const getHostInfoResponsePortsControlMin = 0; + +export const getHostInfoResponsePortsHttpMin = 0; + +export const getHostInfoResponsePortsHttpsMin = 0; + +export const getHostInfoResponsePortsMgmtMin = 0; + +export const getHostInfoResponsePortsRtspMin = 0; + +export const getHostInfoResponsePortsVideoMin = 0; + + + +export const GetHostInfoResponse = S.Struct({ + "abi_version": S.Number.pipe(S.greaterThanOrEqualTo(getHostInfoResponseAbiVersionMin)).annotations({ description: '`punktfunk-core` C ABI version.' }), + "app_version": S.String.annotations({ description: 'GameStream host version advertised to Moonlight clients.' }), + "codecs": S.Array(S.Literal('h264', 'hevc', 'av1', 'pyrowave').annotations({ description: 'Video codec identifier. The wire token matches the codec\'s canonical name used across the\nstack (SDP\/GameStream advertisement, the stats-capture `CaptureMeta.codec`, and the encoder\'s\n[`Codec::label`]) — notably `H.265` serializes as `\"hevc\"`, not `\"h265\"`, so the same codec\nreads identically on every console page.' })).annotations({ description: 'Codecs the host can encode (NVENC).' }), + "gamestream": S.Boolean.annotations({ description: 'Whether the GameStream\/Moonlight-compat planes are running (`--gamestream`). `false` on the\nsecure default (native punktfunk\/1 only) — a console can hide Moonlight-only UI (e.g. the\nMoonlight PIN pairing card, which could never receive a PIN when this is `false`).' }), + "gfe_version": S.String.annotations({ description: 'GFE version advertised to Moonlight clients.' }), + "hostname": S.String, + "local_ip": S.String.annotations({ description: 'Best-effort primary LAN IP.' }), + "ports": S.Struct({ + "audio": S.Number.pipe(S.greaterThanOrEqualTo(getHostInfoResponsePortsAudioMin)), + "control": S.Number.pipe(S.greaterThanOrEqualTo(getHostInfoResponsePortsControlMin)), + "http": S.Number.pipe(S.greaterThanOrEqualTo(getHostInfoResponsePortsHttpMin)).annotations({ description: 'nvhttp plain HTTP (serverinfo, pairing).' }), + "https": S.Number.pipe(S.greaterThanOrEqualTo(getHostInfoResponsePortsHttpsMin)).annotations({ description: 'nvhttp mutual-TLS HTTPS (post-pairing).' }), + "mgmt": S.Number.pipe(S.greaterThanOrEqualTo(getHostInfoResponsePortsMgmtMin)).annotations({ description: 'This management API.' }), + "rtsp": S.Number.pipe(S.greaterThanOrEqualTo(getHostInfoResponsePortsRtspMin)), + "video": S.Number.pipe(S.greaterThanOrEqualTo(getHostInfoResponsePortsVideoMin)) +}).annotations({ description: 'Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).' }), + "uniqueid": S.String.annotations({ description: 'Stable per-host id (persisted across restarts), matched on pairing.' }), + "version": S.String.annotations({ description: '`punktfunk-host` crate version.' }) +}).annotations({ description: 'Host identity and advertised capabilities (static for the life of the process).' }) + + +/** + * Every installed-store title (Steam, read from the host's local files — no Steam API key) + * merged with the user's custom entries, sorted by title. Artwork fields are URLs the client + * fetches directly (the public Steam CDN for Steam titles). + * @summary List the game library + */ +export const GetLibraryResponseItem = S.Struct({ + "art": S.Struct({ + "header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }), + "hero": S.optional(S.NullOr(S.String)).annotations({ description: 'Wide background (Steam `library_hero.jpg`).' }), + "logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }), + "portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' }) +}).annotations({ description: 'Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).' }), + "id": S.String.annotations({ description: 'Stable, store-qualified id: `steam:` or `custom:`.' }), + "launch": S.optional(S.Union(S.Null, S.Struct({ + "kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }), + "value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' }) +}).annotations({ description: 'How the host would launch it, when known.' }))), + "store": S.String.annotations({ description: 'Which store surfaced it: `\"steam\"` or `\"custom\"`.' }), + "title": S.String +}).annotations({ description: 'One title in the unified library, regardless of which store it came from.' }) +export const GetLibraryResponse = S.Array(GetLibraryResponseItem) + + +/** + * Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams + * the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact — + * it's what the user's Steam client already shows for it), the public Steam CDN's flat URL + * convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host + * can't predict, in which case this 404s and the client falls through to its next art candidate). + * Only Steam ids are backed today; any other store 404s. + * @summary Fetch one cover-art image for a library entry + */ +export const GetLibraryArtParams = S.Struct({ + "id": S.String.annotations({ description: 'The store-qualified library id, e.g. `steam:570`' }), + "kind": S.String.annotations({ description: '`portrait` | `hero` | `logo` | `header`' }) +}) + + +/** + * Creates a user-curated title (e.g. a non-Steam game, an emulator, a ROM) with caller-supplied + * artwork URLs. The host assigns a stable id, returned in the body. + * @summary Add a custom library entry + */ +export const CreateCustomGameBody = S.Struct({ + "art": S.optional(S.Struct({ + "header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }), + "hero": S.optional(S.NullOr(S.String)).annotations({ description: 'Wide background (Steam `library_hero.jpg`).' }), + "logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }), + "portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' }) +})).annotations({ description: 'Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).' }), + "launch": S.optional(S.Union(S.Null, S.Struct({ + "kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }), + "value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' }) +}).annotations({ description: 'How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam:\/\/rungameid\/`;\n`command` → run `` nested in a gamescope session.' }))), + "prep": S.optional(S.Array(S.Struct({ + "do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }), + "undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' }) +}).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps — commands run as the host user; operator-privileged config.' }), + "title": S.String +}).annotations({ description: 'Request body to create or replace a custom entry (no `id` — the host owns it).' }) + + +/** + * @summary Update a custom library entry + */ +export const UpdateCustomGameParams = S.Struct({ + "id": S.String.annotations({ description: 'The custom entry id (without the `custom:` prefix)' }) +}) + +export const UpdateCustomGameBody = S.Struct({ + "art": S.optional(S.Struct({ + "header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }), + "hero": S.optional(S.NullOr(S.String)).annotations({ description: 'Wide background (Steam `library_hero.jpg`).' }), + "logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }), + "portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' }) +})).annotations({ description: 'Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).' }), + "launch": S.optional(S.Union(S.Null, S.Struct({ + "kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }), + "value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' }) +}).annotations({ description: 'How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam:\/\/rungameid\/`;\n`command` → run `` nested in a gamescope session.' }))), + "prep": S.optional(S.Array(S.Struct({ + "do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }), + "undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' }) +}).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps — commands run as the host user; operator-privileged config.' }), + "title": S.String +}).annotations({ description: 'Request body to create or replace a custom entry (no `id` — the host owns it).' }) + +export const UpdateCustomGameResponse = S.Struct({ + "art": S.optional(S.Struct({ + "header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }), + "hero": S.optional(S.NullOr(S.String)).annotations({ description: 'Wide background (Steam `library_hero.jpg`).' }), + "logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }), + "portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' }) +})).annotations({ description: 'Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).' }), + "id": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }), + "launch": S.optional(S.Union(S.Null, S.Struct({ + "kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }), + "value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' }) +}).annotations({ description: 'How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam:\/\/rungameid\/`;\n`command` → run `` nested in a gamescope session.' }))), + "prep": S.optional(S.Array(S.Struct({ + "do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }), + "undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' }) +}).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`]).' }), + "title": S.String +}).annotations({ description: 'A user-added title, persisted in `~\/.config\/punktfunk\/library.json`. Same shape the API\nreturns and the web console edits.' }) + + +/** + * @summary Delete a custom library entry + */ +export const DeleteCustomGameParams = S.Struct({ + "id": S.String.annotations({ description: 'The custom entry id (without the `custom:` prefix)' }) +}) + + +/** + * Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device + * names). Unauthenticated, but served to loopback peers only. + * @summary Local status summary for the tray icon + */ +export const getLocalSummaryResponseKeptDisplaysMin = 0; + +export const getLocalSummaryResponseNativePairedClientsMin = 0; + +export const getLocalSummaryResponsePairedClientsMin = 0; + +export const getLocalSummaryResponsePendingApprovalsMin = 0; + +export const getLocalSummaryResponseSessionTwoFpsMin = 0; + +export const getLocalSummaryResponseSessionTwoHeightMin = 0; + +export const getLocalSummaryResponseSessionTwoWidthMin = 0; + + + +export const GetLocalSummaryResponse = S.Struct({ + "audio_streaming": S.Boolean.annotations({ description: 'True while the audio stream thread is running.' }), + "conflicts": S.optional(S.Array(S.String)).annotations({ description: 'Other Moonlight-compatible hosts (Sunshine\/Apollo\/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray\/console surface them so the clash is visible before pairing silently fails.' }), + "kept_displays": S.Number.pipe(S.greaterThanOrEqualTo(getLocalSummaryResponseKeptDisplaysMin)).annotations({ description: 'Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.' }), + "native_paired_clients": S.Number.pipe(S.greaterThanOrEqualTo(getLocalSummaryResponseNativePairedClientsMin)).annotations({ description: 'Number of paired native (punktfunk\/1) devices.' }), + "paired_clients": S.Number.pipe(S.greaterThanOrEqualTo(getLocalSummaryResponsePairedClientsMin)).annotations({ description: 'Number of pinned (paired) GameStream client certificates.' }), + "pending_approvals": S.Number.pipe(S.greaterThanOrEqualTo(getLocalSummaryResponsePendingApprovalsMin)).annotations({ description: 'Native pairing knocks awaiting the operator\'s approval (count only).' }), + "pin_pending": S.Boolean.annotations({ description: 'True while a GameStream pairing handshake is parked waiting for the user\'s PIN.' }), + "session": S.optional(S.Union(S.Null, S.Struct({ + "fps": S.Number.pipe(S.greaterThanOrEqualTo(getLocalSummaryResponseSessionTwoFpsMin)), + "height": S.Number.pipe(S.greaterThanOrEqualTo(getLocalSummaryResponseSessionTwoHeightMin)), + "width": S.Number.pipe(S.greaterThanOrEqualTo(getLocalSummaryResponseSessionTwoWidthMin)) +}).annotations({ description: 'The active launch session (set by Moonlight\'s `\/launch`, cleared on cancel\/stop).' }))), + "version": S.String.annotations({ description: 'Host version (mirrors `\/health`).' }), + "video_streaming": S.Boolean.annotations({ description: 'True while the video stream thread is running.' }) +}).annotations({ description: 'Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM\/Administrators-DACL\'d on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.' }) + + +/** + * The host's recent log entries — an in-memory ring of the newest few thousand, captured at + * DEBUG and above regardless of `RUST_LOG`. Follow live by polling with `after` set to the last + * response's `next` cursor; a `dropped: true` means entries were evicted between polls (the ring + * wrapped). Bearer-only: logs can reference client identities and host paths, so this is part of + * the loopback-only admin surface, never the LAN-readable mTLS one. + * @summary Host logs + */ +export const logsGetQueryAfterMin = 0; + +export const logsGetQueryLimitMin = 0; + + + +export const LogsGetQueryParams = S.Struct({ + "after": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(logsGetQueryAfterMin))).annotations({ description: 'Return entries with seq greater than this (omitted\/0 = oldest retained)' }), + "limit": S.optional(S.Number.pipe(S.greaterThanOrEqualTo(logsGetQueryLimitMin))).annotations({ description: 'Max entries per response (default and cap 1000)' }) +}) + +export const logsGetResponseEntriesItemSeqMin = 0; + +export const logsGetResponseEntriesItemTsMsMin = 0; + +export const logsGetResponseNextMin = 0; + + + +export const LogsGetResponse = S.Struct({ + "dropped": S.Boolean.annotations({ description: 'True when entries between `after` and the first returned one were already evicted.' }), + "entries": S.Array(S.Struct({ + "level": S.String.annotations({ description: '`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`.' }), + "msg": S.String.annotations({ description: 'The formatted message, structured fields appended as `key=value`.' }), + "seq": S.Number.pipe(S.greaterThanOrEqualTo(logsGetResponseEntriesItemSeqMin)).annotations({ description: 'Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.' }), + "target": S.String.annotations({ description: 'The emitting module path (tracing target).' }), + "ts_ms": S.Number.pipe(S.greaterThanOrEqualTo(logsGetResponseEntriesItemTsMsMin)).annotations({ description: 'Unix timestamp in milliseconds.' }) +}).annotations({ description: 'One captured log event.' })), + "next": S.Number.pipe(S.greaterThanOrEqualTo(logsGetResponseNextMin)).annotations({ description: 'Cursor for the next poll (the last returned seq, or the request\'s `after` when empty).' }) +}).annotations({ description: 'One poll\'s worth of log entries.' }) + + +/** + * @summary List native paired clients + */ +export const ListNativeClientsResponseItem = S.Struct({ + "fingerprint": S.String.annotations({ description: 'Hex SHA-256 of the client certificate — its stable id here.' }), + "name": S.String.annotations({ description: 'The name the client supplied when pairing.' }) +}).annotations({ description: 'A paired native (punktfunk\/1) client.' }) +export const ListNativeClientsResponse = S.Array(ListNativeClientsResponseItem) + + +/** + * Removes a punktfunk/1 client from the native trust store by fingerprint. + * @summary Unpair a native client + */ +export const UnpairNativeClientParams = S.Struct({ + "fingerprint": S.String.annotations({ description: 'Hex SHA-256 of the client certificate (case-insensitive)' }) +}) + + +/** + * The native (punktfunk/1) pairing window. Poll while armed to show the PIN + countdown. + * `enabled: false` means this host runs GameStream only (no `--native`). + * @summary Native pairing status + */ +export const getNativePairingResponseExpiresInSecsMin = 0; + +export const getNativePairingResponsePairedClientsMin = 0; + + + +export const GetNativePairingResponse = S.Struct({ + "armed": S.Boolean.annotations({ description: 'True while a pairing window is open.' }), + "enabled": S.Boolean.annotations({ description: 'Whether the native host is running (the unified host started with `--native`).' }), + "expires_in_secs": S.optional(S.NullOr(S.Number.pipe(S.greaterThanOrEqualTo(getNativePairingResponseExpiresInSecsMin)))).annotations({ description: 'Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag).' }), + "paired_clients": S.Number.pipe(S.greaterThanOrEqualTo(getNativePairingResponsePairedClientsMin)).annotations({ description: 'Number of paired native clients.' }), + "pin": S.optional(S.NullOr(S.String)).annotations({ description: 'The PIN to display while armed (null when disarmed).' }) +}).annotations({ description: 'Native (punktfunk\/1) pairing status. Unlike GameStream, the \*\*host\*\* mints the PIN (the SPAKE2\nceremony needs it client-side first), so the console \*\*displays\*\* `pin` for the user to enter on\ntheir device — armed on demand for a short window.' }) + + +/** + * Opens a pairing window and mints a fresh PIN to display. The user enters it on their device + * within `ttl_secs`; the device then appears in the native client list. + * @summary Arm native pairing + */ +export const armNativePairingBodyTtlSecsMin = 0; + + + +export const ArmNativePairingBody = S.Struct({ + "fingerprint": S.optional(S.NullOr(S.String)).annotations({ description: 'Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).' }), + "ttl_secs": S.optional(S.NullOr(S.Number.pipe(S.greaterThanOrEqualTo(armNativePairingBodyTtlSecsMin)))).annotations({ description: 'Window length in seconds (default 120; clamped to 15–600).' }) +}).annotations({ description: 'Arm-native-pairing request body.' }) + +export const armNativePairingResponseExpiresInSecsMin = 0; + +export const armNativePairingResponsePairedClientsMin = 0; + + + +export const ArmNativePairingResponse = S.Struct({ + "armed": S.Boolean.annotations({ description: 'True while a pairing window is open.' }), + "enabled": S.Boolean.annotations({ description: 'Whether the native host is running (the unified host started with `--native`).' }), + "expires_in_secs": S.optional(S.NullOr(S.Number.pipe(S.greaterThanOrEqualTo(armNativePairingResponseExpiresInSecsMin)))).annotations({ description: 'Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag).' }), + "paired_clients": S.Number.pipe(S.greaterThanOrEqualTo(armNativePairingResponsePairedClientsMin)).annotations({ description: 'Number of paired native clients.' }), + "pin": S.optional(S.NullOr(S.String)).annotations({ description: 'The PIN to display while armed (null when disarmed).' }) +}).annotations({ description: 'Native (punktfunk\/1) pairing status. Unlike GameStream, the \*\*host\*\* mints the PIN (the SPAKE2\nceremony needs it client-side first), so the console \*\*displays\*\* `pin` for the user to enter on\ntheir device — armed on demand for a short window.' }) + + +/** + * Unpaired devices that tried to connect while the host requires pairing. Approve one to pair + * it without a PIN (delegated approval); entries expire after ~10 minutes. + * @summary List devices awaiting pairing approval + */ +export const listPendingDevicesResponseAgeSecsMin = 0; + +export const listPendingDevicesResponseIdMin = 0; + + + +export const ListPendingDevicesResponseItem = S.Struct({ + "age_secs": S.Number.pipe(S.greaterThanOrEqualTo(listPendingDevicesResponseAgeSecsMin)).annotations({ description: 'Seconds since the device last knocked.' }), + "fingerprint": S.String.annotations({ description: 'Hex SHA-256 of the device\'s certificate — what approval pins.' }), + "id": S.Number.pipe(S.greaterThanOrEqualTo(listPendingDevicesResponseIdMin)).annotations({ description: 'Id to address approve\/deny (per-process; entries expire after ~10 minutes).' }), + "name": S.String.annotations({ description: 'Best-effort device label (the client\'s own name, else fingerprint-derived).' }) +}).annotations({ description: 'An unpaired device that tried to connect while the host requires pairing — awaiting\n\*\*delegated approval\*\* (approve it here instead of fetching the host PIN out of band).' }) +export const ListPendingDevicesResponse = S.Array(ListPendingDevicesResponseItem) + + +/** + * Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally + * relabel it via the body; send `{}` to keep the name it knocked with. + * @summary Approve a pending device + */ +export const approvePendingDevicePathIdMin = 0; + + + +export const ApprovePendingDeviceParams = S.Struct({ + "id": S.Number.pipe(S.greaterThanOrEqualTo(approvePendingDevicePathIdMin)).annotations({ description: 'Pending-request id from the pending list' }) +}) + +export const ApprovePendingDeviceBody = S.Struct({ + "name": S.optional(S.NullOr(S.String)).annotations({ description: 'Operator-chosen label for the device (defaults to the name it knocked with).' }) +}).annotations({ description: 'Approve-pending-device request body. Send `{}` to keep the device\'s own name.' }) + +export const ApprovePendingDeviceResponse = S.Struct({ + "fingerprint": S.String.annotations({ description: 'Hex SHA-256 of the client certificate — its stable id here.' }), + "name": S.String.annotations({ description: 'The name the client supplied when pairing.' }) +}).annotations({ description: 'A paired native (punktfunk\/1) client.' }) + + +/** + * Drops the request. Not a blocklist — the device's next attempt knocks again. + * @summary Deny a pending device + */ +export const denyPendingDevicePathIdMin = 0; + + + +export const DenyPendingDeviceParams = S.Struct({ + "id": S.Number.pipe(S.greaterThanOrEqualTo(denyPendingDevicePathIdMin)).annotations({ description: 'Pending-request id from the pending list' }) +}) + + +/** + * Poll this to know when to prompt the user for the PIN Moonlight displays. + * @summary Pairing-flow status + */ +export const GetPairingStatusResponse = S.Struct({ + "pin_pending": S.Boolean.annotations({ description: 'True while a pairing handshake is parked waiting for the user\'s PIN.' }) +}).annotations({ description: 'Pairing-flow status.' }) + + +/** + * Delivers the PIN the Moonlight client is displaying, completing the out-of-band half + * of the pairing handshake. + * @summary Submit the pairing PIN + */ +export const SubmitPairingPinBody = S.Struct({ + "pin": S.String.annotations({ description: '1–16 ASCII digits (Moonlight shows 4).' }) +}).annotations({ description: 'The PIN Moonlight displays during pairing.' }) + + +/** + * The full sample time-series of the capture currently recording, for live graphing. `404` when + * nothing is armed. + * @summary Live in-progress capture + */ +export const statsCaptureLiveResponseMetaDurationMsMin = 0; + +export const statsCaptureLiveResponseMetaFpsMin = 0; + +export const statsCaptureLiveResponseMetaHeightMin = 0; + +export const statsCaptureLiveResponseMetaSampleCountMin = 0; + +export const statsCaptureLiveResponseMetaStartedUnixMsMin = 0; + +export const statsCaptureLiveResponseMetaWidthMin = 0; + +export const statsCaptureLiveResponseSamplesItemBitrateKbpsMin = 0; + +export const statsCaptureLiveResponseSamplesItemFecRecoveredMin = 0; + +export const statsCaptureLiveResponseSamplesItemFramesDroppedMin = 0; + +export const statsCaptureLiveResponseSamplesItemPacketsDroppedMin = 0; + +export const statsCaptureLiveResponseSamplesItemSendDroppedMin = 0; + +export const statsCaptureLiveResponseSamplesItemSessionIdMin = 0; + +export const statsCaptureLiveResponseSamplesItemTMsMin = 0; + + + +export const StatsCaptureLiveResponse = S.Struct({ + "meta": S.Struct({ + "client": S.String.annotations({ description: 'Short label \/ fingerprint prefix, or `\"\"` if unknown.' }), + "codec": S.String.annotations({ description: '`\"h264\" | \"hevc\" | \"av1\"`.' }), + "duration_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseMetaDurationMsMin)), + "fps": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseMetaFpsMin)), + "height": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseMetaHeightMin)), + "id": S.String.annotations({ description: 'e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem.' }), + "kind": S.String.annotations({ description: '`\"native\" | \"gamestream\"`.' }), + "sample_count": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseMetaSampleCountMin)), + "started_unix_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseMetaStartedUnixMsMin)), + "width": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseMetaWidthMin)) +}).annotations({ 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`].' }), + "samples": S.Array(S.Struct({ + "bitrate_kbps": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseSamplesItemBitrateKbpsMin)).annotations({ description: 'Configured target bitrate.' }), + "fec_recovered": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseSamplesItemFecRecoveredMin)).annotations({ description: 'FEC shards recovered this window (delta).' }), + "fps": S.Number.annotations({ description: 'Genuine NEW frames\/s from the source.' }), + "frames_dropped": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseSamplesItemFramesDroppedMin)).annotations({ description: 'Frames dropped this window (delta).' }), + "mbps": S.Number.annotations({ description: 'Transmit goodput (Mb\/s).' }), + "packets_dropped": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseSamplesItemPacketsDroppedMin)).annotations({ description: 'Packets dropped this window (receiver-side \/ reassembler, where known).' }), + "repeat_fps": S.Number.annotations({ description: 'Re-encoded holds\/s (source-starvation indicator).' }), + "send_dropped": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseSamplesItemSendDroppedMin)).annotations({ description: 'Host send-buffer overflow \/ EAGAIN this window (delta).' }), + "session_id": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseSamplesItemSessionIdMin)).annotations({ description: 'Disambiguates concurrent sessions (usually constant).' }), + "stages": S.Array(S.Struct({ + "name": S.String.annotations({ description: '`\"capture\" | \"submit\" | \"encode\" | \"packetize\" | \"send\"` (path-dependent).' }), + "p50_us": S.Number, + "p99_us": S.Number +}).annotations({ description: 'One pipeline stage\'s latency in an aggregation window (microseconds).' })).annotations({ description: 'Ordered pipeline stages for this path.' }), + "t_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureLiveResponseSamplesItemTMsMin)).annotations({ description: 'Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).' }) +}).annotations({ description: 'One aggregated sample (~ every 2 s native, ~ every 1 s GameStream).' })) +}).annotations({ description: 'A full capture: summary + the sample time-series. The wire + on-disk shape.' }) + + +/** + * Arms a new performance-stats capture. Idempotent: if a capture is already running this returns + * the current status unchanged. While armed, the streaming loops emit aggregated samples (~ every + * 1–2 s) into the in-progress capture, readable live via `GET /stats/capture/live`. + * @summary Start a stats capture + */ +export const statsCaptureStartResponseElapsedMsMin = 0; + +export const statsCaptureStartResponseSampleCountMin = 0; + +export const statsCaptureStartResponseStartedUnixMsMin = 0; + + + +export const StatsCaptureStartResponse = S.Struct({ + "armed": S.Boolean.annotations({ description: 'Capture currently running.' }), + "elapsed_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStartResponseElapsedMsMin)).annotations({ 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.' }), + "kind": S.String.annotations({ description: 'Path of the in-progress capture (`\"\"` if idle).' }), + "sample_count": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStartResponseSampleCountMin)).annotations({ description: 'Samples in the in-progress capture.' }), + "started_unix_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStartResponseStartedUnixMsMin)).annotations({ description: 'Unix start time of the in-progress capture (`0` if idle).' }) +}).annotations({ description: 'Snapshot of the in-progress capture for the management API.' }) + + +/** + * Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to + * drive the capture-control UI. + * @summary Stats capture status + */ +export const statsCaptureStatusResponseElapsedMsMin = 0; + +export const statsCaptureStatusResponseSampleCountMin = 0; + +export const statsCaptureStatusResponseStartedUnixMsMin = 0; + + + +export const StatsCaptureStatusResponse = S.Struct({ + "armed": S.Boolean.annotations({ description: 'Capture currently running.' }), + "elapsed_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStatusResponseElapsedMsMin)).annotations({ 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.' }), + "kind": S.String.annotations({ description: 'Path of the in-progress capture (`\"\"` if idle).' }), + "sample_count": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStatusResponseSampleCountMin)).annotations({ description: 'Samples in the in-progress capture.' }), + "started_unix_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStatusResponseStartedUnixMsMin)).annotations({ description: 'Unix start time of the in-progress capture (`0` if idle).' }) +}).annotations({ description: 'Snapshot of the in-progress capture for the management API.' }) + + +/** + * Disarms the in-progress capture and writes it to disk atomically, returning its summary. If + * nothing was recording, returns `204 No Content`. + * @summary Stop the stats capture + */ +export const statsCaptureStopResponseDurationMsMin = 0; + +export const statsCaptureStopResponseFpsMin = 0; + +export const statsCaptureStopResponseHeightMin = 0; + +export const statsCaptureStopResponseSampleCountMin = 0; + +export const statsCaptureStopResponseStartedUnixMsMin = 0; + +export const statsCaptureStopResponseWidthMin = 0; + + + +export const StatsCaptureStopResponse = S.Struct({ + "client": S.String.annotations({ description: 'Short label \/ fingerprint prefix, or `\"\"` if unknown.' }), + "codec": S.String.annotations({ description: '`\"h264\" | \"hevc\" | \"av1\"`.' }), + "duration_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStopResponseDurationMsMin)), + "fps": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStopResponseFpsMin)), + "height": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStopResponseHeightMin)), + "id": S.String.annotations({ description: 'e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem.' }), + "kind": S.String.annotations({ description: '`\"native\" | \"gamestream\"`.' }), + "sample_count": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStopResponseSampleCountMin)), + "started_unix_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStopResponseStartedUnixMsMin)), + "width": S.Number.pipe(S.greaterThanOrEqualTo(statsCaptureStopResponseWidthMin)) +}).annotations({ 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`].' }) + + +/** + * Every saved capture's summary (the `meta` head only — not the sample body), newest first. + * @summary List saved recordings + */ +export const statsRecordingsListResponseDurationMsMin = 0; + +export const statsRecordingsListResponseFpsMin = 0; + +export const statsRecordingsListResponseHeightMin = 0; + +export const statsRecordingsListResponseSampleCountMin = 0; + +export const statsRecordingsListResponseStartedUnixMsMin = 0; + +export const statsRecordingsListResponseWidthMin = 0; + + + +export const StatsRecordingsListResponseItem = S.Struct({ + "client": S.String.annotations({ description: 'Short label \/ fingerprint prefix, or `\"\"` if unknown.' }), + "codec": S.String.annotations({ description: '`\"h264\" | \"hevc\" | \"av1\"`.' }), + "duration_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingsListResponseDurationMsMin)), + "fps": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingsListResponseFpsMin)), + "height": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingsListResponseHeightMin)), + "id": S.String.annotations({ description: 'e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem.' }), + "kind": S.String.annotations({ description: '`\"native\" | \"gamestream\"`.' }), + "sample_count": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingsListResponseSampleCountMin)), + "started_unix_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingsListResponseStartedUnixMsMin)), + "width": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingsListResponseWidthMin)) +}).annotations({ 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 const StatsRecordingsListResponse = S.Array(StatsRecordingsListResponseItem) + + +/** + * The full capture (meta + samples) for `id`, for graphing or download. + * @summary Get a saved recording + */ +export const StatsRecordingGetParams = S.Struct({ + "id": S.String.annotations({ description: 'The recording id (its filename stem)' }) +}) + +export const statsRecordingGetResponseMetaDurationMsMin = 0; + +export const statsRecordingGetResponseMetaFpsMin = 0; + +export const statsRecordingGetResponseMetaHeightMin = 0; + +export const statsRecordingGetResponseMetaSampleCountMin = 0; + +export const statsRecordingGetResponseMetaStartedUnixMsMin = 0; + +export const statsRecordingGetResponseMetaWidthMin = 0; + +export const statsRecordingGetResponseSamplesItemBitrateKbpsMin = 0; + +export const statsRecordingGetResponseSamplesItemFecRecoveredMin = 0; + +export const statsRecordingGetResponseSamplesItemFramesDroppedMin = 0; + +export const statsRecordingGetResponseSamplesItemPacketsDroppedMin = 0; + +export const statsRecordingGetResponseSamplesItemSendDroppedMin = 0; + +export const statsRecordingGetResponseSamplesItemSessionIdMin = 0; + +export const statsRecordingGetResponseSamplesItemTMsMin = 0; + + + +export const StatsRecordingGetResponse = S.Struct({ + "meta": S.Struct({ + "client": S.String.annotations({ description: 'Short label \/ fingerprint prefix, or `\"\"` if unknown.' }), + "codec": S.String.annotations({ description: '`\"h264\" | \"hevc\" | \"av1\"`.' }), + "duration_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseMetaDurationMsMin)), + "fps": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseMetaFpsMin)), + "height": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseMetaHeightMin)), + "id": S.String.annotations({ description: 'e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem.' }), + "kind": S.String.annotations({ description: '`\"native\" | \"gamestream\"`.' }), + "sample_count": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseMetaSampleCountMin)), + "started_unix_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseMetaStartedUnixMsMin)), + "width": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseMetaWidthMin)) +}).annotations({ 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`].' }), + "samples": S.Array(S.Struct({ + "bitrate_kbps": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseSamplesItemBitrateKbpsMin)).annotations({ description: 'Configured target bitrate.' }), + "fec_recovered": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseSamplesItemFecRecoveredMin)).annotations({ description: 'FEC shards recovered this window (delta).' }), + "fps": S.Number.annotations({ description: 'Genuine NEW frames\/s from the source.' }), + "frames_dropped": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseSamplesItemFramesDroppedMin)).annotations({ description: 'Frames dropped this window (delta).' }), + "mbps": S.Number.annotations({ description: 'Transmit goodput (Mb\/s).' }), + "packets_dropped": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseSamplesItemPacketsDroppedMin)).annotations({ description: 'Packets dropped this window (receiver-side \/ reassembler, where known).' }), + "repeat_fps": S.Number.annotations({ description: 'Re-encoded holds\/s (source-starvation indicator).' }), + "send_dropped": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseSamplesItemSendDroppedMin)).annotations({ description: 'Host send-buffer overflow \/ EAGAIN this window (delta).' }), + "session_id": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseSamplesItemSessionIdMin)).annotations({ description: 'Disambiguates concurrent sessions (usually constant).' }), + "stages": S.Array(S.Struct({ + "name": S.String.annotations({ description: '`\"capture\" | \"submit\" | \"encode\" | \"packetize\" | \"send\"` (path-dependent).' }), + "p50_us": S.Number, + "p99_us": S.Number +}).annotations({ description: 'One pipeline stage\'s latency in an aggregation window (microseconds).' })).annotations({ description: 'Ordered pipeline stages for this path.' }), + "t_ms": S.Number.pipe(S.greaterThanOrEqualTo(statsRecordingGetResponseSamplesItemTMsMin)).annotations({ description: 'Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).' }) +}).annotations({ description: 'One aggregated sample (~ every 2 s native, ~ every 1 s GameStream).' })) +}).annotations({ description: 'A full capture: summary + the sample time-series. The wire + on-disk shape.' }) + + +/** + * Removes the recording `id` from disk. `404` if there is no such recording. + * @summary Delete a saved recording + */ +export const StatsRecordingDeleteParams = S.Struct({ + "id": S.String.annotations({ description: 'The recording id (its filename stem)' }) +}) + + +/** + * @summary Live host status + */ +export const getStatusResponseActiveSessionsMin = 0; + +export const getStatusResponsePairedClientsMin = 0; + +export const getStatusResponseSessionTwoFpsMin = 0; + +export const getStatusResponseSessionTwoHeightMin = 0; + +export const getStatusResponseSessionTwoWidthMin = 0; + +export const getStatusResponseStreamTwoBitrateKbpsMin = 0; + +export const getStatusResponseStreamTwoFpsMin = 0; + +export const getStatusResponseStreamTwoHeightMin = 0; + +export const getStatusResponseStreamTwoMinFecMin = 0; + +export const getStatusResponseStreamTwoPacketSizeMin = 0; + +export const getStatusResponseStreamTwoWidthMin = 0; + + + +export const GetStatusResponse = S.Struct({ + "active_sessions": S.Number.pipe(S.greaterThanOrEqualTo(getStatusResponseActiveSessionsMin)).annotations({ 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.' }), + "audio_streaming": S.Boolean.annotations({ description: 'True while the audio stream thread is running.' }), + "paired_clients": S.Number.pipe(S.greaterThanOrEqualTo(getStatusResponsePairedClientsMin)).annotations({ description: 'Number of pinned (paired) client certificates.' }), + "pin_pending": S.Boolean.annotations({ description: 'True while a pairing handshake is parked waiting for the user\'s PIN\n(submit it via `POST \/api\/v1\/pair\/pin`).' }), + "session": S.optional(S.Union(S.Null, S.Struct({ + "fps": S.Number.pipe(S.greaterThanOrEqualTo(getStatusResponseSessionTwoFpsMin)), + "height": S.Number.pipe(S.greaterThanOrEqualTo(getStatusResponseSessionTwoHeightMin)), + "width": S.Number.pipe(S.greaterThanOrEqualTo(getStatusResponseSessionTwoWidthMin)) +}).annotations({ description: 'A representative active session. GameStream\'s launch (Moonlight `\/launch`) when present, else\nthe first live native session. `null` when nothing is streaming.' }))), + "stream": S.optional(S.Union(S.Null, S.Struct({ + "bitrate_kbps": S.Number.pipe(S.greaterThanOrEqualTo(getStatusResponseStreamTwoBitrateKbpsMin)), + "codec": S.Literal('h264', 'hevc', 'av1', 'pyrowave').annotations({ description: 'Video codec identifier. The wire token matches the codec\'s canonical name used across the\nstack (SDP\/GameStream advertisement, the stats-capture `CaptureMeta.codec`, and the encoder\'s\n[`Codec::label`]) — notably `H.265` serializes as `\"hevc\"`, not `\"h265\"`, so the same codec\nreads identically on every console page.' }), + "fps": S.Number.pipe(S.greaterThanOrEqualTo(getStatusResponseStreamTwoFpsMin)), + "height": S.Number.pipe(S.greaterThanOrEqualTo(getStatusResponseStreamTwoHeightMin)), + "min_fec": S.Number.pipe(S.greaterThanOrEqualTo(getStatusResponseStreamTwoMinFecMin)).annotations({ description: 'Client\'s parity floor per FEC block (`minRequiredFecPackets`).' }), + "packet_size": S.Number.pipe(S.greaterThanOrEqualTo(getStatusResponseStreamTwoPacketSizeMin)).annotations({ description: 'Video payload size per packet (bytes).' }), + "width": S.Number.pipe(S.greaterThanOrEqualTo(getStatusResponseStreamTwoWidthMin)) +}).annotations({ description: 'The active stream\'s parameters — RTSP-negotiated for GameStream, or the live native session\'s\nmode\/codec\/bitrate. `null` when nothing is streaming.' }))), + "video_streaming": S.Boolean.annotations({ description: 'True while the video stream thread is running.' }) +}).annotations({ description: 'Live host status (changes as clients launch\/end sessions).' }) diff --git a/sdk/src/index.ts b/sdk/src/index.ts new file mode 100644 index 00000000..8380f432 --- /dev/null +++ b/sdk/src/index.ts @@ -0,0 +1,182 @@ +// `@punktfunk/host` — the Promise facade, the package's front door (RFC §7): `connect()`, +// `await`, `.on()` — a thin veneer over the same core the Effect surface uses (config +// resolution, one HTTP implementation, one reconnecting SSE source). Effect is the substrate +// and the power surface (`@punktfunk/host/effect`), never a prerequisite: +// +// import { connect } from "@punktfunk/host"; +// +// const pf = await connect(); +// pf.events.on("pairing.pending", async (e) => { +// await notifyPhone(`Pairing request from ${e.device.name}`); +// }); +import type { Effect } from "effect"; +import type { PunktfunkHost } from "./client.js"; +import { + type ConnectOptions, + type ResolvedConfig, + resolveConfig, +} from "./config.js"; +import { HttpStatusError, httpRequest } from "./core.js"; +import { type SseFrame, sseFrames } from "./sse.js"; +import { + decodeHostEvent, + type EventOf, + type HostEvent, + type HostEventKind, + kindMatches, +} from "./wire.js"; + +export { HttpStatusError } from "./core.js"; +export type { ConnectOptions } from "./config.js"; +export type { + ClientRef, + DeviceRef, + DisconnectReason, + EventOf, + HostEvent, + HostEventKind, + Plane, + SessionRef, + StreamRef, +} from "./wire.js"; + +/** `on()` also accepts these beyond the typed kinds. */ +type SpecialPattern = "*" | "dropped" | "unknown" | (string & {}); + +interface Listener { + pattern: string; + cb: (ev: never) => unknown; +} + +export interface PunktfunkEvents { + /** + * Subscribe to lifecycle events. `pattern` is an exact kind (`"stream.started"` — the + * callback is typed to it), a `domain.*` prefix, `"*"` (every known event), `"dropped"` + * (the fell-off-the-ring marker), or `"unknown"` (kinds this SDK doesn't know — a newer + * host). Returns the unsubscribe function. Callback errors are caught and warned, never + * fatal to the stream. + */ + on( + pattern: K, + cb: (ev: EventOf) => unknown, + ): () => void; + on(pattern: SpecialPattern, cb: (ev: HostEvent) => unknown): () => void; +} + +export interface Punktfunk { + /** Resolved connection (URL/token/CA). */ + readonly config: ResolvedConfig; + /** One management-API request under `/api/v1` (`request("GET", "/status")`). */ + request(method: string, path: string, body?: unknown): Promise; + /** The lifecycle-event subscription surface. */ + readonly events: PunktfunkEvents; + /** Stop the event stream and release the connection. */ + close(): void; +} + +/** + * Connect to the host's management API: resolves the URL, bearer token, and the host's + * self-signed identity cert from the environment/host files (zero config on the host box), + * verifies the credentials with a `/health`-adjacent probe, and returns the client. + */ +export const connect = async (options?: ConnectOptions): Promise => { + const cfg = await resolveConfig(options); + // Fail fast on bad credentials/URL: one cheap authenticated probe. + await httpRequest(cfg, "GET", "/host"); + + const listeners = new Set(); + let pump: AsyncGenerator | undefined; + let closed = false; + + const warn = (m: string) => console.warn(`[punktfunk] ${m}`); + const dispatch = (pattern: string, ev: unknown) => { + for (const l of listeners) { + if (l.pattern === pattern || (pattern !== "dropped" && pattern !== "unknown" && (l.pattern === "*" || kindMatches(l.pattern, pattern)))) { + try { + (l.cb as (e: unknown) => unknown)(ev); + } catch (e) { + warn(`listener for "${l.pattern}" threw: ${e}`); + } + } + } + }; + const startPump = () => { + if (pump || closed) return; + pump = sseFrames(cfg, { onWarning: warn }); + void (async () => { + try { + for await (const frame of pump) { + if (closed) break; + if (frame.event === "dropped") { + dispatch("dropped", frame); + continue; + } + let json: unknown; + try { + json = JSON.parse(frame.data); + } catch { + continue; + } + const decoded = decodeHostEvent(json); + if (decoded._tag === "Left") { + dispatch("unknown", json); + continue; + } + dispatch(decoded.right.kind, decoded.right); + } + } catch (e) { + if (!closed) warn(`event stream stopped: ${e}`); + } + })(); + }; + + return { + config: cfg, + request: (method, path, body) => httpRequest(cfg, method, path, body), + events: { + on(pattern: string, cb: (ev: never) => unknown) { + const l: Listener = { pattern, cb }; + listeners.add(l); + startPump(); + return () => listeners.delete(l); + }, + } as PunktfunkEvents, + close() { + closed = true; + pump?.return(undefined).catch(() => {}); + pump = undefined; + }, + }; +}; + +// ------------------------------------------------------------------- plugins (RFC §8) + +/** + * A plugin's `main`: either a plain async function receiving the connected client, or an + * Effect requiring the `PunktfunkHost` service (`@punktfunk/host/effect`) — the shape that + * makes it well-behaved under the managed runner's supervision (structured interruption, + * scoped finalizers). + */ +export type PluginMain = + | ((pf: Punktfunk) => Promise | unknown) + | Effect.Effect; + +export interface PluginDef { + /** Package-convention name (`punktfunk-plugin-*` drops the prefix): kebab-case. */ + name: string; + main: PluginMain; +} + +/** + * Declare a plugin (the `punktfunk-plugin-*` convention, RFC §8). In v1 a plugin is a script + * the operator runs; the managed runner (a later, optional package) discovers this default + * export and supervises `main`. + */ +export const definePlugin = (def: PluginDef): PluginDef => { + if (!/^[a-z][a-z0-9-]*$/.test(def.name)) { + throw new Error( + `plugin name "${def.name}" must be kebab-case ([a-z][a-z0-9-]*)`, + ); + } + return def; +}; diff --git a/sdk/src/sse.ts b/sdk/src/sse.ts new file mode 100644 index 00000000..14371d2b --- /dev/null +++ b/sdk/src/sse.ts @@ -0,0 +1,172 @@ +// The SSE half of the SDK: a spec-shaped parser plus one shared reconnecting frame source both +// surfaces consume — the Effect `Stream` wraps it, the Promise facade iterates it directly, so +// there is exactly one implementation of connect/parse/resume (RFC §7: "two surfaces, one +// core"). Reconnects carry `Last-Event-ID` (the host replays from its ring); backoff is +// exponential + jittered, capped, and resets after a healthy connection. +import type { ResolvedConfig } from "./config.js"; + +/** One parsed SSE frame. */ +export interface SseFrame { + /** The `event:` name — an event kind, or `dropped`, or `message` when absent. */ + event: string; + /** The joined `data:` payload. */ + data: string; + /** The `id:` field (the host sets it to the event's `seq`). */ + id?: string; +} + +/** Incremental SSE parser (the WHATWG dispatch rules the host's frames need). */ +export class SseParser { + private buf = ""; + private event = ""; + private data: string[] = []; + private id: string | undefined; + + /** Feed a chunk; returns the frames it completed. */ + push(chunk: string): SseFrame[] { + this.buf += chunk; + const frames: SseFrame[] = []; + for (;;) { + const nl = this.buf.indexOf("\n"); + if (nl < 0) break; + let line = this.buf.slice(0, nl); + this.buf = this.buf.slice(nl + 1); + if (line.endsWith("\r")) line = line.slice(0, -1); + if (line.length === 0) { + // Blank line = dispatch. + if (this.data.length > 0 || this.event.length > 0) { + frames.push({ + event: this.event.length > 0 ? this.event : "message", + data: this.data.join("\n"), + id: this.id, + }); + } + this.event = ""; + this.data = []; + continue; + } + if (line.startsWith(":")) continue; // comment (the host's keep-alives) + const colon = line.indexOf(":"); + const field = colon < 0 ? line : line.slice(0, colon); + let value = colon < 0 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + switch (field) { + case "event": + this.event = value; + break; + case "data": + this.data.push(value); + break; + case "id": + this.id = value; + break; + default: // unknown fields are ignored per spec (retry: handled by our own backoff) + } + } + return frames; + } +} + +export interface EventStreamOptions { + /** + * Resume cursor (`?since=`); reconnects use the newer `Last-Event-ID` automatically. + * **Omitted = live tail only** (events from now on — a fresh notify script must not + * re-fire on the host's replayed history); `0` = replay the host's full ring first; + * `N` = resume after seq N. + */ + since?: number; + /** Server-side kind filter (`["stream.*", "pairing.pending"]`). */ + kinds?: string[]; + /** Called on transient trouble (reconnects, decode warnings). Default: console.warn. */ + onWarning?: (message: string) => void; +} + +/** + * The "live tail only" cursor: beyond any realistic seq, so the host's catch-up is empty and + * (being > 0 yet ≥ the ring head) it never trips the `dropped` marker. The first received + * frame's real id replaces it for reconnects. + */ +const LIVE_ONLY_CURSOR = Number.MAX_SAFE_INTEGER; + +/** Thrown when the stream cannot ever work (bad credentials) — retrying would loop 401s. */ +export class SseAuthError extends Error { + readonly _tag = "SseAuthError"; +} + +const BACKOFF_INITIAL_MS = 500; +const BACKOFF_CAP_MS = 15_000; +/** A connection that lived this long resets the backoff (it was healthy, not flapping). */ +const HEALTHY_MS = 30_000; + +/** + * The shared frame source: connect → parse → yield frames, forever — reconnecting with + * `Last-Event-ID` on any hiccup. Ends only by consumer break/return (both surfaces cancel by + * dropping the iterator, which aborts the in-flight request) or throws [`SseAuthError`]. + */ +export async function* sseFrames( + cfg: ResolvedConfig, + opts: EventStreamOptions = {}, +): AsyncGenerator { + const warn = opts.onWarning ?? ((m) => console.warn(`[punktfunk] ${m}`)); + let lastId: number = opts.since ?? LIVE_ONLY_CURSOR; + let backoff = BACKOFF_INITIAL_MS; + const abort = new AbortController(); + try { + for (;;) { + const url = new URL(`${cfg.url}/api/v1/events`); + if (opts.kinds && opts.kinds.length > 0) + url.searchParams.set("kinds", opts.kinds.join(",")); + const headers: Record = { + authorization: `Bearer ${cfg.token}`, + accept: "text/event-stream", + }; + if (lastId !== 0) headers["last-event-id"] = String(lastId); + + const connectedAt = Date.now(); + try { + const resp = await cfg.fetch(url, { + headers, + signal: abort.signal, + }); + if (resp.status === 401) throw new SseAuthError("invalid credentials"); + if (!resp.ok || !resp.body) + throw new Error(`event stream HTTP ${resp.status}`); + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + const parser = new SseParser(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; // server closed (slow-consumer cut / shutdown) → reconnect + for (const frame of parser.push( + decoder.decode(value, { stream: true }), + )) { + if (frame.id !== undefined) { + const id = Number(frame.id); + if (Number.isFinite(id)) lastId = id; + } + yield frame; + } + } + } finally { + reader.cancel().catch(() => {}); + } + throw new Error("event stream ended"); + } catch (e) { + if (e instanceof SseAuthError) throw e; + if (abort.signal.aborted) return; + if (Date.now() - connectedAt >= HEALTHY_MS) backoff = BACKOFF_INITIAL_MS; + const jittered = backoff * (0.5 + Math.random()); + warn( + `event stream reconnecting in ${Math.round(jittered)}ms (${ + e instanceof Error ? e.message : String(e) + })`, + ); + await new Promise((r) => setTimeout(r, jittered)); + backoff = Math.min(backoff * 2, BACKOFF_CAP_MS); + } + } + } finally { + abort.abort(); // consumer went away — tear down any in-flight request + } +} diff --git a/sdk/src/wire.ts b/sdk/src/wire.ts new file mode 100644 index 00000000..79df7530 --- /dev/null +++ b/sdk/src/wire.ts @@ -0,0 +1,160 @@ +// The lifecycle-event wire schemas (RFC §4/§7) — hand-written as a discriminated union on +// `kind` for precise types and decode errors. The REST surface is generated (./gen/schemas.ts); +// the event stream's `text/event-stream` payload is not expressible there, and the host's +// Rust-side JSON snapshot tests (crates/punktfunk-host/src/events.rs) are the wire's source of +// truth — this file mirrors them. Additive-only within `schema: 1`: decoding tolerates unknown +// keys (Effect's default), and an unknown `kind` surfaces on the raw channel, never a throw. +import { Schema as S } from "effect"; + +export const Plane = S.Literal("native", "gamestream"); +export type Plane = S.Schema.Type; + +export const DisconnectReason = S.Literal("quit", "timeout", "error"); +export type DisconnectReason = S.Schema.Type; + +export const ClientRef = S.Struct({ + name: S.String, + fingerprint: S.optional(S.String), + plane: Plane, +}); +export type ClientRef = S.Schema.Type; + +export const SessionRef = S.Struct({ + id: S.Number, + client: S.String, + mode: S.String, + hdr: S.Boolean, +}); +export type SessionRef = S.Schema.Type; + +export const StreamRef = S.Struct({ + mode: S.String, + hdr: S.Boolean, + client: S.String, + app: S.optional(S.String), + plane: Plane, +}); +export type StreamRef = S.Schema.Type; + +export const DeviceRef = S.Struct({ + name: S.String, + fingerprint: S.String, + plane: Plane, +}); +export type DeviceRef = S.Schema.Type; + +/** The `{seq, ts_ms, schema}` envelope every event carries. */ +const envelope = { + seq: S.Number, + ts_ms: S.Number, + schema: S.Number, +} as const; + +export const ClientConnected = S.Struct({ + ...envelope, + kind: S.Literal("client.connected"), + client: ClientRef, +}); +export const ClientDisconnected = S.Struct({ + ...envelope, + kind: S.Literal("client.disconnected"), + client: ClientRef, + reason: DisconnectReason, +}); +export const SessionStarted = S.Struct({ + ...envelope, + kind: S.Literal("session.started"), + session: SessionRef, +}); +export const SessionEnded = S.Struct({ + ...envelope, + kind: S.Literal("session.ended"), + session: SessionRef, +}); +export const StreamStarted = S.Struct({ + ...envelope, + kind: S.Literal("stream.started"), + stream: StreamRef, +}); +export const StreamStopped = S.Struct({ + ...envelope, + kind: S.Literal("stream.stopped"), + stream: StreamRef, +}); +export const PairingPending = S.Struct({ + ...envelope, + kind: S.Literal("pairing.pending"), + device: DeviceRef, +}); +export const PairingCompleted = S.Struct({ + ...envelope, + kind: S.Literal("pairing.completed"), + device: DeviceRef, +}); +export const PairingDenied = S.Struct({ + ...envelope, + kind: S.Literal("pairing.denied"), + device: DeviceRef, +}); +export const DisplayCreated = S.Struct({ + ...envelope, + kind: S.Literal("display.created"), + backend: S.String, + mode: S.String, +}); +export const DisplayReleased = S.Struct({ + ...envelope, + kind: S.Literal("display.released"), + count: S.Number, +}); +export const LibraryChanged = S.Struct({ + ...envelope, + kind: S.Literal("library.changed"), + source: S.String, +}); +export const HostStarted = S.Struct({ + ...envelope, + kind: S.Literal("host.started"), + version: S.String, + gamestream: S.Boolean, +}); +export const HostStopping = S.Struct({ + ...envelope, + kind: S.Literal("host.stopping"), +}); + +/** Every known lifecycle event — discriminated on `kind`. */ +export const HostEvent = S.Union( + ClientConnected, + ClientDisconnected, + SessionStarted, + SessionEnded, + StreamStarted, + StreamStopped, + PairingPending, + PairingCompleted, + PairingDenied, + DisplayCreated, + DisplayReleased, + LibraryChanged, + HostStarted, + HostStopping, +); +export type HostEvent = S.Schema.Type; + +/** The known event kinds (for filters and the facade's `on()`). */ +export type HostEventKind = HostEvent["kind"]; + +/** Narrow a HostEvent by kind: `EventOf<"stream.started">`. */ +export type EventOf = Extract; + +export const decodeHostEvent = S.decodeUnknownEither(HostEvent); + +/** + * Does `pattern` select `kind`? Exact kinds (`stream.started`) or `domain.*` prefixes on the + * dot boundary — the same vocabulary as the host's SSE `?kinds=` filter and hooks `on:` field. + */ +export const kindMatches = (pattern: string, kind: string): boolean => + pattern.endsWith(".*") + ? kind.startsWith(pattern.slice(0, -1)) // "stream.*" → prefix "stream." + : pattern === kind; diff --git a/sdk/test/sse.test.ts b/sdk/test/sse.test.ts new file mode 100644 index 00000000..4b0737a2 --- /dev/null +++ b/sdk/test/sse.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { SseAuthError, SseParser, sseFrames } from "../src/sse.js"; +import type { ResolvedConfig } from "../src/config.js"; + +describe("SseParser", () => { + test("parses frames split across arbitrary chunks, skipping comments", () => { + const p = new SseParser(); + let frames = p.push("id: 4\nevent: library.ch"); + expect(frames.length).toBe(0); + frames = p.push('anged\ndata: {"seq":4}\n\n: keep-alive\n\nid: 5\n'); + expect(frames.length).toBe(1); + expect(frames[0]).toEqual({ event: "library.changed", data: '{"seq":4}', id: "4" }); + frames = p.push("data: x\n\n"); + expect(frames.length).toBe(1); + expect(frames[0]?.id).toBe("5"); + expect(frames[0]?.event).toBe("message"); + }); + + test("joins multi-line data and handles CRLF", () => { + const p = new SseParser(); + const frames = p.push("data: a\r\ndata: b\r\n\r\n"); + expect(frames[0]?.data).toBe("a\nb"); + }); +}); + +const cfgFor = (port: number, token = "t"): ResolvedConfig => ({ + url: `http://127.0.0.1:${port}`, + token, + fetch, +}); + +describe("sseFrames", () => { + test("reads frames, reconnects with Last-Event-ID after a server close", async () => { + const lastEventIds: Array = []; + let connection = 0; + const server = Bun.serve({ + port: 0, + fetch(req) { + lastEventIds.push(req.headers.get("last-event-id")); + connection += 1; + const first = connection === 1; + const body = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + if (first) { + controller.enqueue(enc.encode('id: 1\nevent: library.changed\ndata: {"seq":1}\n\n')); + controller.close(); // server closes → client must reconnect + } else { + controller.enqueue(enc.encode('id: 2\nevent: library.changed\ndata: {"seq":2}\n\n')); + // stay open + } + }, + }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + }, + }); + try { + const gen = sseFrames(cfgFor(server.port as number), { onWarning: () => {} }); + const f1 = await gen.next(); + expect(f1.value?.id).toBe("1"); + const f2 = await gen.next(); // spans the reconnect + expect(f2.value?.id).toBe("2"); + await gen.return(undefined); + // No `since` = live-tail-only: the first connect carries the beyond-tip cursor, + // the reconnect carries the last REAL id. + expect(lastEventIds[0]).toBe(String(Number.MAX_SAFE_INTEGER)); + expect(lastEventIds[1]).toBe("1"); + } finally { + server.stop(true); + } + }); + + test("401 is terminal (no retry loop)", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response("{}", { status: 401 }), + }); + try { + const gen = sseFrames(cfgFor(server.port as number), { onWarning: () => {} }); + await expect(gen.next()).rejects.toBeInstanceOf(SseAuthError); + } finally { + server.stop(true); + } + }); +}); diff --git a/sdk/test/surfaces.test.ts b/sdk/test/surfaces.test.ts new file mode 100644 index 00000000..9c637775 --- /dev/null +++ b/sdk/test/surfaces.test.ts @@ -0,0 +1,117 @@ +// Both surfaces against one mock host: the Promise facade end to end, and the Effect surface's +// request/get/events with typed errors. +import { describe, expect, test } from "bun:test"; +import { Effect, Schema as S, Stream } from "effect"; +import { connect } from "../src/index.js"; +import * as pf from "../src/effect.js"; + +const TOKEN = "test-token"; + +/** A minimal mock host: auth-checked /host, /status, and an SSE /events feed. */ +const mockHost = () => { + const emitted = new TextEncoder().encode( + 'id: 7\nevent: pairing.pending\ndata: {"seq":7,"ts_ms":3,"schema":1,"kind":"pairing.pending","device":{"name":"iPad Pro","fingerprint":"ab12","plane":"native"}}\n\n' + + 'id: 8\nevent: future.kind\ndata: {"seq":8,"ts_ms":4,"schema":1,"kind":"future.kind"}\n\n' + + 'id: 9\nevent: library.changed\ndata: {"seq":9,"ts_ms":5,"schema":1,"kind":"library.changed","source":"manual"}\n\n', + ); + return Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (req.headers.get("authorization") !== `Bearer ${TOKEN}`) { + return Response.json({ error: "missing or invalid credentials" }, { status: 401 }); + } + switch (url.pathname) { + case "/api/v1/host": + return Response.json({ name: "mock", version: "0.12.0" }); + case "/api/v1/status": + return Response.json({ ok: true, sessions: 0 }); + case "/api/v1/boom": + return Response.json({ error: "kaput" }, { status: 500 }); + case "/api/v1/events": { + const body = new ReadableStream({ + start(c) { + c.enqueue(emitted); // then stay open + }, + }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + } + default: + return Response.json({ error: "not found" }, { status: 404 }); + } + }, + }); +}; + +describe("promise facade", () => { + test("connect → request → typed events → unknown channel → close", async () => { + const server = mockHost(); + try { + const client = await connect({ url: `http://127.0.0.1:${server.port}`, token: TOKEN }); + const status = (await client.request("GET", "/status")) as { ok: boolean }; + expect(status.ok).toBe(true); + + const got: string[] = []; + const unknown: unknown[] = []; + const done = new Promise((resolve) => { + client.events.on("pairing.pending", (e) => { + got.push(`pending:${e.device.name}`); + }); + client.events.on("library.*", (e) => { + got.push(`lib:${e.kind}`); + resolve(); + }); + client.events.on("unknown", (e) => unknown.push(e)); + }); + await done; + expect(got).toEqual(["pending:iPad Pro", "lib:library.changed"]); + expect(unknown.length).toBe(1); + client.close(); + } finally { + server.stop(true); + } + }); + + test("connect fails fast on a bad token", async () => { + const server = mockHost(); + try { + await expect( + connect({ url: `http://127.0.0.1:${server.port}`, token: "wrong" }), + ).rejects.toThrow(/credentials/); + } finally { + server.stop(true); + } + }); +}); + +describe("effect surface", () => { + test("request + schema get + typed errors + event stream", async () => { + const server = mockHost(); + const live = pf.PunktfunkHostLive({ url: `http://127.0.0.1:${server.port}`, token: TOKEN }); + try { + const program = Effect.gen(function* () { + const status = yield* pf.get("/status", S.Struct({ ok: S.Boolean })); + expect(status.ok).toBe(true); + + // A wrong shape is VersionSkew, not undefined-later. + const skew = yield* pf + .get("/status", S.Struct({ nope: S.String })) + .pipe(Effect.flip); + expect(skew._tag).toBe("VersionSkew"); + + // A host error carries its ApiError envelope message. + const boom = yield* pf.request("GET", "/boom").pipe(Effect.flip); + expect(boom._tag).toBe("ApiError"); + if (boom._tag === "ApiError") expect(boom.message).toBe("kaput"); + + // The decoded stream skips the unknown kind and delivers the known ones. + const events = yield* pf.events().pipe(Stream.take(2), Stream.runCollect); + const kinds = [...events].map((e) => e.kind); + expect(kinds).toEqual(["pairing.pending", "library.changed"]); + }); + await Effect.runPromise(program.pipe(Effect.provide(live))); + } finally { + server.stop(true); + } + }); +}); diff --git a/sdk/test/wire.test.ts b/sdk/test/wire.test.ts new file mode 100644 index 00000000..5cd1d31a --- /dev/null +++ b/sdk/test/wire.test.ts @@ -0,0 +1,63 @@ +// The wire schemas must decode EXACTLY what the host emits — the JSON literals here are the +// Rust side's snapshot-test strings (crates/punktfunk-host/src/events.rs), the schema gate. +import { describe, expect, test } from "bun:test"; +import { decodeHostEvent, kindMatches } from "../src/wire.js"; + +describe("wire", () => { + test("decodes the host's snapshot frames", () => { + const stream = decodeHostEvent( + JSON.parse( + '{"seq":4182,"ts_ms":1700000000000,"schema":1,"kind":"stream.started","stream":{"mode":"3840x2160@120","hdr":true,"client":"Living Room TV","app":"steam:570","plane":"native"}}', + ), + ); + expect(stream._tag).toBe("Right"); + if (stream._tag === "Right" && stream.right.kind === "stream.started") { + expect(stream.right.stream.mode).toBe("3840x2160@120"); + expect(stream.right.stream.app).toBe("steam:570"); + } + + const disc = decodeHostEvent( + JSON.parse( + '{"seq":1,"ts_ms":1700000000000,"schema":1,"kind":"client.disconnected","client":{"name":"Deck","fingerprint":"b1c2","plane":"gamestream"},"reason":"timeout"}', + ), + ); + expect(disc._tag).toBe("Right"); + if (disc._tag === "Right" && disc.right.kind === "client.disconnected") { + expect(disc.right.reason).toBe("timeout"); + } + + const stopping = decodeHostEvent( + JSON.parse('{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}'), + ); + expect(stopping._tag).toBe("Right"); + }); + + test("tolerates unknown keys (additive-only wire)", () => { + const r = decodeHostEvent({ + seq: 9, + ts_ms: 1, + schema: 1, + kind: "library.changed", + source: "manual", + future_field: { anything: true }, + }); + expect(r._tag).toBe("Right"); + }); + + test("unknown kinds fail decode (they ride the raw channel)", () => { + const r = decodeHostEvent({ + seq: 9, + ts_ms: 1, + schema: 1, + kind: "totally.new", + }); + expect(r._tag).toBe("Left"); + }); + + test("kindMatches mirrors the host filter semantics", () => { + expect(kindMatches("stream.started", "stream.started")).toBe(true); + expect(kindMatches("stream.*", "stream.stopped")).toBe(true); + expect(kindMatches("stream.*", "streamx.started")).toBe(false); + expect(kindMatches("stream.started", "stream.stopped")).toBe(false); + }); +}); diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json new file mode 100644 index 00000000..3c44f397 --- /dev/null +++ b/sdk/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["bun"] + }, + "include": ["src", "test"] +}