Reviewed-on: #11
@punktfunk/plugin-playnite
Sync your Playnite 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 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) watches that file and reconciles it into the host library as theplayniteprovider, with a console-hosted web UI. Zero-config auth via the runner.
Install
Two one-time steps.
1 · This plugin (on the host)
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 release
and double-click it — Playnite installs it like any add-on. Restart Playnite once.
The console tells you this too: until the exporter has written anything, the plugin's Playnite
page shows the same download link, so you never have to come here for it. Every release attaches a
punktfunk-sync.pext.sha256 beside the file if you want to verify it.
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.jsonalone — see 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.
Knowing when the game ends
That handoff has a cost: the launch command is gone within a second, so the host has nothing left to
watch and can't tell that the game ended. Each title therefore also syncs a detect hint — how to
recognize the game's own process — built from whatever Playnite knows about it:
| where it comes from | |
|---|---|
| install directory | the game's own, or Playnite's Directory tracking path, or the emulator's directory for an emulated title |
| executable | the play action's file, or the configured emulator's executable |
| process name | Playnite's Process tracking path, or the play action's file name when no path resolved |
All three are optional and all three are sent when they resolve — the host matches on any of them, so a game that re-execs into a differently-named binary under the same directory is still found.
With a hint, quitting a Playnite-launched game behaves like any other title's: your client returns to
its library instead of showing the host's desktop, and an operator who opted into ending games when a
session ends has something to act on. A game that yields none of the three is honestly untrackable —
it syncs, it launches, and the host reports it as untracked rather than pretending to watch it.
⚠ Earlier exporters sent the install directory only, so an emulated title, a manually added one, or a library plugin that never records an install directory reached the host with nothing at all. If your games don't end their streaming session, update the exporter extension (below) — the plugin alone cannot fix it, because only the Playnite side can see any of this.
Needs the 0.3.0 exporter and a host on the release that shipped detect; older combinations
degrade quietly rather than breaking.
…and what Playnite simply tells us (0.4.0 exporter)
Recognising a process is guesswork; Playnite knows. It starts the game, tracks it in whatever mode
you configured, and raises an event on both edges — so from the 0.4.0 exporter it also writes
punktfunk-running.json beside the library export, and this plugin passes that on to the host
(PUT /library/provider/playnite/running).
That is what closes the case above rather than merely narrowing it: a game with no install directory, executable or process name still ends its streaming session when you quit it, because nobody had to recognise anything. Where a detect hint does exist, the report simply gets there first.
The exporter re-stamps that file every 30s while a game is up, purely to prove Playnite is still there; if it stops (Playnite killed, the box slept), the plugin goes quiet, the host's claim expires, and it returns to recognising games by their processes exactly as before. Turn the whole thing off with Settings → Sync → Tell the host what's running if you would rather it did not.
Needs the 0.4.0 exporter and a host carrying that route; an older host answers 404 and the plugin carries on without it.
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:
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, built on the
same blueprint as 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. |
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):
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.jsonlives in the hardened<config_dir>/plugin-state; the plugin refuses a group/world-writable one.GET /api/artserves only paths referenced by the current export, and only image extensions.- No outbound network at all. No telemetry.
License
MIT OR Apache-2.0.