e8acf922de
Overview (exporter health, Playnite version, counts, sync, live SSE activity, and a first-run panel that names the exact ingest path the .pext writes to), Library (covers + per-game include toggles + why-not-synced), Settings (filters, art delivery, sync cadence, paths). Data layer is AtomHttpApi atoms derived from the shared contract; the whole thing runs with zero host against an in-browser mock transport with four scenarios, and Storybook renders the real pages on the same fixtures. CI now asserts the production bundle carries no fixture strings. Also: CI rebuilt for the workspace layout (one root install, per-package typecheck, publish from plugin/ on a v* tag) with the exporter job and `publish: needs [build, exporter]` unchanged; README rewritten around the three workspaces, with the ingest inbox explained as the load-bearing mechanism it is.
184 lines
11 KiB
Markdown
184 lines
11 KiB
Markdown
# @punktfunk/plugin-playnite
|
|
|
|
Sync your **[Playnite](https://playnite.link)** library into the Punktfunk host game library. Every
|
|
store and emulator Playnite manages — Steam, GOG, Epic, Xbox, itch, RetroArch, standalone emulators,
|
|
manually-added games — shows up in Punktfunk's grid on every client, and launching a title hands it
|
|
straight back to Playnite, which performs the real launch. It ships a web UI that lives **inside the
|
|
Punktfunk console** (no second password, no second port).
|
|
|
|
It has two halves, both on the **same Windows box** as the Punktfunk host:
|
|
|
|
```
|
|
┌─────────────────────────────┐ ┌────────────────────────────────────────────┐
|
|
│ Playnite │ │ Punktfunk host (this plugin, in the │
|
|
│ └ Punktfunk Sync extension │ JSON │ scripting runner) │
|
|
│ writes │ ─────▶ │ reads punktfunk-library.json, maps it to │
|
|
│ punktfunk-library.json │ │ entries, PUT /library/provider/playnite │
|
|
└─────────────────────────────┘ └────────────────────────────────────────────┘
|
|
```
|
|
|
|
- **The Playnite exporter** (`exporter/`, C#) is a tiny [GenericPlugin](https://api.playnite.link/docs/tutorials/extensions/genericPlugins.html)
|
|
that writes your library to a JSON file whenever it changes. Playnite locks its library database
|
|
while running, so reading it from *inside* Playnite is the only robust way — this is that adapter,
|
|
and nothing more.
|
|
- **This plugin** (TypeScript, on [`@punktfunk/plugin-kit`](https://git.unom.io/unom/punktfunk/src/branch/main/plugin-kit))
|
|
watches that file and reconciles it into the host library as the `playnite` provider, with a
|
|
console-hosted web UI. Zero-config auth via the runner.
|
|
|
|
## Install
|
|
|
|
Two one-time steps.
|
|
|
|
### 1 · This plugin (on the host)
|
|
|
|
```powershell
|
|
cd "$(punktfunk config-dir)\plugins" # e.g. C:\ProgramData\punktfunk\plugins
|
|
# bunfig.toml points the @punktfunk scope at the registry (once)
|
|
bun add @punktfunk/plugin-playnite
|
|
Enable-ScheduledTask PunktfunkScripting # enable the scripting runner if it isn't already
|
|
```
|
|
|
|
Open the Punktfunk console — a **Playnite** entry appears in the nav. It will say it's waiting for
|
|
Playnite until you do step 2.
|
|
|
|
### 2 · The Punktfunk Sync extension (in Playnite)
|
|
|
|
Download **`punktfunk-sync.pext`** from the [latest CI build](https://git.unom.io/unom/punktfunk-plugin-playnite/actions)
|
|
(the `exporter` job's artifact) and **double-click it** — Playnite installs it like any add-on.
|
|
Restart Playnite once.
|
|
|
|
That's it. The console's **Playnite** page flips to "Exporter connected" and your games sync within
|
|
seconds of any library change. No configuration needed.
|
|
|
|
> **Headless box** without the console? The engine is fully functional from a `config.json` alone —
|
|
> see [Headless / CLI](#headless--cli).
|
|
|
|
## The ingest inbox
|
|
|
|
Punktfunk's plugin runner is **de-privileged** on Windows (it runs as `NT AUTHORITY\LocalService`),
|
|
so it cannot read the interactive user's `%APPDATA%\Playnite\ExtensionsData\…` — which is where a
|
|
Playnite extension normally writes. So the exporter drops a second copy into the host's **ingest
|
|
inbox**, `<config_dir>\ingest\playnite\punktfunk-library.json`, which `punktfunk-host plugins enable`
|
|
makes user-writable. The plugin reads that inbox **first**, and falls back to scanning Playnite's own
|
|
`ExtensionsData` (which works for a same-user/SYSTEM runner, or on Linux under Wine).
|
|
|
|
This is why the plugin works at all under the security tiers; if the console says it's reading the
|
|
ingest inbox, that's the healthy path.
|
|
|
|
## Launching
|
|
|
|
Each title syncs with a launch command of `start "" "playnite://playnite/start/<gameId>"`. The host
|
|
runs that in the interactive Windows session, so **Playnite** owns the actual launch — the correct
|
|
store client or emulator, exactly as if you'd hit Play in Playnite. No per-store launch mapping to
|
|
maintain.
|
|
|
|
## Box art
|
|
|
|
Playnite stores cover art as **local files** on the host. Rather than shipping image bytes in the
|
|
reconcile (which doesn't scale — a large library blows past the host's request-body limit), the
|
|
default is to send the local file **path** and let the host serve the cover through its art proxy
|
|
(`/library/art/…`, exactly like Steam art). The payload stays tiny at any library size.
|
|
|
|
| `art.mode` | Behaviour |
|
|
|--------------|-----------------------------------------------------------------|
|
|
| `host` | **Default.** Send the cover's local path; the host serves the bytes. Scales to thousands of titles. Requires a host with the provider-art proxy. |
|
|
| `dataurl` | Inline the cover as a size-capped `data:` URL (`maxBytes` caps one image). Self-contained, but for **small** libraries only. |
|
|
| `off` | Sync titles with no art (lightest payload). |
|
|
|
|
`art.includeBackground` additionally sends the Playnite background as `hero` art (off by default).
|
|
|
|
The plugin's own SPA can't load a `C:\…\cover.jpg` either, so it fetches covers through
|
|
`GET /api/art?path=…` — which serves **only** paths the currently-ingested export references. It is
|
|
an allow-list lookup, not an arbitrary file read.
|
|
|
|
## Filters
|
|
|
|
| Key | Default | Meaning |
|
|
|-------------------------|---------|----------------------------------------------------------------|
|
|
| `filter.installedOnly` | `true` | Only sync games Playnite marks installed. |
|
|
| `filter.includeHidden` | `false` | Include games flagged Hidden in Playnite. |
|
|
| `filter.sources` | `[]` | If non-empty, keep only these sources (`"Steam"`, `"GOG"`…). Case-insensitive. |
|
|
| `filter.excludeSources` | `[]` | Drop these sources. |
|
|
| `gameOverrides` | `{}` | Per-game `{ "<guid>": { "exclude": true } }` — the Library page's toggle. |
|
|
|
|
Edit them in the console's **Playnite** page, or in `config.json` directly. Only the keys you author
|
|
are stored: defaults live in the schema (`contract/src/config.ts`) and are never baked into your file.
|
|
|
|
## Headless / CLI
|
|
|
|
The plugin owns `<config_dir>/plugin-state/playnite/config.json` and is fully functional from that
|
|
file alone. A debug CLI mirrors the engine:
|
|
|
|
```sh
|
|
bunx punktfunk-plugin-playnite where # where the exporter output was found (+ probed paths)
|
|
bunx punktfunk-plugin-playnite preview # the desired library — no host write
|
|
bunx punktfunk-plugin-playnite sync # reconcile into the live library
|
|
bunx punktfunk-plugin-playnite uninstall # remove every entry this plugin owns
|
|
```
|
|
|
|
Provider entries are deliberately **left** in the library when the plugin stops, so your games survive
|
|
restarts and reboots; `uninstall` is the explicit way to clear them.
|
|
|
|
## Requirements
|
|
|
|
- A **Windows** Punktfunk host running the scripting runner (`punktfunk-scripting`).
|
|
- **Playnite** on the same box, with the Punktfunk Sync extension installed.
|
|
- `@punktfunk/host` **≥ 0.1.2** + `@punktfunk/plugin-kit` **≥ 0.1.4** (shared with the runner, not
|
|
bundled).
|
|
|
|
## Development — three bun workspaces
|
|
|
|
This plugin is the second consumer of
|
|
[`@punktfunk/plugin-kit`](https://git.unom.io/unom/punktfunk/src/branch/main/plugin-kit), built on the
|
|
same blueprint as [`rom-manager`](https://git.unom.io/unom/punktfunk-plugin-rom-manager) — copy either
|
|
repo's structure to start a new plugin.
|
|
|
|
| Workspace | What it is |
|
|
|---|---|
|
|
| `contract/` | **The single source of truth**: the cross-process export Schema (the C# exporter's other half, with the schema-version guard as a decode check), the config schema (raw↔resolved in one schema), domain DTOs, the `HttpApi` contract, API errors. Never published — bundled into the plugin, imported source-level by the UI. No hand-mirrored types anywhere. |
|
|
| `plugin/` | The published package. `src/domain` is the pure, injectable, unit-tested core (locate/read, art, reconcile); `src/services` wires it into the kit (ConfigService, CacheStore, SyncEngine, ProviderClient, HttpApi handlers + SSE + the art proxy); `src/index.ts` is the `definePluginKit` entry (async at the runner boundary, Effect inside). |
|
|
| `ui/` | The SPA: React 19 + `@unom/ui` + `@unom/app-ui` + the kit's `/react` helpers + `/theme.css`, with an Effect-native data layer (`AtomHttpApi` atoms derived from the contract). Three pages — **Overview**, **Library**, **Settings**. Builds into `plugin/dist/ui`. |
|
|
|
|
```sh
|
|
bun install # one workspace install (Gitea registry for @punktfunk/@unom scopes)
|
|
bun test # domain tests (plugin/)
|
|
bun run typecheck # contract → plugin → ui
|
|
bun run build # backend bundle + SPA → plugin/dist
|
|
|
|
cd ui && bun run dev # UI with ZERO host running (in-browser mock layer + scenarios)
|
|
cd ui && bun run storybook # per-page stories on the same fixtures (port 6014)
|
|
cd ui && bun run screenshots # headless captures of every page story
|
|
cd plugin && bun run dev # auth-free dev API on :5886 …
|
|
cd ui && bun run dev:live # … and the UI proxied against it
|
|
```
|
|
|
|
`@unom/*` + `@effect/atom-react` are build-time only — the SPA ships as static assets, so the
|
|
published plugin carries no UI runtime dependencies.
|
|
|
|
The exporter (needs the .NET SDK; builds net462 on any OS via reference assemblies):
|
|
|
|
```sh
|
|
dotnet build exporter/PunktfunkSync.csproj -c Release
|
|
# package the .pext:
|
|
( cd exporter/bin/Release && zip -j ../../../punktfunk-sync.pext extension.yaml PunktfunkSync.dll )
|
|
```
|
|
|
|
**The export shape is a contract with the C# side.** `contract/src/export.ts` and
|
|
`exporter/Model.cs` describe the same document; change one and you must change the other, and bump
|
|
`SCHEMA` if the change is breaking. The reader refuses a document stamped with a higher `SCHEMA` than
|
|
it understands rather than mis-reading it.
|
|
|
|
## Security
|
|
|
|
- The **ingest inbox is writable by any local user** — treat what it contains as untrusted. Game ids
|
|
are validated as .NET Guids before they reach a launch command; the `playnite://` URI is the only
|
|
thing ever interpolated, and Playnite performs the launch.
|
|
- `config.json` lives in the hardened `<config_dir>/plugin-state`; the plugin refuses a
|
|
group/world-writable one.
|
|
- `GET /api/art` serves only paths referenced by the current export, and only image extensions.
|
|
- No outbound network at all. No telemetry.
|
|
|
|
## License
|
|
|
|
MIT OR Apache-2.0.
|