2 Commits

Author SHA1 Message Date
enricobuehler e8acf922de feat(ui): three-page console SPA on @unom/ui + the kit's react helpers
CI / build (push) Successful in 31s
CI / exporter (push) Successful in 12s
CI / publish (push) Successful in 22s
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.
2026-07-20 21:05:04 +02:00
enricobuehler b81e03685a refactor: rewrite on @punktfunk/plugin-kit — contract + plugin workspaces
The framework half of this plugin (engine lifecycle, config/state, host
reconcile, UI server, CLI) was ~80% identical to rom-manager's. That code is
now @punktfunk/plugin-kit; this replaces the local copy with it and adopts the
rom-manager blueprint layout.

  contract/  Effect Schemas: the cross-process export contract (the C#
             exporter's other half, with the schema-version guard as a decode
             check), the config schema (one schema, Encoded = authored file
             shape, defaults only via withDecodingDefaultKey), domain DTOs, the
             HttpApi contract, API errors.
  plugin/    src/domain = the pure core (locate/read, art, reconcile), ported
             tests green before any Effect wiring; src/services = the kit's
             ConfigService/CacheStore/SyncEngine/ProviderClient; definePluginKit
             entry, kit CLI, auth-free dev API.

Preserved, because they are what makes playnite work at all: the ingest inbox
is still read FIRST (the de-privileged LocalService runner cannot reach the
interactive user's %APPDATA%), the host/dataurl/off art modes, and the
playnite:// launch hand-back. The C# exporter is untouched.

New: per-game include/exclude overrides, and an allow-listed /api/art proxy so
the SPA can render local Playnite covers — it serves only paths the currently
ingested export references.

Dropped: the standalone password UI server (console surface + CLI only, as in
rom-manager 0.3.0) and the devEntry flag.

Trap found and fixed here: the HttpApi encoder serialises `undefined` as
`null`, so a `Schema.optional` field the server omits cannot be decoded by the
client — silently in the SSE feed, loudly in the typed client. Every optional
EngineStatus field is `Schema.NullOr` with an explicit value on the wire.
2026-07-20 20:56:12 +02:00
85 changed files with 5690 additions and 2634 deletions
+32 -13
View File
@@ -1,10 +1,11 @@
# CI for @punktfunk/plugin-playnite (Gitea Actions). # CI for the playnite workspace (Gitea Actions).
# build — the TS plugin + SPA: lint, typecheck, test, bundle (mirrors the rom-manager plugin CI). # build — the three bun workspaces (contract + plugin + ui): one root install, lint,
# exporter — the C# Playnite extension → a `.pext`, built net462 with the .NET SDK on Linux (no # per-package typecheck, domain tests, bundle + SPA. Mirrors rom-manager's.
# Windows/MSBuild needed) and uploaded as a workflow artifact. # exporter — the C# Playnite extension → a `.pext`, built net462 with the .NET SDK on
# publish — npm publish to the Gitea registry on a `v*` tag. # Linux (no Windows/MSBuild needed) and uploaded as a workflow artifact.
# Installs resolve @punktfunk/* from the Gitea registry via the bunfig scope map + REGISTRY_TOKEN; # publish — npm publish to the Gitea registry on a `v*` tag, from plugin/.
# everything else (effect, react, tailwind…) comes from npm. # @punktfunk/* and @unom/* resolve from the Gitea registry via the root bunfig scope maps
# + REGISTRY_TOKEN; everything else (effect, react, tailwind…) comes from npm.
name: CI name: CI
on: on:
@@ -31,22 +32,38 @@ jobs:
run: | run: |
test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; } test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc" printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc"
- name: Install - name: Install (workspace)
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Lint & format - name: Lint & format
run: bunx biome check run: bunx biome check
- name: Typecheck (backend) - name: Typecheck (contract)
working-directory: contract
run: bunx tsc --noEmit run: bunx tsc --noEmit
- name: Test (engine) - name: Typecheck (plugin)
working-directory: plugin
run: bunx tsc --noEmit
- name: Test (domain)
working-directory: plugin
run: bun test run: bun test
- name: Build backend + SPA - name: Build backend bundle + SPA
working-directory: plugin
run: bun run build:all run: bun run build:all
- name: Typecheck (UI) - name: Typecheck (UI)
working-directory: ui working-directory: ui
run: bunx tsc --noEmit run: bunx tsc --noEmit
- name: Sanity — plugin default export is a valid PluginDef - name: Sanity — plugin default export is a valid PluginDef
working-directory: plugin
run: | run: |
bun -e 'import p from "./dist/index.js"; const d = p.default ?? p; if (d?.name !== "playnite" || typeof d?.main !== "function") { console.error("bad default export", d); process.exit(1); } console.log("ok:", d.name)' bun -e 'import plugin from "./dist/index.js"; if (plugin?.name !== "playnite" || typeof plugin?.main !== "function") { console.error("bad default export", plugin); process.exit(1); } console.log("ok:", plugin.name)'
- name: Sanity — no fixtures in the production SPA bundle
working-directory: plugin
run: |
for needle in placehold.co mockHttpClientLayer tickerFrames; do
if grep -rq "$needle" dist/ui/assets/*.js; then
echo "fixture string '$needle' leaked into the production bundle"; exit 1
fi
done
echo "ok: production bundle is fixture-free"
exporter: exporter:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -90,12 +107,14 @@ jobs:
run: | run: |
test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; } test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc" printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc"
- name: Install - name: Install (workspace)
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Tag matches package version - name: Tag matches package version
working-directory: plugin
run: | run: |
TAG="${GITHUB_REF_NAME#v}" TAG="${GITHUB_REF_NAME#v}"
PKG="$(node -p "require('./package.json').version")" PKG="$(node -p "require('./package.json').version")"
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME != package version $PKG"; exit 1; } test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME != package version $PKG"; exit 1; }
- name: Publish (prepublishOnly builds backend + SPA) - name: Publish (prepublishOnly builds backend + SPA)
working-directory: plugin
run: bun publish run: bun publish
+3 -1
View File
@@ -1,7 +1,9 @@
node_modules node_modules
dist dist
plugin/dist
ui/dist ui/dist
ui/node_modules ui/storybook-static
ui/screenshots
*.log *.log
.DS_Store .DS_Store
+82 -28
View File
@@ -3,24 +3,25 @@
Sync your **[Playnite](https://playnite.link)** library into the Punktfunk host game library. Every 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, 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 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. 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: It has two halves, both on the **same Windows box** as the Punktfunk host:
``` ```
┌─────────────────────────────┐ ┌──────────────────────────────────────────┐ ┌─────────────────────────────┐ ┌────────────────────────────────────────────
│ Playnite │ │ Punktfunk host (this plugin, in the │ │ Playnite │ │ Punktfunk host (this plugin, in the │
│ └ Punktfunk Sync extension │ JSON │ scripting runner) │ │ └ Punktfunk Sync extension │ JSON │ scripting runner) │
│ writes │ ─────▶ │ reads punktfunk-library.json, embeds art, │ writes │ ─────▶ │ reads punktfunk-library.json, maps it to
│ punktfunk-library.json │ │ PUT /library/provider/playnite │ 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) - **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 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, while running, so reading it from *inside* Playnite is the only robust way — this is that adapter,
and nothing more. and nothing more.
- **This plugin** (TypeScript, on [`@punktfunk/host`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk)) - **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 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. console-hosted web UI. Zero-config auth via the runner.
@@ -37,8 +38,8 @@ bun add @punktfunk/plugin-playnite
Enable-ScheduledTask PunktfunkScripting # enable the scripting runner if it isn't already Enable-ScheduledTask PunktfunkScripting # enable the scripting runner if it isn't already
``` ```
Open the Punktfunk console — a **Playnite** entry appears in the nav. (Headless box without the Open the Punktfunk console — a **Playnite** entry appears in the nav. It will say it's waiting for
console? The engine is fully functional from a `config.json` alone — see [Headless](#headless--cli).) Playnite until you do step 2.
### 2 · The Punktfunk Sync extension (in Playnite) ### 2 · The Punktfunk Sync extension (in Playnite)
@@ -46,13 +47,23 @@ Download **`punktfunk-sync.pext`** from the [latest CI build](https://git.unom.i
(the `exporter` job's artifact) and **double-click it** — Playnite installs it like any add-on. (the `exporter` job's artifact) and **double-click it** — Playnite installs it like any add-on.
Restart Playnite once. Restart Playnite once.
That's it. The console's **Playnite** page shows "Exporter connected" and your games sync within That's it. The console's **Playnite** page flips to "Exporter connected" and your games sync within
seconds of any library change. seconds of any library change. No configuration needed.
> The console page can also **deploy the exporter for you** if you'd rather not handle the `.pext` > **Headless box** without the console? The engine is fully functional from a `config.json` alone
> it drops the extension into `%APPDATA%\Playnite\Extensions\` and asks you to restart Playnite. > see [Headless / CLI](#headless--cli).
> (Best-effort: it needs read/write access to the interactive user's `%APPDATA%`; the `.pext` is the
> reliable fallback.) ## 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 ## Launching
@@ -64,17 +75,21 @@ maintain.
## Box art ## Box art
Playnite stores cover art as **local files** on the host. Rather than shipping image bytes in the 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 plugin reconcile (which doesn't scale — a large library blows past the host's request-body limit), the
sends the local file **path** and the host serves the cover through its art proxy 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. (`/library/art/…`, exactly like Steam art). The payload stays tiny at any library size.
| `art.mode` | Behaviour | | `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. | | `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 — a big one exceeds the host's body limit. | | `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). | | `off` | Sync titles with no art (lightest payload). |
`art.includeBackground` additionally serves the Playnite background as `hero` art (off by default). `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 ## Filters
@@ -84,13 +99,15 @@ sends the local file **path** and the host serves the cover through its art prox
| `filter.includeHidden` | `false` | Include games flagged Hidden in Playnite. | | `filter.includeHidden` | `false` | Include games flagged Hidden in Playnite. |
| `filter.sources` | `[]` | If non-empty, keep only these sources (`"Steam"`, `"GOG"`…). Case-insensitive. | | `filter.sources` | `[]` | If non-empty, keep only these sources (`"Steam"`, `"GOG"`…). Case-insensitive. |
| `filter.excludeSources` | `[]` | Drop these sources. | | `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. 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 ## Headless / CLI
The plugin owns `<config_dir>/playnite/config.json` (see [`config.example.json`](./config.example.json)). The plugin owns `<config_dir>/plugin-state/playnite/config.json` and is fully functional from that
It's fully functional from that file alone. A debug CLI mirrors the engine: file alone. A debug CLI mirrors the engine:
```sh ```sh
bunx punktfunk-plugin-playnite where # where the exporter output was found (+ probed paths) bunx punktfunk-plugin-playnite where # where the exporter output was found (+ probed paths)
@@ -99,23 +116,45 @@ bunx punktfunk-plugin-playnite sync # reconcile into the live library
bunx punktfunk-plugin-playnite uninstall # remove every entry this plugin owns bunx punktfunk-plugin-playnite uninstall # remove every entry this plugin owns
``` ```
For a host without the console, set `ui.standalone: true` and a password Provider entries are deliberately **left** in the library when the plugin stops, so your games survive
(`bunx punktfunk-plugin-playnite set-password <pw>`); the UI then serves on `ui.port` (default 47994). restarts and reboots; `uninstall` is the explicit way to clear them.
## Requirements ## Requirements
- A **Windows** Punktfunk host running the scripting runner (`punktfunk-scripting`). - A **Windows** Punktfunk host running the scripting runner (`punktfunk-scripting`).
- **Playnite** on the same box, with the Punktfunk Sync extension installed. - **Playnite** on the same box, with the Punktfunk Sync extension installed.
- The mgmt-API provider reconcile is loopback + token; the runner supplies both automatically. - `@punktfunk/host` **≥ 0.1.2** + `@punktfunk/plugin-kit` **≥ 0.1.4** (shared with the runner, not
bundled).
## Build from source ## 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 ```sh
bun install bun install # one workspace install (Gitea registry for @punktfunk/@unom scopes)
bun run build:all # backend (dist/) + SPA (dist/ui/) bun test # domain tests (plugin/)
bun test 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): The exporter (needs the .NET SDK; builds net462 on any OS via reference assemblies):
```sh ```sh
@@ -124,6 +163,21 @@ dotnet build exporter/PunktfunkSync.csproj -c Release
( cd exporter/bin/Release && zip -j ../../../punktfunk-sync.pext extension.yaml PunktfunkSync.dll ) ( 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 ## License
MIT OR Apache-2.0. MIT OR Apache-2.0.
+8 -1
View File
@@ -7,7 +7,14 @@
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": ["**", "!dist", "!ui/dist", "!**/node_modules", "!exporter"] "includes": [
"**",
"!**/dist",
"!**/node_modules",
"!exporter",
"!ui/storybook-static",
"!ui/screenshots"
]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
+1678 -8
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,5 +1,5 @@
# Resolve the `@punktfunk` scope from the Gitea npm registry (like `@punktfunk/host` itself), so # Resolve the @punktfunk and @unom scopes from the Gitea npm registry for the whole
# `bun add @punktfunk/plugin-playnite` pulls the shared `@punktfunk/host` + `effect` deps out of the # workspace (plugin deps + UI design system).
# box. During local development the SDK is consumed via `bun link @punktfunk/host`.
[install.scopes] [install.scopes]
"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/" "@punktfunk" = "https://git.unom.io/api/packages/unom/npm/"
"@unom" = "https://git.unom.io/api/packages/unom/npm/"
-24
View File
@@ -1,24 +0,0 @@
{
"filter": {
"installedOnly": true,
"includeHidden": false,
"sources": [],
"excludeSources": ["Xbox"]
},
"art": {
"mode": "host",
"maxBytes": 1500000,
"includeBackground": false
},
"sync": {
"pollMinutes": 5,
"watch": true,
"debounceMs": 1500
},
"ui": {
"standalone": false,
"port": 47994,
"bind": "127.0.0.1"
},
"playniteDir": ""
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@playnite/contract",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "The single source of truth shared by the plugin server and the UI: the cross-process export schema (the C# exporter's other half), the config schema, domain DTOs, the HttpApi contract, and API errors. Never published — bundled into the plugin, aliased into the UI.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"effect": "^4.0.0-beta.99",
"@punktfunk/plugin-kit": "^0.1.4"
},
"devDependencies": {
"effect": "4.0.0-beta.99",
"@punktfunk/plugin-kit": "^0.1.4",
"typescript": "^5.9.3",
"@types/bun": "^1.3.0"
}
}
+96
View File
@@ -0,0 +1,96 @@
// The one HttpApi contract — HttpApiBuilder implements it server-side, AtomHttpApi
// derives the UI's typed client + atoms from it. No hand-mirrored types anywhere.
//
// Two routes sit OUTSIDE the HttpApi because neither is JSON: the SSE status feed
// (`effect/unstable/httpapi` has no event-stream media type at beta.99) and the cover-art
// proxy (image bytes). Both are plain HttpRouter routes; their paths live here so the UI
// and the server still share one definition.
import { ProviderEntry } from "@punktfunk/plugin-kit/wire";
import { Schema } from "effect";
import {
HttpApi,
HttpApiEndpoint,
HttpApiGroup,
HttpApiSchema,
} from "effect/unstable/httpapi";
import { EngineStatus, SyncReport } from "./domain.js";
import { ConfigInvalid, ExportUnavailable, SyncInProgress } from "./errors.js";
/** GET /api/config + PUT /api/config response: the authored raw shape, verbatim.
* The UI resolves it locally by decoding through the SHARED PlayniteConfigSchema. */
export const ConfigPayload = Schema.Struct({ raw: Schema.Unknown });
export type ConfigPayload = typeof ConfigPayload.Type;
/** GET /api/preview: the dry-run desired state (no art embedding, no host write). */
export const PreviewResult = Schema.Struct({
entries: Schema.Array(ProviderEntry),
report: SyncReport,
});
export type PreviewResult = typeof PreviewResult.Type;
export const PlayniteApi = HttpApi.make("playnite")
.add(
HttpApiGroup.make("status").add(
HttpApiEndpoint.get("get", "/api/status", { success: EngineStatus }),
),
)
.add(
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("get", "/api/config", { success: ConfigPayload }),
)
.add(
HttpApiEndpoint.put("put", "/api/config", {
payload: Schema.Unknown,
success: ConfigPayload,
error: ConfigInvalid.pipe(HttpApiSchema.status(400)),
}),
),
)
.add(
HttpApiGroup.make("library").add(
HttpApiEndpoint.get("preview", "/api/preview", {
success: PreviewResult,
error: ExportUnavailable.pipe(HttpApiSchema.status(503)),
}),
),
)
.add(
HttpApiGroup.make("sync").add(
HttpApiEndpoint.post("run", "/api/sync", {
success: SyncReport,
error: Schema.Union([
SyncInProgress.pipe(HttpApiSchema.status(409)),
ExportUnavailable.pipe(HttpApiSchema.status(503)),
]),
}),
),
);
/** The SSE status feed. Frames: `event: status`, data = EngineStatus JSON. */
export const EVENTS_PATH = "/api/events";
export const EVENTS_EVENT = "status";
/**
* The cover-art proxy. Playnite art is LOCAL files on the host, which a browser cannot
* load — so the SPA asks the plugin for them by path. The server only serves a path that
* appears in the currently-ingested export (an allow-list, not an arbitrary file read).
*/
export const ART_PATH = "/api/art";
/**
* Resolve one of the exporter's art references to something an `<img src>` can load.
* Remote art (Playnite passes `http(s)` references through verbatim) and `data:` URLs are
* already loadable; a local path goes through the allow-listed proxy above.
*/
export const artUrl = (
prefix: string,
reference: string | null | undefined,
): string | undefined => {
if (reference === null || reference === undefined || reference === "") {
return undefined;
}
if (/^(https?:|data:)/i.test(reference)) return reference;
return `${prefix}${ART_PATH}?path=${encodeURIComponent(reference)}`;
};
+92
View File
@@ -0,0 +1,92 @@
// The config model. ONE schema: the Encoded side IS the authored file shape (RawConfig),
// the Type side is the fully-resolved shape the engine consumes (Config). Defaults live
// ONLY here (`withDecodingDefaultKey` + encodingStrategy "omit") — the kit's ConfigService
// persists the raw shape verbatim, so a UI save never bakes defaults into the file.
//
// Dropped vs 0.1.x: `ui.*` (the standalone password server is gone — console surface + CLI
// only) and `devEntry` (a dev flag has no place in the prod shape; `preview` shows the
// round-trip instead). Added: `gameOverrides`, the Library page's per-game include toggle.
// Stale keys in existing files are tolerated on decode and dropped by the next save.
import { Effect, Schema } from "effect";
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
schema.pipe(
Schema.withDecodingDefaultKey(Effect.succeed(value), {
encodingStrategy: "omit",
}),
);
/** When and how often we re-read the export and reconcile. */
export const SyncOptions = Schema.Struct({
/** Interval poll in minutes — the safety net under the exporter-driven file watch. */
pollMinutes: withDefault(Schema.Number, 5),
/** Watch the ingest inbox and reconcile on change (the primary trigger). */
watch: withDefault(Schema.Boolean, true),
/** Debounce window for coalescing watch/poll-triggered re-reads (ms). */
debounceMs: withDefault(Schema.Number, 1500),
});
export type SyncOptions = typeof SyncOptions.Type;
/** Which Playnite games become library entries. */
export const FilterOptions = Schema.Struct({
/** Only sync games Playnite marks installed. Off = include not-yet-installed titles. */
installedOnly: withDefault(Schema.Boolean, true),
/** Include games flagged Hidden in Playnite. */
includeHidden: withDefault(Schema.Boolean, false),
/** If non-empty, keep ONLY games whose source is in this list ("Steam", "GOG", …). */
sources: withDefault(Schema.Array(Schema.String), []),
/** Drop games whose source is in this list (applied after `sources`). */
excludeSources: withDefault(Schema.Array(Schema.String), []),
});
export type FilterOptions = typeof FilterOptions.Type;
/**
* How cover art reaches the clients. Playnite art is local files on the host:
* - `host` (default): send the local file PATH; the host serves the bytes through its art
* proxy (`/library/art/…`, like Steam art). The reconcile payload carries no image data,
* so this scales to a library of any size — the recommended mode.
* - `dataurl`: inline each cover as a size-capped `data:` URL. Self-contained but does NOT
* scale — a large library blows past the host's reconcile body limit. Small libraries only.
* - `off`: sync titles with no art.
*/
export const ArtMode = Schema.Literals(["host", "dataurl", "off"]);
export type ArtMode = typeof ArtMode.Type;
export const ArtOptions = Schema.Struct({
mode: withDefault(ArtMode, "host"),
/** Skip an image whose file is larger than this (bytes) — `dataurl` mode only. */
maxBytes: withDefault(Schema.Number, 1_500_000),
/** Also send the Playnite background as `hero` art (usually large — off by default). */
includeBackground: withDefault(Schema.Boolean, false),
});
export type ArtOptions = typeof ArtOptions.Type;
/** A per-game override, keyed by the Playnite game Guid (the reconcile `external_id`). */
export const GameOverride = Schema.Struct({
exclude: Schema.optionalKey(Schema.Boolean),
});
export type GameOverride = typeof GameOverride.Type;
export const PlayniteConfigSchema = Schema.Struct({
/** Override the auto-detected Playnite data directory (`%APPDATA%\\Playnite`). Point it
* at a portable install, or at the interactive user's profile when the runner runs as
* a different user. The ingest inbox is still read FIRST. */
playniteDir: Schema.optionalKey(Schema.String),
sync: withDefault(SyncOptions, {}),
filter: withDefault(FilterOptions, {}),
art: withDefault(ArtOptions, {}),
gameOverrides: withDefault(Schema.Record(Schema.String, GameOverride), {}),
});
/** The fully-resolved config the engine consumes. */
export type Config = typeof PlayniteConfigSchema.Type;
/** The on-disk config as authored (all optional). */
export type RawConfig = typeof PlayniteConfigSchema.Encoded;
/**
* Resolve an authored raw config to the total shape (defaults filled). Throws on an
* invalid shape — the sync path for tests and the UI; the server side uses the kit's
* ConfigService (Effect + typed errors) instead.
*/
export const resolveConfig = (raw: unknown): Config =>
Schema.decodeUnknownSync(PlayniteConfigSchema)(raw);
+88
View File
@@ -0,0 +1,88 @@
// Domain DTOs shared by the plugin server and the UI — Schemas are the source of truth,
// the derived types keep the original domain names so the pure core reads unchanged.
//
// Trap, measured on this API (rom-manager's note only covers half of it): `optionalKey`
// rejects a present-but-`undefined` value and 400s at the RESPONSE encoder — but
// `Schema.optional` is not the fix, because the HttpApi encoder serialises `undefined` as
// `null`, and `string | undefined` then refuses that null when the CLIENT decodes it. The
// failure is silent in the SSE feed (invalid frames are skipped) and loud in the typed
// client. So: every "may be absent" field here is `Schema.NullOr`, and the server always
// sends an explicit value. JSON-native, symmetric, no undefined anywhere on the wire.
import { Schema } from "effect";
/** Where the export was found — drives the console's "exporter connected?" banner. */
export const Location = Schema.Struct({
/** The resolved source dir (the ingest inbox, or a Playnite data dir), or null. */
dir: Schema.NullOr(Schema.String),
/** Every dir considered, in probe order (the UI's diagnostics view). */
candidates: Schema.Array(Schema.String),
/** The resolved export file, or null if the exporter hasn't written one yet. */
file: Schema.NullOr(Schema.String),
/** mtime (epoch ms) of the export file, or null. */
mtime: Schema.NullOr(Schema.Number),
/** True when the file came from `<config_dir>/ingest/playnite` (the reliable lane). */
viaIngest: Schema.Boolean,
});
export type Location = typeof Location.Type;
/** A game the compute rejected, with a human reason (Library page, CLI). */
export const SkippedGame = Schema.Struct({
id: Schema.String,
title: Schema.String,
reason: Schema.String,
});
export type SkippedGame = typeof SkippedGame.Type;
/** A summary of one desired-state compute (Overview + Library pages). */
export const SyncReport = Schema.Struct({
/** ISO timestamp the exporter stamped on the source export. */
generatedAt: Schema.String,
/** Schema version of the export that produced this report. */
schemaVersion: Schema.Number,
/** Games in the export before filtering. */
considered: Schema.Number,
/** Entries in the reconcile payload. */
included: Schema.Number,
skipped: Schema.Array(SkippedGame),
/** Dropped by an explicit per-game override (the Library page's toggle). */
excluded: Schema.Array(
Schema.Struct({ id: Schema.String, title: Schema.String }),
),
warnings: Schema.Array(Schema.String),
/** Included entries per Playnite source ("Steam", "GOG", "(none)", …). */
perSource: Schema.Record(Schema.String, Schema.Number),
});
export type SyncReport = typeof SyncReport.Type;
export const LastSync = Schema.Struct({
fingerprint: Schema.String,
count: Schema.Number,
at: Schema.Number,
});
export type LastSync = typeof LastSync.Type;
/** The engine status the Overview page renders and the SSE feed streams. */
export const EngineStatus = Schema.Struct({
platform: Schema.String,
location: Location,
/** Last read/decode error (missing export, corruption, too-new schema), else null. */
exportError: Schema.NullOr(Schema.String),
/** Playnite's own version + mode, as stamped on the last readable export. */
playnite: Schema.NullOr(
Schema.Struct({
version: Schema.NullOr(Schema.String),
mode: Schema.NullOr(Schema.String),
}),
),
artMode: Schema.String,
syncing: Schema.Boolean,
lastSync: Schema.NullOr(LastSync),
lastReport: Schema.NullOr(SyncReport),
paths: Schema.Struct({
dir: Schema.String,
config: Schema.String,
cache: Schema.String,
ingest: Schema.String,
}),
});
export type EngineStatus = typeof EngineStatus.Type;
+25
View File
@@ -0,0 +1,25 @@
// API errors — Schema-backed so they cross the wire typed; status set per-endpoint via
// HttpApiSchema.status in api.ts.
import { Schema } from "effect";
/** PUT /api/config body failed schema validation. → 400 */
export class ConfigInvalid extends Schema.TaggedErrorClass<ConfigInvalid>()(
"ConfigInvalid",
{ issues: Schema.String },
) {}
/** A sync pass is already running (the trigger was coalesced). → 409 */
export class SyncInProgress extends Schema.TaggedErrorClass<SyncInProgress>()(
"SyncInProgress",
{},
) {}
/**
* No exporter output was found, or the file is unreadable / not a valid export / stamped
* with a schema this plugin does not understand. → 503, because it is a "the other half
* isn't installed yet" state rather than a server fault: the UI turns it into onboarding.
*/
export class ExportUnavailable extends Schema.TaggedErrorClass<ExportUnavailable>()(
"ExportUnavailable",
{ message: Schema.String },
) {}
+72
View File
@@ -0,0 +1,72 @@
// The CROSS-PROCESS contract: the other half of this file is C#.
//
// The Playnite exporter (`exporter/`, a GenericPlugin) writes `punktfunk-library.json` in
// exactly this shape on every library change; this plugin decodes it. Keep it in lockstep
// with `exporter/Model.cs` — the `[SerializationPropertyName]` attributes there pin the
// JSON keys these Schemas read. Bump `SCHEMA` on any breaking change; the exporter stamps
// the version it wrote and the `schema` check below turns a too-new file into a decode
// error rather than a silent mis-read.
//
// Leniency is deliberate and asymmetric: every field except `id`, `schema` and `games` has
// a decoding default, so an exporter that predates (or postdates) a field addition still
// decodes. Per-GAME validity — a malformed Guid, a blank title — is NOT a decode failure:
// those become skip reasons in the pure core, so one bad row can never cost you the whole
// library.
import { Effect, Schema } from "effect";
/** The file the exporter writes (ingest inbox, and its own `ExtensionsData/<guid>/`). */
export const EXPORT_FILE = "punktfunk-library.json";
/** Schema version this reader understands. A newer major is refused (see `LibraryExport`). */
export const SCHEMA = 1;
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
schema.pipe(
Schema.withDecodingDefaultKey(Effect.succeed(value), {
encodingStrategy: "omit",
}),
);
/** One game as the Playnite exporter sees it. Art fields are ABSOLUTE local paths on the
* host box (resolved via `IPlayniteAPI.Database.GetFullFilePath`) — except a remote
* `http(s)` art URL, which the exporter passes through verbatim. */
export const ExportedGame = Schema.Struct({
/** Playnite's stable game `Guid` ("d" format) — our reconcile `external_id`. */
id: Schema.String,
name: withDefault(Schema.String, ""),
/** Whether Playnite considers the game installed (drives the `installedOnly` filter). */
installed: withDefault(Schema.Boolean, false),
/** Playnite's per-game "Hidden" flag. */
hidden: withDefault(Schema.Boolean, false),
/** The library source name ("Steam", "GOG", "Epic", emulator name, …) or null. */
source: withDefault(Schema.NullOr(Schema.String), null),
/** Platform display names ("PC (Windows)", "Nintendo Switch", …). */
platforms: withDefault(Schema.Array(Schema.String), []),
cover: withDefault(Schema.NullOr(Schema.String), null),
background: withDefault(Schema.NullOr(Schema.String), null),
icon: withDefault(Schema.NullOr(Schema.String), null),
});
export type ExportedGame = typeof ExportedGame.Type;
export const PlayniteInfo = Schema.Struct({
version: withDefault(Schema.NullOr(Schema.String), null),
/** "Desktop" | "Fullscreen" — informational. */
mode: withDefault(Schema.NullOr(Schema.String), null),
});
export type PlayniteInfo = typeof PlayniteInfo.Type;
/** The whole export document. Decoding it IS the schema-version guard. */
export const LibraryExport = Schema.Struct({
schema: Schema.Number.pipe(
Schema.check(
Schema.isLessThanOrEqualTo(SCHEMA, {
description: `a punktfunk-library.json schema this plugin understands (≤ ${SCHEMA}) — a higher one means the Playnite exporter is newer than the plugin; update the plugin`,
}),
),
),
/** ISO-8601 timestamp the exporter stamped on this document. */
generatedAt: withDefault(Schema.String, ""),
playnite: withDefault(PlayniteInfo, {}),
games: Schema.Array(ExportedGame),
});
export type LibraryExport = typeof LibraryExport.Type;
+8
View File
@@ -0,0 +1,8 @@
// @playnite/contract — the shared source of truth (the cross-process export schema, the
// config schema, domain DTOs, the HttpApi contract, API errors). Bundled into the plugin,
// aliased into the UI.
export * from "./api.js";
export * from "./config.js";
export * from "./domain.js";
export * from "./errors.js";
export * from "./export.js";
+3 -4
View File
@@ -3,14 +3,13 @@
"target": "ES2022", "target": "ES2022",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"lib": ["ES2022", "DOM"], "lib": ["ES2022"],
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true, "noEmit": true,
"verbatimModuleSyntax": true,
"types": ["bun"] "types": ["bun"]
}, },
"include": ["src", "test"], "include": ["src"]
"exclude": ["ui", "dist", "node_modules"]
} }
+1 -1
View File
@@ -17,7 +17,7 @@ On startup, on every `OnLibraryUpdated`, and (debounced) on any per-game add/upd
%APPDATA%\Playnite\ExtensionsData\<plugin-guid>\punktfunk-library.json %APPDATA%\Playnite\ExtensionsData\<plugin-guid>\punktfunk-library.json
``` ```
Shape (kept in lockstep with `../src/export-schema.ts`): Shape (kept in lockstep with `../contract/src/export.ts`):
```jsonc ```jsonc
{ {
+10 -46
View File
@@ -1,55 +1,19 @@
{ {
"name": "@punktfunk/plugin-playnite", "name": "playnite-workspace",
"version": "0.1.1", "private": true,
"private": false,
"type": "module", "type": "module",
"description": "Punktfunk plugin: syncs your Playnite library into the host game library as a provider — every store and emulator Playnite manages, launched back through Playnite, with a console-hosted web UI. Pairs with the bundled Playnite exporter extension.", "workspaces": [
"license": "MIT OR Apache-2.0", "contract",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite",
"repository": {
"type": "git",
"url": "https://git.unom.io/unom/punktfunk-plugin-playnite.git"
},
"bugs": {
"url": "https://git.unom.io/unom/punktfunk-plugin-playnite/issues"
},
"keywords": [
"punktfunk",
"plugin", "plugin",
"playnite", "ui"
"game-library",
"game-streaming"
], ],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"punktfunk-plugin-playnite": "./dist/cli.js"
},
"files": [
"dist"
],
"publishConfig": {
"registry": "https://git.unom.io/api/packages/unom/npm/"
},
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "cd contract && bun run typecheck && cd ../plugin && bun run typecheck && cd ../ui && bun run typecheck",
"test": "bun test", "test": "cd plugin && bun test",
"build": "tsc -p tsconfig.build.json", "build": "cd plugin && bun run build:all",
"build:ui": "cd ui && bun install --silent && bun run build", "check": "bunx biome check ."
"build:all": "bun run build && bun run build:ui",
"sync": "bun src/cli.ts sync",
"where": "bun src/cli.ts where",
"preview": "bun src/cli.ts preview",
"prepublishOnly": "bun run build:all"
},
"dependencies": {
"@punktfunk/host": "^0.1.1",
"effect": "^4.0.0-beta.98"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.5.2", "@biomejs/biome": "^2.5.2"
"@types/bun": "^1.3.0",
"typescript": "^5.9.3"
} }
} }
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@punktfunk/plugin-playnite",
"version": "0.2.0",
"private": false,
"type": "module",
"description": "Punktfunk plugin: syncs your Playnite library into the host game library as a provider — every store and emulator Playnite manages, launched back through Playnite, with a console-hosted web UI. Pairs with the bundled Playnite exporter extension. Built on @punktfunk/plugin-kit.",
"license": "MIT OR Apache-2.0",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite",
"repository": {
"type": "git",
"url": "https://git.unom.io/unom/punktfunk-plugin-playnite.git"
},
"bugs": {
"url": "https://git.unom.io/unom/punktfunk-plugin-playnite/issues"
},
"keywords": [
"punktfunk",
"plugin",
"playnite",
"game-library",
"game-streaming"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./types/index.d.ts",
"bin": {
"punktfunk-plugin-playnite": "./dist/cli.js"
},
"files": [
"dist",
"types"
],
"publishConfig": {
"registry": "https://git.unom.io/api/packages/unom/npm/"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test",
"build": "bun build src/index.ts src/cli.ts --target=bun --outdir dist --external effect --external '@punktfunk/*'",
"build:ui": "cd ../ui && bun run build",
"build:all": "bun run build && bun run build:ui",
"dev": "bun src/dev.ts",
"prepublishOnly": "bun run build:all"
},
"dependencies": {
"@punktfunk/host": "^0.1.2",
"@punktfunk/plugin-kit": "^0.1.4",
"effect": "^4.0.0-beta.99"
},
"devDependencies": {
"@playnite/contract": "workspace:*",
"@types/bun": "^1.3.0",
"typescript": "^5.9.3"
}
}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bun
// Ops CLI on the kit's dispatcher — the same layer graph as the plugin entry, so every
// command sees the exact services the runner runs. where/preview work offline;
// sync/uninstall talk to the host.
import {
type CliCommand,
ProviderClient,
runPluginCli,
} from "@punktfunk/plugin-kit";
import { Effect } from "effect";
import pkg from "../package.json" with { type: "json" };
import { PLUGIN_NAME, PROVIDER_ID } from "./const.js";
import { type PlayniteServices, playniteLayer } from "./layer.js";
import { PlayniteSync } from "./services/engine.js";
const print = (line: string) => Effect.sync(() => console.log(line));
const commands: Record<string, CliCommand<PlayniteServices>> = {
where: {
summary: "Show where the Playnite exporter's output was found",
offline: true,
run: () =>
Effect.gen(function* () {
const sync = yield* PlayniteSync;
const loc = yield* sync.locate;
yield* print(`Source dir: ${loc.dir ?? "(not found)"}`);
yield* print(`Export file: ${loc.file ?? "(not found)"}`);
yield* print(`Via ingest: ${loc.viaIngest ? "yes" : "no"}`);
if (loc.mtime !== null) {
yield* print(`Last written: ${new Date(loc.mtime).toISOString()}`);
}
yield* print("Candidates probed:");
for (const c of loc.candidates) yield* print(` ${c}`);
if (loc.file === null) {
yield* print(
"\nNo export found. Install the Punktfunk Sync extension in Playnite (see exporter/README.md),",
);
yield* print("or set `playniteDir` in the plugin's config.json.");
}
}),
},
preview: {
summary: "Dry-run: show the entries a sync would reconcile",
offline: true,
run: () =>
Effect.gen(function* () {
const sync = yield* PlayniteSync;
const { entries, report } = yield* sync.preview;
yield* print(
`${report.included}/${report.considered} game(s) would sync (${report.skipped.length} skipped, ${report.excluded.length} excluded by you).`,
);
for (const [source, n] of Object.entries(report.perSource).sort(
(a, b) => b[1] - a[1],
)) {
yield* print(` ${source}: ${n}`);
}
for (const e of entries.slice(0, 40)) {
yield* print(` ${e.title}${e.launch?.value ?? "(no launch)"}`);
}
if (entries.length > 40) {
yield* print(` … and ${entries.length - 40} more`);
}
for (const s of report.skipped.slice(0, 20)) {
yield* print(`SKIP ${s.title}: ${s.reason}`);
}
for (const w of report.warnings) yield* print(`! ${w}`);
}),
},
sync: {
summary: "Reconcile the library provider now",
run: () =>
Effect.gen(function* () {
const sync = yield* PlayniteSync;
const outcome = yield* sync.engine.sync("manual");
yield* print(
outcome._tag === "AlreadyRunning"
? "a sync is already running"
: `${outcome._tag}: ${outcome.report.included} entries`,
);
}),
},
uninstall: {
summary: "Remove every playnite entry from the host library",
run: () =>
Effect.gen(function* () {
const provider = yield* ProviderClient;
yield* provider.remove(PROVIDER_ID);
yield* print("provider entries removed");
}),
},
};
await runPluginCli({
def: {
name: PLUGIN_NAME,
version: pkg.version,
layer: playniteLayer,
main: Effect.void,
},
commands,
});
+3 -3
View File
@@ -1,6 +1,6 @@
// Plugin identity. The package name is `@punktfunk/plugin-playnite` (the runner's scoped glob), but // Plugin identity. The package name is `@punktfunk/plugin-playnite` (the runner's scoped
// the `definePlugin` name, the library provider id, and the console-nav id are all the short // glob), but the `definePluginKit` name, the library provider id, the state/ingest dir
// `playnite`. // names, and the console-nav id are all the short `playnite`.
export const PLUGIN_NAME = "playnite"; export const PLUGIN_NAME = "playnite";
export const PROVIDER_ID = "playnite"; export const PROVIDER_ID = "playnite";
export const UI_TITLE = "Playnite"; export const UI_TITLE = "Playnite";
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bun
// Dev-only API server: the plugin's HttpApi on a plain loopback port (:5886), no auth, no
// static files — the `bun run dev:live` proxy target for the Vite UI. Connects to a running
// host when one is up (full sync path); otherwise runs host-less (locate, preview, config
// and the art proxy all work; reconcile fails softly in the engine's safeSync).
// This is NOT part of the shipped plugin (never bundled into dist/index.js or cli.js).
import { connect, type Punktfunk } from "@punktfunk/host";
import {
hostClientFromFacade,
loggingLayer,
pluginInfoLayer,
} from "@punktfunk/plugin-kit";
import { Effect, Layer, ManagedRuntime } from "effect";
import { HttpRouter } from "effect/unstable/http";
import pkg from "../package.json" with { type: "json" };
import { PLUGIN_NAME } from "./const.js";
import { playniteLayer } from "./layer.js";
import { makeApi } from "./services/api.js";
import { PlayniteConfig } from "./services/config.js";
import { PlayniteSync } from "./services/engine.js";
const PORT = Number(process.env.PLAYNITE_DEV_PORT ?? 5886);
const offlineFacade = (): Punktfunk =>
({
request: async (method: string, path: string) => {
throw new Error(`dev: no host running (tried ${method} ${path})`);
},
close: () => {},
}) as unknown as Punktfunk;
const pf = await connect().catch(() => {
console.log("dev: no punktfunk host reachable — running host-less");
return offlineFacade();
});
const base = Layer.mergeAll(
hostClientFromFacade(pf),
pluginInfoLayer({ name: PLUGIN_NAME, version: pkg.version }),
loggingLayer(PLUGIN_NAME),
);
const rt = ManagedRuntime.make(Layer.provideMerge(playniteLayer, base));
// No Effect.scoped here: the engine's loops belong to the runtime's layer scope and must
// outlive this acquisition effect (they close on rt.dispose()).
const { handler, dispose } = await rt.runPromise(
Effect.gen(function* () {
const sync = yield* PlayniteSync;
const config = yield* PlayniteConfig;
yield* sync.engine.start;
return HttpRouter.toWebHandler(makeApi({ sync, config }));
}),
);
const server = Bun.serve({
hostname: "127.0.0.1",
port: PORT,
idleTimeout: 0, // SSE
fetch: (req) => handler(req),
});
console.log(`playnite dev API on http://127.0.0.1:${server.port}/api/status`);
process.once("SIGINT", () => {
void dispose()
.then(() => rt.dispose())
.finally(() => process.exit(0));
});
+100
View File
@@ -0,0 +1,100 @@
// Cover art. Playnite stores art as local files on the host (it passes a remote `http`
// reference through verbatim). Three delivery modes — see `ArtOptions` in the contract:
// - `host` (default): emit the reference AS-IS; the host serves local paths through its
// art proxy (`/library/art/…`), so the reconcile payload carries no image bytes and
// scales to any library size.
// - `dataurl`: inline the cover as a size-capped `data:` URL (self-contained, but only fit
// for small libraries — a big one blows past the host's reconcile body limit).
// - `off`: no art at all.
//
// This module also owns the ALLOW-LIST for the plugin's own `/api/art` proxy: the SPA can't
// load `C:\…\cover.jpg`, so it asks the plugin for those bytes — and the plugin only ever
// serves a path that the current export actually references (`artPaths`).
import * as fs from "node:fs";
import * as path from "node:path";
import type {
ArtOptions,
ExportedGame,
LibraryExport,
} from "@playnite/contract";
import type { Artwork } from "@punktfunk/plugin-kit/wire";
const MIME: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".ico": "image/x-icon",
".tga": "image/x-tga",
};
/** The content type for a local art file, or null for an extension we don't serve. */
export const mimeFor = (file: string): string | null =>
MIME[path.extname(file).toLowerCase()] ?? null;
/** True for a reference the browser/host can already fetch without our help. */
export const isRemote = (reference: string): boolean =>
/^(https?:|data:)/i.test(reference);
/** Read a local image file into a `data:` URL, or null if missing, oversized, or an
* unknown type. */
export const fileToDataUrl = (
file: string | null,
maxBytes: number,
): string | null => {
if (!file) return null;
if (isRemote(file)) return file; // already inline-able
const mime = mimeFor(file);
if (!mime) return null;
try {
const st = fs.statSync(file);
if (!st.isFile() || st.size === 0 || st.size > maxBytes) return null;
return `data:${mime};base64,${fs.readFileSync(file).toString("base64")}`;
} catch {
return null;
}
};
/** Build the artwork block for a game per the art config. `undefined` when there is none. */
export const buildArtwork = (
game: ExportedGame,
art: ArtOptions,
): Artwork | undefined => {
if (art.mode === "off") return undefined;
// `host` mode: emit the references verbatim (the exporter only sets one when the file
// exists). The host serves the bytes, so the payload stays tiny at any library size.
// `dataurl` mode: inline (size-capped).
const [portrait, background] =
art.mode === "host"
? [game.cover, art.includeBackground ? game.background : null]
: [
fileToDataUrl(game.cover, art.maxBytes),
art.includeBackground
? fileToDataUrl(game.background, art.maxBytes)
: null,
];
if (!portrait && !background) return undefined;
// `Artwork` keys are optionalKey — omit them, never set them to undefined.
return {
...(portrait ? { portrait } : {}),
...(background ? { hero: background } : {}),
} satisfies Artwork;
};
/**
* Every LOCAL art path the export references — the allow-list the `/api/art` proxy serves
* from. Remote references are excluded (the browser loads those itself), so the proxy can
* never be talked into reading a file Playnite didn't hand us.
*/
export const artPaths = (exp: LibraryExport): ReadonlySet<string> => {
const out = new Set<string>();
for (const game of exp.games) {
for (const ref of [game.cover, game.background, game.icon]) {
if (ref && !isRemote(ref) && mimeFor(ref) !== null) out.add(ref);
}
}
return out;
};
+161
View File
@@ -0,0 +1,161 @@
// Locating and reading the exporter's output — the half of the cross-process contract
// that runs on the host. Pure of Effect (plain sync fs) so it unit-tests against tmp dirs;
// the engine service wraps it.
//
// WHICH ACCOUNT can see the file is the whole problem. The managed runner is de-privileged
// (`NT AUTHORITY\LocalService`) and cannot read the interactive user's `%APPDATA%`, so the
// reliable source is the **ingest inbox** (`<config_dir>/ingest/playnite/`) that the
// exporter also drops the file into — checked FIRST, always. The `%APPDATA%` /
// `C:\Users\*` `ExtensionsData` scan still works for a same-user/SYSTEM runner or on Linux
// (Wine/portable), and an explicit `playniteDir` override joins the candidate list.
//
// The `ExtensionsData/<guid>/` folder name is NOT hardcoded: we glob
// `ExtensionsData/*/punktfunk-library.json` and take the newest, so the reader stays
// decoupled from the exporter's extension id.
import * as fs from "node:fs";
import * as path from "node:path";
import {
type Config,
EXPORT_FILE,
LibraryExport,
type Location,
} from "@playnite/contract";
import { pluginIngestDir } from "@punktfunk/plugin-kit";
import { Schema } from "effect";
import { PLUGIN_NAME } from "../const.js";
const exists = (p: string): boolean => {
try {
fs.accessSync(p);
return true;
} catch {
return false;
}
};
/** Candidate Playnite data directories, most-specific first (the config override is
* prepended by the caller). */
export const candidatePlayniteDirs = (): string[] => {
const out: string[] = [];
if (process.platform === "win32") {
const appdata = process.env.APPDATA;
if (appdata) out.push(path.join(appdata, "Playnite"));
// SYSTEM/other-user runner: scan every local profile's roaming AppData.
const usersRoot = path.join(process.env.SystemDrive ?? "C:", "\\", "Users");
try {
for (const user of fs.readdirSync(usersRoot)) {
out.push(path.join(usersRoot, user, "AppData", "Roaming", "Playnite"));
}
} catch {
// no C:\Users (or not Windows-like) — fine
}
} else {
// Playnite is Windows-only, but a Wine/portable layout may set these — best effort.
const home = process.env.HOME;
if (home) out.push(path.join(home, ".local", "share", "Playnite"));
}
return [...new Set(out)];
};
/** Find the newest `punktfunk-library.json` under a Playnite dir's `ExtensionsData`. */
const findExportUnder = (
dir: string,
): { file: string; mtime: number } | null => {
const base = path.join(dir, "ExtensionsData");
let best: { file: string; mtime: number } | null = null;
let subdirs: string[];
try {
subdirs = fs.readdirSync(base);
} catch {
return null;
}
for (const sub of subdirs) {
const file = path.join(base, sub, EXPORT_FILE);
try {
const st = fs.statSync(file);
if (!best || st.mtimeMs > best.mtime) best = { file, mtime: st.mtimeMs };
} catch {
// no export in this extension's dir
}
}
return best;
};
/** Resolve where the export lives. The ingest inbox always wins; then a Playnite data dir
* that actually holds an export; else the first candidate that at least exists, so the UI
* can distinguish "Playnite found, exporter not installed" from "Playnite not found". */
export const locateExport = (config: Config): Location => {
const override = config.playniteDir?.trim();
const playniteDirs = override
? [override, ...candidatePlayniteDirs()]
: candidatePlayniteDirs();
const inbox = pluginIngestDir(PLUGIN_NAME);
const candidates = [inbox, ...playniteDirs];
const inboxFile = path.join(inbox, EXPORT_FILE);
try {
const st = fs.statSync(inboxFile);
return {
dir: inbox,
candidates,
file: inboxFile,
mtime: st.mtimeMs,
viaIngest: true,
};
} catch {
// no ingest drop yet — fall back to scanning Playnite's own data dirs
}
for (const dir of playniteDirs) {
const hit = findExportUnder(dir);
if (hit) {
return {
dir,
candidates,
file: hit.file,
mtime: hit.mtime,
viaIngest: false,
};
}
}
return {
dir: playniteDirs.find(exists) ?? null,
candidates,
file: null,
mtime: null,
viaIngest: false,
};
};
/** A missing, corrupt, foreign, or too-new export. Carries a sentence fit for the UI. */
export class ExportError extends Error {}
const decodeExport = Schema.decodeUnknownSync(LibraryExport);
/**
* Read + decode the export file. The schema-version guard lives in the contract Schema
* (`schema` is checked `<= SCHEMA`), so a too-new file surfaces here as a decode failure —
* one place, shared with the UI's type, no second hand-written check to drift.
*/
export const readExport = (file: string): LibraryExport => {
let raw: string;
try {
raw = fs.readFileSync(file, "utf8");
} catch (e) {
throw new ExportError(`cannot read ${file}: ${e}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new ExportError(`${file} is not valid JSON: ${e}`);
}
try {
return decodeExport(parsed);
} catch (e) {
throw new ExportError(
`${file} is not a usable Punktfunk library export: ${e}`,
);
}
};
@@ -1,69 +1,57 @@
// The pure core: turn a validated library export + the config into the declarative entry list the host // The pure core: turn a decoded library export + the config into the declarative entry
// reconcile expects, plus a report of what was included and why anything was skipped. No IO here (art // list the host reconcile expects, plus a report of what was included and why anything was
// is injected via `artFor`), so it's fully unit-testable. // skipped. No IO here (art is injected via `artFor`), so it is fully unit-testable.
import type { Config } from "../config.js"; import type {
import type { ExportedGame, LibraryExport } from "../export-schema.js"; Config,
import type { Artwork, ProviderEntryInput } from "../wire.js"; ExportedGame,
LibraryExport,
SyncReport,
} from "@playnite/contract";
import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
export interface SkippedGame { /** Playnite ids are .NET Guids. Validate the shape before interpolating into a launch
id: string; * command the id comes from our own exporter, but the guard is cheap and keeps the
title: string; * command line unambiguous. */
reason: string;
}
export interface SyncReport {
/** ISO timestamp the exporter stamped on the source export. */
generatedAt: string;
/** Games in the export before filtering. */
considered: number;
/** Entries in the reconcile payload. */
included: number;
skipped: SkippedGame[];
warnings: string[];
/** Count of included entries per Playnite source ("Steam", "GOG", "(none)"…). */
perSource: Record<string, number>;
}
/** Playnite ids are .NET Guids. Validate the shape before interpolating into a launch command the
* id comes from our own exporter, but the guard is cheap and keeps the command line unambiguous. */
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const isGuid = (id: string): boolean => GUID.test(id); export const isGuid = (id: string): boolean => GUID.test(id);
/** The launch command for a Playnite game: hand the `playnite://` URI to Playnite so IT performs the /** The launch command for a Playnite game: hand the `playnite://` URI to Playnite so IT
* real launch (any store or emulator, uniformly). The host runs `command` values via * performs the real launch (any store or emulator, uniformly). The host runs `command`
* `cmd.exe /c <value>` in the interactive session, so `start "" "<uri>"` invokes the URI handler. */ * values via `cmd.exe /c <value>` in the interactive session, so `start "" "<uri>"`
* invokes the URI handler. */
export const launchCommand = ( export const launchCommand = (
id: string, id: string,
platform: NodeJS.Platform = process.platform, platform: NodeJS.Platform = process.platform,
): ProviderEntryInput["launch"] => { ): ProviderEntry["launch"] => {
const uri = `playnite://playnite/start/${id}`; const uri = `playnite://playnite/start/${id}`;
// Playnite is Windows-only; the Windows host is the supported target. `start ""` gives cmd a // Playnite is Windows-only; the Windows host is the supported target. `start ""` gives
// (empty) window title so a quoted URI isn't mistaken for one. // cmd an (empty) window title so a quoted URI isn't mistaken for one.
if (platform === "win32") if (platform === "win32") {
return { kind: "command", value: `start "" "${uri}"` }; return { kind: "command", value: `start "" "${uri}"` };
// Best-effort elsewhere (Wine/portable): a desktop opener resolves the registered handler. }
// Best-effort elsewhere (Wine/portable): a desktop opener resolves the handler.
return { kind: "command", value: `xdg-open "${uri}"` }; return { kind: "command", value: `xdg-open "${uri}"` };
}; };
const sourceKey = (g: ExportedGame): string => g.source ?? "(none)"; const sourceKey = (g: ExportedGame): string => g.source ?? "(none)";
export interface ComputeInput { export interface ComputeInput {
exp: LibraryExport; readonly exp: LibraryExport;
config: Config; readonly config: Config;
platform?: NodeJS.Platform; readonly platform?: NodeJS.Platform;
/** Art builder (IO lives in the engine; tests pass a stub). */ /** Art builder (IO lives in the engine; tests pass a stub). */
artFor: (game: ExportedGame) => Artwork | undefined; readonly artFor: (game: ExportedGame) => Artwork | undefined;
} }
export interface ComputeResult { export interface ComputeResult {
entries: ProviderEntryInput[]; readonly entries: ProviderEntry[];
report: SyncReport; readonly report: SyncReport;
} }
/** In `dataurl` art mode only: warn above this many entries, since each inlined cover adds to the /** In `dataurl` art mode only: warn above this many entries, since each inlined cover adds
* reconcile payload and a large library blows past the host's body limit (see README "Box art"). * to the reconcile payload and a large library blows past the host's body limit. `host`
* `host` mode (the default) sends paths, not bytes, so it has no such ceiling. */ * mode (the default) sends references, not bytes, so it has no such ceiling. */
const WARN_ENTRIES = 3000; const WARN_ENTRIES = 3000;
export const computeEntries = ({ export const computeEntries = ({
@@ -78,13 +66,14 @@ export const computeEntries = ({
filter.excludeSources.map((s) => s.toLowerCase()), filter.excludeSources.map((s) => s.toLowerCase()),
); );
const entries: ProviderEntryInput[] = []; const entries: ProviderEntry[] = [];
const skipped: SkippedGame[] = []; const skipped: SyncReport["skipped"][number][] = [];
const excluded: SyncReport["excluded"][number][] = [];
const warnings: string[] = []; const warnings: string[] = [];
const perSource: Record<string, number> = {}; const perSource: Record<string, number> = {};
for (const g of exp.games) { for (const g of exp.games) {
const title = (g.name ?? "").trim(); const title = g.name.trim();
const skip = (reason: string) => const skip = (reason: string) =>
skipped.push({ id: g.id, title: title || g.id, reason }); skipped.push({ id: g.id, title: title || g.id, reason });
@@ -113,15 +102,21 @@ export const computeEntries = ({
skip(`source "${g.source ?? "(none)"}" excluded`); skip(`source "${g.source ?? "(none)"}" excluded`);
continue; continue;
} }
// Last, so `excluded` only ever holds games that would OTHERWISE be included —
// what the Library page offers a "re-include" button for.
if (config.gameOverrides[g.id]?.exclude === true) {
excluded.push({ id: g.id, title });
continue;
}
const entry: ProviderEntryInput = { const art = artFor(g);
entries.push({
external_id: g.id, external_id: g.id,
title, title,
launch: launchCommand(g.id, platform), launch: launchCommand(g.id, platform),
}; // `art` is an optionalKey — omit it, never set it to undefined.
const art = artFor(g); ...(art ? { art } : {}),
if (art) entry.art = art; });
entries.push(entry);
const key = sourceKey(g); const key = sourceKey(g);
perSource[key] = (perSource[key] ?? 0) + 1; perSource[key] = (perSource[key] ?? 0) + 1;
} }
@@ -139,9 +134,11 @@ export const computeEntries = ({
entries, entries,
report: { report: {
generatedAt: exp.generatedAt, generatedAt: exp.generatedAt,
schemaVersion: exp.schema,
considered: exp.games.length, considered: exp.games.length,
included: entries.length, included: entries.length,
skipped, skipped,
excluded,
warnings, warnings,
perSource, perSource,
}, },
+46
View File
@@ -0,0 +1,46 @@
// The plugin entry — the default export the runner discovers. Everything is Effect inside
// definePluginKit's async-main boundary (see @punktfunk/plugin-kit): start the sync engine,
// serve the console UI, park until interruption.
//
// Provider entries are deliberately LEFT in the host library on shutdown, so the games
// survive plugin restarts and host reboots; a clean uninstall is the explicit CLI command.
import { definePluginKit, serveUi } from "@punktfunk/plugin-kit";
import { Effect } from "effect";
import pkg from "../package.json" with { type: "json" };
import { PLUGIN_NAME, UI_ICON, UI_TITLE } from "./const.js";
import { playniteLayer } from "./layer.js";
import { makeApi } from "./services/api.js";
import { PlayniteConfig } from "./services/config.js";
import { PlayniteSync } from "./services/engine.js";
export { PLUGIN_NAME, PROVIDER_ID, UI_ICON, UI_TITLE } from "./const.js";
const plugin = definePluginKit({
name: PLUGIN_NAME,
version: pkg.version,
layer: playniteLayer,
main: Effect.gen(function* () {
const sync = yield* PlayniteSync;
const config = yield* PlayniteConfig;
// Headless sync first — the library reconciles even if the UI can't come up.
yield* sync.engine.start;
yield* serveUi({
title: UI_TITLE,
icon: UI_ICON,
staticDir: new URL("./ui/", import.meta.url),
api: makeApi({ sync, config }),
}).pipe(
Effect.catch((e) =>
Effect.logWarning(
`ui: could not serve the console surface (${e.cause}) — engine continues headless`,
),
),
);
yield* Effect.never;
}),
});
export default plugin;
+29
View File
@@ -0,0 +1,29 @@
// The plugin's service graph over the kit base (HostClient | PluginInfo) — shared by the
// runner entry (index.ts), the CLI, and the dev server so they all run the same engine.
import type { HostClient } from "@punktfunk/plugin-kit";
import { type PluginInfo, ProviderClient } from "@punktfunk/plugin-kit";
import { Layer } from "effect";
import { PlayniteCache } from "./services/cache.js";
import { PlayniteConfig } from "./services/config.js";
import { PlayniteSync } from "./services/engine.js";
export type PlayniteServices =
| PlayniteConfig
| PlayniteCache
| PlayniteSync
| ProviderClient;
export const playniteLayer: Layer.Layer<
PlayniteServices,
never,
HostClient | PluginInfo
> = PlayniteSync.layer.pipe(
Layer.provideMerge(
Layer.mergeAll(
PlayniteConfig.layer,
PlayniteCache.layer,
ProviderClient.layer,
),
),
);
+117
View File
@@ -0,0 +1,117 @@
// The HttpApi implementation + the two non-JSON routes (SSE status feed, cover-art
// proxy), composed as the Layer `serveUi` mounts. Built from live service VALUES (not
// layers) so the handler runtime shares the plugin runtime's singletons — one engine, one
// config service.
import {
ART_PATH,
ConfigInvalid,
EVENTS_EVENT,
EVENTS_PATH,
ExportUnavailable,
PlayniteApi,
type PlayniteConfigSchema,
SyncInProgress,
} from "@playnite/contract";
import {
type ConfigService,
httpApiEnv,
sseRoute,
} from "@punktfunk/plugin-kit";
import { Effect, Layer } from "effect";
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import type { PlayniteSyncService } from "./engine.js";
export interface ApiDeps {
readonly sync: PlayniteSyncService;
readonly config: ConfigService<typeof PlayniteConfigSchema>;
}
/**
* `GET /api/art?path=<abs>` — the cover-art proxy. Playnite art is local files on the
* host, which the SPA cannot load, so it asks us for the bytes. The engine only answers
* for a path the CURRENT export references (see `artPaths`), so this is an allow-list
* lookup, not an arbitrary file read; anything else is a flat 404.
*/
const artRoute = (
deps: ApiDeps,
): Layer.Layer<never, never, HttpRouter.HttpRouter> =>
HttpRouter.add("GET", ART_PATH, (request) =>
Effect.gen(function* () {
const file = new URL(request.url, "http://localhost").searchParams.get(
"path",
);
if (file === null || file === "") {
return HttpServerResponse.empty({ status: 400 });
}
const art = yield* deps.sync.art(file);
if (art === undefined) return HttpServerResponse.empty({ status: 404 });
return HttpServerResponse.uint8Array(art.bytes, {
contentType: art.contentType,
// Playnite art files are content-addressed by Playnite; the path changes
// when the picture does, so a long private cache is safe.
headers: { "cache-control": "private, max-age=3600" },
});
}),
);
export const makeApi = (
deps: ApiDeps,
): Layer.Layer<never, never, HttpRouter.HttpRouter> => {
const statusGroup = HttpApiBuilder.group(PlayniteApi, "status", (h) =>
h.handle("get", () => deps.sync.status),
);
const configGroup = HttpApiBuilder.group(PlayniteApi, "config", (h) =>
h
.handle("get", () =>
deps.config.loadRaw.pipe(
Effect.map((raw) => ({ raw: raw as unknown })),
Effect.orDie,
),
)
.handle("put", ({ payload }) =>
deps.config.saveRaw(payload).pipe(
Effect.mapError((e) => new ConfigInvalid({ issues: String(e) })),
// Loops restart + resync in the background; the PUT answers immediately.
Effect.tap(() => Effect.forkDetach(deps.sync.engine.reconfigure)),
Effect.map(() => ({ raw: payload as unknown })),
),
),
);
const libraryGroup = HttpApiBuilder.group(PlayniteApi, "library", (h) =>
h.handle("preview", () => deps.sync.preview),
);
const syncGroup = HttpApiBuilder.group(PlayniteApi, "sync", (h) =>
h.handle("run", () =>
deps.sync.engine.sync("manual").pipe(
// A SyncError wraps whatever compute/apply failed with; an unreadable export
// is the one case the UI can act on, so it stays a typed 503.
Effect.mapError((e) =>
e.cause instanceof ExportUnavailable
? e.cause
: new ExportUnavailable({ message: String(e.cause) }),
),
Effect.flatMap((outcome) =>
outcome._tag === "AlreadyRunning"
? Effect.fail(new SyncInProgress())
: Effect.succeed(outcome.report),
),
),
),
);
return Layer.mergeAll(
HttpApiBuilder.layer(PlayniteApi).pipe(
Layer.provide(
Layer.mergeAll(statusGroup, configGroup, libraryGroup, syncGroup),
),
Layer.provide(httpApiEnv),
),
// EngineStatus is an identity codec (plain JSON shape) — default JSON encode.
sseRoute(EVENTS_PATH, deps.sync.statusChanges, { event: EVENTS_EVENT }),
artRoute(deps),
);
};
+28
View File
@@ -0,0 +1,28 @@
// The plugin's disposable derived state (cache.json), on the kit's CacheStore. Playnite's
// library IS the source of truth and lives one process away, so there is nothing to cache
// but the last reconcile fingerprint — an unchanged export then costs one read and no PUT.
// Corrupt/absent → empty; every mutation is write-through.
import { LastSync } from "@playnite/contract";
import {
type CacheStore,
makeCacheStore,
type PluginInfo,
} from "@punktfunk/plugin-kit";
import { Context, Layer, Schema } from "effect";
export const CacheSchema = Schema.Struct({
lastSync: Schema.optionalKey(LastSync),
});
export type Cache = typeof CacheSchema.Type;
export const emptyCache: Cache = {};
export class PlayniteCache extends Context.Service<
PlayniteCache,
CacheStore<typeof CacheSchema>
>()("playnite/PlayniteCache") {
static readonly layer: Layer.Layer<PlayniteCache, never, PluginInfo> =
Layer.effect(PlayniteCache)(
makeCacheStore({ schema: CacheSchema, empty: emptyCache }),
);
}
+19
View File
@@ -0,0 +1,19 @@
// The plugin's config service — the kit's ConfigService over the contract schema.
// Raw round-trip by construction: the file on disk is always the authored shape.
import { PlayniteConfigSchema } from "@playnite/contract";
import {
type ConfigService,
makeConfigService,
type PluginInfo,
} from "@punktfunk/plugin-kit";
import { Context, Layer } from "effect";
export class PlayniteConfig extends Context.Service<
PlayniteConfig,
ConfigService<typeof PlayniteConfigSchema>
>()("playnite/PlayniteConfig") {
static readonly layer: Layer.Layer<PlayniteConfig, never, PluginInfo> =
Layer.effect(PlayniteConfig)(
makeConfigService({ schema: PlayniteConfigSchema }),
);
}
+270
View File
@@ -0,0 +1,270 @@
// PlayniteSync — the plugin's engine service: wires the pure domain (locate → read →
// compute) into the kit's generic SyncEngine (poll/watch/debounce/coalesce/fingerprint)
// and the ProviderClient reconcile. Also owns the EngineStatus assembly the API and the
// SSE feed share, the side-effect-light preview the Library page uses, and the allow-listed
// cover-art reader behind `/api/art`.
import * as fs from "node:fs";
import * as path from "node:path";
import {
type Config,
type EngineStatus,
EXPORT_FILE,
ExportUnavailable,
type LibraryExport,
type Location,
type PlayniteInfo,
type PreviewResult,
resolveConfig,
type SyncReport,
} from "@playnite/contract";
import {
type LastSync,
makeSyncEngine,
ProviderClient,
pluginIngestDir,
type SyncEngine,
} from "@punktfunk/plugin-kit";
import type { ProviderEntry } from "@punktfunk/plugin-kit/wire";
import {
Context,
Duration,
Effect,
Layer,
Ref,
type Scope,
Stream,
} from "effect";
import { PLUGIN_NAME, PROVIDER_ID } from "../const.js";
import { artPaths, buildArtwork, mimeFor } from "../domain/art.js";
import { locateExport, readExport } from "../domain/locate.js";
import { computeEntries } from "../domain/reconcile.js";
import { PlayniteCache } from "./cache.js";
import { PlayniteConfig } from "./config.js";
/** One cover the `/api/art` proxy is willing to serve. */
export interface ArtFile {
readonly bytes: Uint8Array;
readonly contentType: string;
}
export interface PlayniteSyncService {
readonly engine: SyncEngine<SyncReport>;
/** Read + compute the desired state. NO art embedding, NO reconcile. */
readonly preview: Effect.Effect<PreviewResult, ExportUnavailable>;
readonly status: Effect.Effect<EngineStatus>;
/** The SSE feed: a full EngineStatus on every engine transition. */
readonly statusChanges: Stream.Stream<EngineStatus>;
/** Where the export was found — the CLI's `where`, without a full read. */
readonly locate: Effect.Effect<Location>;
/**
* Read one local cover, but ONLY if the currently-ingested export references it.
* `undefined` for anything else — the browser cannot ask this for an arbitrary file.
*/
readonly art: (file: string) => Effect.Effect<ArtFile | undefined>;
}
export class PlayniteSync extends Context.Service<
PlayniteSync,
PlayniteSyncService
>()("playnite/PlayniteSync") {
// Layer.effect runs `make` in the LAYER's scope (Scope is excluded from R) — the
// engine's watch/poll loops live exactly as long as the plugin runtime.
static readonly layer: Layer.Layer<
PlayniteSync,
never,
PlayniteConfig | PlayniteCache | ProviderClient
> = Layer.effect(PlayniteSync)(make());
}
const NO_LOCATION: Location = {
dir: null,
candidates: [],
file: null,
mtime: null,
viaIngest: false,
};
/** The art allow-list, pinned to the export document it was derived from. */
interface AllowList {
readonly file: string;
readonly mtime: number;
readonly paths: ReadonlySet<string>;
}
function make(): Effect.Effect<
PlayniteSyncService,
never,
PlayniteConfig | PlayniteCache | ProviderClient | Scope.Scope
> {
return Effect.gen(function* () {
const cfg = yield* PlayniteConfig;
const cache = yield* PlayniteCache;
const provider = yield* ProviderClient;
const platform = process.platform;
const ingest = pluginIngestDir(PLUGIN_NAME);
// Observed state the status view reports (the old Engine's private fields).
const lastLocation = yield* Ref.make<Location>(NO_LOCATION);
const lastError = yield* Ref.make<string | undefined>(undefined);
const lastPlaynite = yield* Ref.make<PlayniteInfo | undefined>(undefined);
const allowList = yield* Ref.make<AllowList | undefined>(undefined);
const loadOrDefault = cfg.load.pipe(
Effect.orElseSucceed(() => resolveConfig({})),
);
/** Locate + read + decode, refreshing everything the status view and the art proxy
* derive from the export. The one place an export failure is recorded. */
const readCurrent = (
config: Config,
): Effect.Effect<LibraryExport, ExportUnavailable> =>
Effect.gen(function* () {
const location = locateExport(config);
yield* Ref.set(lastLocation, location);
if (location.file === null) {
const message = `no exporter output under ${location.dir ?? "any Playnite data dir"} — is the Punktfunk Sync extension installed in Playnite?`;
yield* Ref.set(lastError, message);
return yield* Effect.fail(new ExportUnavailable({ message }));
}
const file = location.file;
const exp = yield* Effect.try({
try: () => readExport(file),
catch: (cause) =>
new ExportUnavailable({
message: cause instanceof Error ? cause.message : String(cause),
}),
}).pipe(Effect.tapError((e) => Ref.set(lastError, e.message)));
yield* Ref.set(lastError, undefined);
yield* Ref.set(lastPlaynite, exp.playnite);
yield* Ref.set(allowList, {
file,
mtime: location.mtime ?? 0,
paths: artPaths(exp),
});
return exp;
});
const computeWith = (
embedArt: boolean,
): Effect.Effect<
{ readonly entries: ProviderEntry[]; readonly report: SyncReport },
ExportUnavailable
> =>
Effect.gen(function* () {
const config = yield* loadOrDefault;
const exp = yield* readCurrent(config);
return computeEntries({
exp,
config,
platform,
// The preview skips the (heavy, in `dataurl` mode) art embed.
artFor: embedArt
? (g) => buildArtwork(g, config.art)
: () => undefined,
});
});
const engine = yield* makeSyncEngine<
SyncReport,
ReadonlyArray<ProviderEntry>,
never
>({
compute: () => computeWith(true),
apply: (entries) => provider.reconcile(PROVIDER_ID, entries),
lastSync: {
get: cache.get.pipe(Effect.map((c) => c.lastSync)),
set: (last: LastSync) =>
cache.update((c) => ({ ...c, lastSync: last })).pipe(Effect.ignore),
},
settings: loadOrDefault.pipe(
Effect.map((config) => ({
pollInterval: Duration.minutes(Math.max(1, config.sync.pollMinutes)),
watch: config.sync.watch,
debounce: Duration.millis(config.sync.debounceMs),
watchDirs: watchTargets(config),
})),
),
});
/**
* What to watch: the ingest inbox ALWAYS (the de-privileged runner's only source,
* so an exporter drop reconciles within seconds even when the Playnite dir resolved
* elsewhere), plus the located Playnite dir's `ExtensionsData` when there is one.
* The kit's watcher set is best-effort — an absent dir is silently skipped and the
* interval poll covers it.
*/
function watchTargets(config: Config): string[] {
const targets = new Set<string>([ingest]);
const located = locateExport(config);
if (located.dir !== null && !located.viaIngest) {
const extData = path.join(located.dir, "ExtensionsData");
targets.add(fs.existsSync(extData) ? extData : located.dir);
}
return [...targets];
}
const status: Effect.Effect<EngineStatus> = Effect.gen(function* () {
const config = yield* loadOrDefault;
const engineStatus = yield* engine.status;
const location = yield* Ref.get(lastLocation);
const exportError = yield* Ref.get(lastError);
const playnite = yield* Ref.get(lastPlaynite);
return {
platform,
// Before the first sync the status view would otherwise show nothing at all.
location: location.file === null ? locateExport(config) : location,
// Explicit nulls, never undefined — see the trap note in contract/domain.ts.
exportError: exportError ?? null,
playnite: playnite ?? null,
artMode: config.art.mode,
syncing: engineStatus.syncing,
lastSync: engineStatus.lastSync ?? null,
lastReport: engineStatus.lastReport ?? null,
paths: {
dir: path.dirname(cfg.path),
config: cfg.path,
cache: cache.path,
ingest: path.join(ingest, EXPORT_FILE),
},
} satisfies EngineStatus;
});
const art = (file: string): Effect.Effect<ArtFile | undefined> =>
Effect.gen(function* () {
const cached = yield* Ref.get(allowList);
const config = yield* loadOrDefault;
const located = locateExport(config);
// Refresh when we have never read an export, or the file moved under us —
// otherwise a cover added since the last sync would 404 until the next one.
const stale =
cached === undefined ||
located.file !== cached.file ||
(located.mtime ?? 0) !== cached.mtime;
if (stale) yield* readCurrent(config).pipe(Effect.ignore);
const allow = yield* Ref.get(allowList);
if (allow === undefined || !allow.paths.has(file)) return undefined;
const contentType = mimeFor(file);
if (contentType === null) return undefined;
return yield* Effect.try(
(): ArtFile => ({ bytes: fs.readFileSync(file), contentType }),
).pipe(Effect.orElseSucceed(() => undefined));
});
return {
engine,
preview: computeWith(false),
status,
// A fresh subscriber gets the CURRENT status immediately, then one frame per
// engine transition — so the console's live view is right the moment it
// connects, rather than blank until the next sync. Each frame is a full
// snapshot, so the sliver between the two stages costs nothing.
statusChanges: Stream.concat(
Stream.fromEffect(status),
engine.changes.pipe(Stream.mapEffect(() => status)),
),
locate: loadOrDefault.pipe(Effect.map(locateExport)),
art,
} satisfies PlayniteSyncService;
});
}
+51 -3
View File
@@ -2,9 +2,8 @@ import { afterAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { buildArtwork, fileToDataUrl } from "../src/art.js"; import type { ArtOptions, ExportedGame } from "@playnite/contract";
import { DEFAULT_ART } from "../src/config.js"; import { artPaths, buildArtwork, fileToDataUrl } from "../src/domain/art.js";
import type { ExportedGame } from "../src/export-schema.js";
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-art-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-art-"));
const write = (name: string, bytes: Buffer): string => { const write = (name: string, bytes: Buffer): string => {
@@ -14,6 +13,12 @@ const write = (name: string, bytes: Buffer): string => {
}; };
afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));
const DEFAULT_ART: ArtOptions = {
mode: "host",
maxBytes: 1_500_000,
includeBackground: false,
};
const game = (over: Partial<ExportedGame>): ExportedGame => ({ const game = (over: Partial<ExportedGame>): ExportedGame => ({
id: "11111111-1111-1111-1111-111111111111", id: "11111111-1111-1111-1111-111111111111",
name: "Game", name: "Game",
@@ -49,6 +54,11 @@ describe("fileToDataUrl", () => {
true, true,
); );
}); });
test("a remote reference passes through untouched", () => {
expect(fileToDataUrl("https://cdn.example/cover.jpg", 10)).toBe(
"https://cdn.example/cover.jpg",
);
});
}); });
describe("buildArtwork", () => { describe("buildArtwork", () => {
@@ -106,3 +116,41 @@ describe("buildArtwork", () => {
).toBeUndefined(); ).toBeUndefined();
}); });
}); });
describe("artPaths (the /api/art allow-list)", () => {
const exp = (games: ExportedGame[]) => ({
schema: 1,
generatedAt: "2026-01-01T00:00:00Z",
playnite: { version: "10", mode: "Desktop" },
games,
});
test("collects every local cover/background/icon the export references", () => {
const paths = artPaths(
exp([
game({ cover: "/a/cover.png", background: "/a/bg.jpg" }),
game({ icon: "/a/icon.ico" }),
]),
);
expect([...paths].sort()).toEqual([
"/a/bg.jpg",
"/a/cover.png",
"/a/icon.ico",
]);
});
test("excludes remote references and unservable extensions", () => {
const paths = artPaths(
exp([
game({ cover: "https://cdn.example/cover.jpg" }),
game({ cover: "/a/notes.txt" }),
]),
);
expect(paths.size).toBe(0);
});
test("a path the export never mentions is not allow-listed", () => {
const paths = artPaths(exp([game({ cover: "/a/cover.png" })]));
expect(paths.has("/etc/shadow")).toBe(false);
});
});
+73
View File
@@ -0,0 +1,73 @@
// The config contract: defaults live ONLY in the schema, and the Encoded side is the
// authored file shape. A regression here would let a UI save bake defaults into the
// operator's config.json (the raw round-trip invariant the kit's ConfigService relies on).
import { describe, expect, test } from "bun:test";
import type { RawConfig } from "@playnite/contract";
import { PlayniteConfigSchema, resolveConfig } from "@playnite/contract";
import { Schema } from "effect";
const encode = Schema.encodeUnknownSync(PlayniteConfigSchema);
describe("resolveConfig", () => {
test("an empty file resolves to the documented defaults", () => {
expect(resolveConfig({})).toEqual({
sync: { pollMinutes: 5, watch: true, debounceMs: 1500 },
filter: {
installedOnly: true,
includeHidden: false,
sources: [],
excludeSources: [],
},
art: { mode: "host", maxBytes: 1_500_000, includeBackground: false },
gameOverrides: {},
});
});
test("a partial group keeps the untouched keys at their defaults", () => {
const config = resolveConfig({ art: { mode: "dataurl" } });
expect(config.art.mode).toBe("dataurl");
expect(config.art.maxBytes).toBe(1_500_000);
expect(config.sync.pollMinutes).toBe(5);
});
test("stale keys from an older config are tolerated and dropped", () => {
// 0.1.x wrote `ui` and `devEntry`; both are gone in 0.2.0.
const config = resolveConfig({
ui: { standalone: true, port: 47994 },
devEntry: true,
filter: { installedOnly: false },
});
expect(config.filter.installedOnly).toBe(false);
expect("ui" in config).toBe(false);
expect("devEntry" in config).toBe(false);
});
test("an invalid art mode is refused", () => {
expect(() => resolveConfig({ art: { mode: "inline" } })).toThrow();
});
});
describe("raw round-trip", () => {
test("encoding a resolved config omits every defaulted key", () => {
// This is what makes a UI save non-destructive: defaults never reach the file.
expect(encode(resolveConfig({}))).toEqual({});
});
test("encode is LOSSY for defaulted groups — which is why we never re-encode", () => {
// `encodingStrategy: "omit"` drops a defaulted key unconditionally, so a decode →
// encode round-trip would silently throw away authored values. The kit's
// ConfigService therefore persists the RAW payload verbatim and only *validates*
// by decoding; nothing in the plugin may write `encode(config)` back to disk.
const raw: RawConfig = {
playniteDir: "D:\\Playnite",
filter: { excludeSources: ["Xbox"] },
gameOverrides: {
"11111111-1111-1111-1111-111111111111": { exclude: true },
},
};
// Undefaulted keys survive…
expect(encode(resolveConfig(raw))).toEqual({ playniteDir: "D:\\Playnite" });
// …but the authored values themselves are only safe in the raw shape.
expect(resolveConfig(raw).filter.excludeSources).toEqual(["Xbox"]);
});
});
@@ -2,9 +2,8 @@ import { afterAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { resolveConfig } from "../src/config.js"; import { EXPORT_FILE, resolveConfig } from "@playnite/contract";
import { EXPORT_FILE } from "../src/export-schema.js"; import { ExportError, locateExport, readExport } from "../src/domain/locate.js";
import { ExportError, locateExport, readExport } from "../src/playnite.js";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-loc-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-loc-"));
afterAll(() => fs.rmSync(root, { recursive: true, force: true })); afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
@@ -14,8 +13,9 @@ const layout = (name: string, doc: unknown): string => {
const dir = path.join(root, name); const dir = path.join(root, name);
const ext = path.join(dir, "ExtensionsData", "abc-guid"); const ext = path.join(dir, "ExtensionsData", "abc-guid");
fs.mkdirSync(ext, { recursive: true }); fs.mkdirSync(ext, { recursive: true });
if (doc !== undefined) if (doc !== undefined) {
fs.writeFileSync(path.join(ext, EXPORT_FILE), JSON.stringify(doc)); fs.writeFileSync(path.join(ext, EXPORT_FILE), JSON.stringify(doc));
}
return dir; return dir;
}; };
@@ -35,6 +35,7 @@ describe("locateExport", () => {
path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE), path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE),
); );
expect(loc.mtime).toBeGreaterThan(0); expect(loc.mtime).toBeGreaterThan(0);
expect(loc.viaIngest).toBe(false);
}); });
test("reports the dir but no file when the exporter hasn't written yet", () => { test("reports the dir but no file when the exporter hasn't written yet", () => {
@@ -58,6 +59,9 @@ describe("locateExport", () => {
const loc = locateExport(resolveConfig({ playniteDir: pd })); const loc = locateExport(resolveConfig({ playniteDir: pd }));
expect(loc.dir).toBe(inbox); expect(loc.dir).toBe(inbox);
expect(loc.file).toBe(path.join(inbox, EXPORT_FILE)); expect(loc.file).toBe(path.join(inbox, EXPORT_FILE));
expect(loc.viaIngest).toBe(true);
// The inbox always leads the probe list, so the UI can show it as the target.
expect(loc.candidates[0]).toBe(inbox);
} finally { } finally {
if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR; if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = prev; else process.env.PUNKTFUNK_CONFIG_DIR = prev;
@@ -73,6 +77,25 @@ describe("readExport", () => {
expect(readExport(file).schema).toBe(1); expect(readExport(file).schema).toBe(1);
}); });
test("fills defaults for fields an older exporter omits", () => {
const dir = layout("sparse", { schema: 1, games: [{ id: "abc" }] });
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
const exp = readExport(file);
expect(exp.generatedAt).toBe("");
expect(exp.playnite).toEqual({ version: null, mode: null });
expect(exp.games[0]).toEqual({
id: "abc",
name: "",
installed: false,
hidden: false,
source: null,
platforms: [],
cover: null,
background: null,
icon: null,
});
});
test("rejects a future schema", () => { test("rejects a future schema", () => {
const dir = layout("future", { ...validDoc, schema: 99 }); const dir = layout("future", { ...validDoc, schema: 99 });
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE); const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
@@ -88,4 +111,8 @@ describe("readExport", () => {
fs.writeFileSync(foreign, JSON.stringify({ hello: "world" })); fs.writeFileSync(foreign, JSON.stringify({ hello: "world" }));
expect(() => readExport(foreign)).toThrow(ExportError); expect(() => readExport(foreign)).toThrow(ExportError);
}); });
test("rejects a missing file", () => {
expect(() => readExport(path.join(root, "nope.json"))).toThrow(ExportError);
});
}); });
@@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { resolveConfig } from "../src/config.js"; import type { ExportedGame, LibraryExport } from "@playnite/contract";
import { resolveConfig } from "@playnite/contract";
import { import {
computeEntries, computeEntries,
isGuid, isGuid,
launchCommand, launchCommand,
} from "../src/engine/reconcile.js"; } from "../src/domain/reconcile.js";
import type { ExportedGame, LibraryExport } from "../src/export-schema.js";
const G1 = "11111111-1111-1111-1111-111111111111"; const G1 = "11111111-1111-1111-1111-111111111111";
const G2 = "22222222-2222-2222-2222-222222222222"; const G2 = "22222222-2222-2222-2222-222222222222";
@@ -56,10 +56,9 @@ describe("launchCommand", () => {
describe("computeEntries filtering", () => { describe("computeEntries filtering", () => {
test("installedOnly drops uninstalled games", () => { test("installedOnly drops uninstalled games", () => {
const config = resolveConfig({});
const { entries, report } = computeEntries({ const { entries, report } = computeEntries({
exp: exp([game({ id: G1 }), game({ id: G2, installed: false })]), exp: exp([game({ id: G1 }), game({ id: G2, installed: false })]),
config, config: resolveConfig({}),
platform: "win32", platform: "win32",
artFor: noArt, artFor: noArt,
}); });
@@ -146,4 +145,82 @@ describe("computeEntries filtering", () => {
}); });
expect(entries[0]?.art?.portrait).toBe("data:image/png;base64,AAAA"); expect(entries[0]?.art?.portrait).toBe("data:image/png;base64,AAAA");
}); });
test("the report carries the export's stamp", () => {
const { report } = computeEntries({
exp: exp([game({ id: G1 })]),
config: resolveConfig({}),
platform: "win32",
artFor: noArt,
});
expect(report.generatedAt).toBe("2026-01-01T00:00:00Z");
expect(report.schemaVersion).toBe(1);
expect(report.considered).toBe(1);
});
});
describe("per-game overrides", () => {
test("an excluded game lands in `excluded`, not `skipped`", () => {
const { entries, report } = computeEntries({
exp: exp([
game({ id: G1, name: "Kept" }),
game({ id: G2, name: "Dropped" }),
]),
config: resolveConfig({ gameOverrides: { [G2]: { exclude: true } } }),
platform: "win32",
artFor: noArt,
});
expect(entries.map((e) => e.title)).toEqual(["Kept"]);
expect(report.excluded).toEqual([{ id: G2, title: "Dropped" }]);
expect(report.skipped).toEqual([]);
});
test("`exclude: false` is a no-op (the UI omits the key instead)", () => {
const { entries } = computeEntries({
exp: exp([game({ id: G1 })]),
config: resolveConfig({ gameOverrides: { [G1]: { exclude: false } } }),
platform: "win32",
artFor: noArt,
});
expect(entries.length).toBe(1);
});
test("a game already filtered out is not double-reported as excluded", () => {
const { report } = computeEntries({
exp: exp([game({ id: G1, installed: false })]),
config: resolveConfig({ gameOverrides: { [G1]: { exclude: true } } }),
platform: "win32",
artFor: noArt,
});
expect(report.excluded).toEqual([]);
expect(report.skipped[0]?.reason).toBe("not installed");
});
});
describe("dataurl guard rail", () => {
test("warns once the inlined-cover payload would be too large", () => {
const many = Array.from({ length: 3001 }, (_, i) =>
game({
id: `${i.toString(16).padStart(8, "0")}-1111-1111-1111-111111111111`,
name: `Game ${i}`,
}),
);
const { report } = computeEntries({
exp: exp(many),
config: resolveConfig({ art: { mode: "dataurl" } }),
platform: "win32",
artFor: noArt,
});
expect(report.warnings[0]).toContain('art.mode "dataurl"');
});
test("host mode never warns", () => {
const { report } = computeEntries({
exp: exp([game({ id: G1 })]),
config: resolveConfig({ art: { mode: "host" } }),
platform: "win32",
artFor: noArt,
});
expect(report.warnings).toEqual([]);
});
}); });
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"types": ["bun"]
},
"include": ["src", "test"]
}
+6
View File
@@ -0,0 +1,6 @@
// The published type surface: a plugin is a runtime artifact — the only thing anyone
// imports from the package is the default PluginDef (the runner's discovery contract).
import type { PluginDef } from "@punktfunk/host";
declare const plugin: PluginDef;
export default plugin;
-70
View File
@@ -1,70 +0,0 @@
// Cover art. Playnite stores art as local files on the host. Two delivery modes (see ArtMode):
// - `host` (default): emit the local file PATH; the updated host serves the bytes via its art proxy
// (`/library/art/…`), so the reconcile payload carries no image data and scales to any library size.
// - `dataurl`: inline the cover as a size-capped `data:` URL (self-contained, but only fit for small
// libraries — a big one blows past the host's reconcile body limit). `maxBytes` caps a single image.
import * as fs from "node:fs";
import * as path from "node:path";
import type { ArtOptions } from "./config.js";
import type { ExportedGame } from "./export-schema.js";
import type { Artwork } from "./wire.js";
const MIME: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".ico": "image/x-icon",
".tga": "image/x-tga",
};
const mimeFor = (file: string): string | null =>
MIME[path.extname(file).toLowerCase()] ?? null;
/** Read a local image file into a `data:` URL, or null if missing, oversized, or an unknown type. */
export const fileToDataUrl = (
file: string | null,
maxBytes: number,
): string | null => {
if (!file) return null;
const mime = mimeFor(file);
if (!mime) return null;
try {
const st = fs.statSync(file);
if (!st.isFile() || st.size === 0 || st.size > maxBytes) return null;
const b64 = fs.readFileSync(file).toString("base64");
return `data:${mime};base64,${b64}`;
} catch {
return null;
}
};
/** Build the artwork block for a game per the art config. Returns undefined when nothing was embedded. */
export const buildArtwork = (
game: ExportedGame,
art: ArtOptions,
): Artwork | undefined => {
if (art.mode === "off") return undefined;
// `host` mode: emit the local file paths verbatim (the exporter only sets a path when the file
// exists). The host serves the bytes via its art proxy, so the reconcile payload carries no image
// data and scales to any library size. The default.
if (art.mode === "host") {
const out: Artwork = {};
if (game.cover) out.portrait = game.cover;
if (art.includeBackground && game.background) out.hero = game.background;
return out.portrait || out.hero ? out : undefined;
}
// `dataurl` mode: inline (size-capped) — self-contained but only fit for small libraries.
const portrait = fileToDataUrl(game.cover, art.maxBytes);
const hero = art.includeBackground
? fileToDataUrl(game.background, art.maxBytes)
: null;
if (!portrait && !hero) return undefined;
const out: Artwork = {};
if (portrait) out.portrait = portrait;
if (hero) out.hero = hero;
return out;
};
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env bun
// Dev/debug CLI — reuses the pure core and the engine. `where`/`preview` are offline (no host needed);
// `sync`/`uninstall` connect to the host and reconcile. Handy for configuring a headless box or
// checking what a `config.json` will produce before pushing it to the library.
//
// bunx punktfunk-plugin-playnite where # where the exporter output was found
// 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 all this plugin's entries
// bunx punktfunk-plugin-playnite set-password <pw> # standalone-UI password
import { connect } from "@punktfunk/host";
import { Engine } from "./engine/index.js";
import { computeEntries } from "./engine/reconcile.js";
import { locateExport, readExport } from "./playnite.js";
import { hashPassword } from "./server/password.js";
import { loadConfig, saveConfig } from "./state.js";
const cmdWhere = (): void => {
const config = loadConfig();
const loc = locateExport(config);
console.log(`Playnite data dir: ${loc.dir ?? "(not found)"}`);
console.log(`Export file: ${loc.file ?? "(not found)"}`);
if (loc.mtime)
console.log(`Last written: ${new Date(loc.mtime).toISOString()}`);
console.log("Candidates probed:");
for (const c of loc.candidates) console.log(` ${c}`);
if (!loc.file) {
console.log(
"\nNo export found. Install the Punktfunk Sync extension in Playnite (see exporter/README.md),",
);
console.log("or set `playniteDir` in the plugin config.json.");
}
};
const cmdPreview = (): void => {
const config = loadConfig();
const loc = locateExport(config);
if (!loc.file) {
console.log("No exporter output found — run `where` for details.");
process.exitCode = 1;
return;
}
const exp = readExport(loc.file);
const { entries, report } = computeEntries({
exp,
config,
artFor: () => undefined,
});
console.log(
`Preview: ${report.included}/${report.considered} game(s) would sync (${report.skipped.length} skipped).`,
);
const bySource = Object.entries(report.perSource).sort((a, b) => b[1] - a[1]);
for (const [s, n] of bySource) console.log(` ${s}: ${n}`);
console.log();
for (const e of entries.slice(0, 40))
console.log(` ${e.title}${e.launch?.value ?? "(no launch)"}`);
if (entries.length > 40) console.log(` … and ${entries.length - 40} more`);
if (report.skipped.length) {
console.log(`\n${report.skipped.length} skipped (first 20):`);
for (const s of report.skipped.slice(0, 20))
console.log(` ${s.title}: ${s.reason}`);
}
for (const w of report.warnings) console.log(`! ${w}`);
};
const cmdSync = async (): Promise<void> => {
const pf = await connect();
try {
const report = await new Engine({ pf }).sync("cli");
if (report) {
console.log(
`Synced: ${report.included} title(s), ${report.skipped.length} skipped.`,
);
} else {
console.log("Sync did not reconcile (no change, or see log).");
}
} finally {
pf.close();
}
};
const cmdUninstall = async (): Promise<void> => {
const pf = await connect();
try {
await new Engine({ pf }).uninstall();
console.log("Removed all playnite entries from the library.");
} finally {
pf.close();
}
};
const cmdSetPassword = (password: string | undefined): void => {
if (!password) {
console.error("usage: set-password <password>");
process.exitCode = 2;
return;
}
const config = loadConfig();
config.ui.passwordHash = hashPassword(password);
saveConfig(config);
console.log("Standalone-UI password set (config.ui.passwordHash).");
};
const main = async (): Promise<void> => {
const [cmd, arg] = process.argv.slice(2);
switch (cmd) {
case "where":
return cmdWhere();
case "preview":
return cmdPreview();
case "sync":
return cmdSync();
case "uninstall":
return cmdUninstall();
case "set-password":
return cmdSetPassword(arg);
default:
console.log(
"usage: punktfunk-plugin-playnite <where|preview|sync|uninstall|set-password>",
);
process.exitCode = cmd ? 2 : 0;
}
};
await main();
-117
View File
@@ -1,117 +0,0 @@
// The plugin's configuration model — the single source of truth for the sync engine, editable by hand
// (headless boxes) or by the web UI. Everything the engine needs is derivable from this file plus the
// exporter's `punktfunk-library.json`; `resolveConfig` fills defaults so the rest of the code works
// against a fully-populated shape.
/** When and how often we re-read the export and reconcile. */
export interface SyncOptions {
/** Interval poll in minutes — the backstop under the exporter-driven file watch. */
pollMinutes: number;
/** Watch the exporter's output directory and reconcile on change (the primary trigger). */
watch: boolean;
/** Debounce window for coalescing watch/poll-triggered re-reads (ms). */
debounceMs: number;
}
/** Which Playnite games become library entries. */
export interface FilterOptions {
/** Only sync games Playnite marks installed (default). Off = include not-yet-installed titles too. */
installedOnly: boolean;
/** Include games flagged Hidden in Playnite (default off). */
includeHidden: boolean;
/** If non-empty, keep ONLY games whose source is in this list ("Steam", "GOG", …). */
sources: string[];
/** Drop games whose source is in this list (applied after `sources`). */
excludeSources: string[];
}
/** How cover art reaches the clients. Playnite art is local files on the host:
* - `host` (default): send the local file PATH; the host serves the bytes through its art proxy
* (`/library/art/…`, like Steam art). The reconcile payload carries no image data, so this scales
* to libraries of any size — the recommended mode.
* - `dataurl`: inline each cover as a size-capped `data:` URL. Self-contained but does NOT scale — a
* large library blows past the host's reconcile body limit. Small libraries only.
* - `off`: sync titles with no art. */
export type ArtMode = "host" | "dataurl" | "off";
export interface ArtOptions {
mode: ArtMode;
/** Skip an image whose file is larger than this (bytes) — a `data:` URL bloats the library payload,
* so covers over the cap are dropped rather than inlined. */
maxBytes: number;
/** Also inline the Playnite background as `hero` art (usually large — off by default). */
includeBackground: boolean;
}
export interface UiOptions {
/** Fallback standalone mode: serve the UI on a fixed port behind a password instead of via the
* console proxy. For host-only installs without the console. */
standalone: boolean;
/** Standalone bind port. */
port: number;
/** Standalone bind address (defaults to loopback; a non-loopback bind REQUIRES a password). */
bind: string;
/** scrypt password hash (`scrypt$<saltB64>$<hashB64>`), required for a non-loopback standalone bind. */
passwordHash?: string;
}
/** The on-disk config as authored (all optional — `resolveConfig` fills the rest). */
export interface RawConfig {
/** Override the auto-detected Playnite data directory (`%APPDATA%\Playnite`). Point it at a
* portable install, or at the interactive user's profile when the runner runs as a different user. */
playniteDir?: string;
sync?: Partial<SyncOptions>;
filter?: Partial<FilterOptions>;
art?: Partial<ArtOptions>;
ui?: Partial<UiOptions>;
/** Dev/acceptance flag: reconcile one hardcoded entry so the round-trip is observable. */
devEntry?: boolean;
}
/** The fully-resolved config the engine consumes. */
export interface Config {
playniteDir?: string;
sync: SyncOptions;
filter: FilterOptions;
art: ArtOptions;
ui: UiOptions;
devEntry: boolean;
}
export const DEFAULT_SYNC: SyncOptions = {
pollMinutes: 5,
watch: true,
debounceMs: 1500,
};
export const DEFAULT_FILTER: FilterOptions = {
installedOnly: true,
includeHidden: false,
sources: [],
excludeSources: [],
};
export const DEFAULT_ART: ArtOptions = {
mode: "host",
maxBytes: 1_500_000,
includeBackground: false,
};
export const DEFAULT_UI: UiOptions = {
standalone: false,
port: 47994,
bind: "127.0.0.1",
};
/** Fill defaults over an authored config so the rest of the engine sees a total shape. */
export const resolveConfig = (raw: RawConfig): Config => ({
playniteDir: raw.playniteDir?.trim() || undefined,
sync: { ...DEFAULT_SYNC, ...raw.sync },
filter: { ...DEFAULT_FILTER, ...raw.filter },
art: { ...DEFAULT_ART, ...raw.art },
ui: { ...DEFAULT_UI, ...raw.ui },
devEntry: raw.devEntry ?? false,
});
/** The empty starting config for a fresh install. */
export const emptyConfig = (): Config => resolveConfig({});
-328
View File
@@ -1,328 +0,0 @@
// The sync engine: the headless half of the plugin. It ties the pure core (locate → read → compute)
// to the real world — reading the exporter's `punktfunk-library.json`, embedding cover art, and the
// host reconcile PUT — and drives it on start, on an interval poll, on export-file changes (debounced),
// and on demand from the UI/CLI. Fully functional from a hand-written `config.json` alone.
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import type { Punktfunk } from "@punktfunk/host";
import { buildArtwork } from "../art.js";
import type { Config } from "../config.js";
import type { ExportedGame } from "../export-schema.js";
import { deleteProvider, reconcileProvider } from "../host.js";
import { type Logger, makeLogger } from "../log.js";
import { ingestDir } from "../paths.js";
import {
ExportError,
type Location,
locateExport,
readExport,
} from "../playnite.js";
import {
type Cache,
loadCache,
loadConfig,
saveCache,
statePaths,
} from "../state.js";
import type { ProviderEntryInput } from "../wire.js";
import { computeEntries, type SyncReport } from "./reconcile.js";
export interface EngineDeps {
pf: Punktfunk;
platform?: NodeJS.Platform;
log?: Logger;
}
export interface EngineStatus {
platform: NodeJS.Platform;
/** Where the export is (or why it wasn't found) — drives the console's "connected?" banner. */
location: Location;
/** Last read error (schema/corruption), if any. */
exportError?: string;
lastSync?: Cache["lastSync"];
lastReport?: SyncReport;
paths: ReturnType<typeof statePaths>;
syncing: boolean;
}
/** A stable fingerprint of (export bytes + the config knobs that affect output). Lets an unchanged
* re-read skip the expensive art-embed + PUT entirely. */
const fingerprint = (bytes: Buffer, config: Config): string =>
createHash("sha256")
.update(bytes)
.update(
JSON.stringify({
filter: config.filter,
art: config.art,
devEntry: config.devEntry,
platform: process.platform,
}),
)
.digest("hex");
/** A synthetic entry proving the reconcile round-trip end-to-end (dev flag / acceptance). */
const devEntry = (platform: NodeJS.Platform): ProviderEntryInput => ({
external_id: "00000000-0000-0000-0000-000000000000",
title: "Playnite (dev entry)",
launch: {
kind: "command",
value: platform === "win32" ? "cmd /c echo hi" : "echo hi",
},
});
export class Engine {
private readonly pf: Punktfunk;
private readonly platform: NodeJS.Platform;
private readonly log: Logger;
private cache: Cache;
private syncing = false;
private pending = false;
private lastReport?: SyncReport;
private lastLocation: Location = {
dir: null,
candidates: [],
file: null,
mtime: null,
};
private lastError?: string;
private pollTimer?: ReturnType<typeof setInterval>;
private debounceTimer?: ReturnType<typeof setTimeout>;
private watchers: fs.FSWatcher[] = [];
private readonly listeners = new Set<(status: EngineStatus) => void>();
constructor(deps: EngineDeps) {
this.pf = deps.pf;
this.platform = deps.platform ?? process.platform;
this.log = deps.log ?? makeLogger();
this.cache = loadCache();
}
// ---------------------------------------------------------------- lifecycle
async start(): Promise<void> {
await this.sync("startup");
this.schedulePoll();
this.setupWatchers();
}
stop(): void {
if (this.pollTimer) clearInterval(this.pollTimer);
if (this.debounceTimer) clearTimeout(this.debounceTimer);
for (const w of this.watchers) {
try {
w.close();
} catch {
// already closed
}
}
this.watchers = [];
}
private schedulePoll(): void {
const config = loadConfig();
const ms = Math.max(1, config.sync.pollMinutes) * 60_000;
if (this.pollTimer) clearInterval(this.pollTimer);
this.pollTimer = setInterval(() => void this.sync("poll"), ms);
(this.pollTimer as { unref?: () => void }).unref?.();
}
/** Watch the exporter's `ExtensionsData` tree so a library change reconciles within seconds. */
private setupWatchers(): void {
for (const w of this.watchers) {
try {
w.close();
} catch {
// ignore
}
}
this.watchers = [];
const config = loadConfig();
if (!config.sync.watch) return;
const loc = locateExport(config);
const targets = new Set<string>();
// Always watch the ingest inbox when present — the de-privileged runner's source, so an
// exporter drop reconciles within seconds even when `loc.dir` resolved elsewhere (or nowhere).
const inbox = ingestDir();
if (fs.existsSync(inbox)) targets.add(inbox);
// Plus the located Playnite dir: prefer its ExtensionsData (where the file lives), else the
// dir itself so we notice the export appearing (same-user/SYSTEM runner, or Linux).
if (loc.dir) {
const extData = path.join(loc.dir, "ExtensionsData");
targets.add(fs.existsSync(extData) ? extData : loc.dir);
}
for (const target of targets) {
try {
this.watchers.push(
fs.watch(target, { recursive: true }, () => this.debouncedSync()),
);
} catch {
try {
this.watchers.push(fs.watch(target, () => this.debouncedSync()));
} catch {
this.log(`watch: cannot watch ${target} (poll only)`);
}
}
}
}
private debouncedSync(): void {
const config = loadConfig();
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(
() => void this.sync("fs-change"),
config.sync.debounceMs,
);
(this.debounceTimer as { unref?: () => void }).unref?.();
}
// ---------------------------------------------------------------- compute
/** Locate + read + compute WITHOUT embedding art or touching the host (the UI preview). */
computeDry(): { location: Location; report?: SyncReport; error?: string } {
const config = loadConfig();
const location = locateExport(config);
if (!location.file) {
return { location, error: "no exporter output found yet" };
}
try {
const exp = readExport(location.file);
const { report } = computeEntries({
exp,
config,
platform: this.platform,
artFor: () => undefined, // preview: skip the (heavy) art embed
});
return { location, report };
} catch (e) {
return { location, error: String(e) };
}
}
// ---------------------------------------------------------------- sync
/** The guarded reconcile: coalesces concurrent triggers, skips the read/embed/PUT when nothing changed. */
async sync(reason: string): Promise<SyncReport | undefined> {
if (this.syncing) {
this.pending = true;
return undefined;
}
this.syncing = true;
this.notify();
try {
const config = loadConfig();
const location = locateExport(config);
this.lastLocation = location;
if (!location.file) {
this.lastError = `no exporter output under ${location.dir ?? "any Playnite data dir"} — is the Punktfunk Sync extension installed in Playnite?`;
this.log(`sync (${reason}): ${this.lastError}`);
return undefined;
}
const bytes = fs.readFileSync(location.file);
const fp = fingerprint(bytes, config);
if (this.cache.lastSync?.fingerprint === fp) {
this.lastError = undefined;
this.log(
`sync (${reason}): no changes (${this.cache.lastSync.count} title(s))`,
);
return this.lastReport;
}
const exp = readExport(location.file);
const { entries, report } = computeEntries({
exp,
config,
platform: this.platform,
artFor: (g: ExportedGame) => buildArtwork(g, config.art),
});
const finalEntries = config.devEntry
? [...entries, devEntry(this.platform)]
: entries;
await reconcileProvider(this.pf, finalEntries);
this.cache.lastSync = {
fingerprint: fp,
count: finalEntries.length,
at: Date.now(),
};
saveCache(this.cache);
this.lastReport = report;
this.lastError = undefined;
this.log(
`sync (${reason}): reconciled ${finalEntries.length} title(s) (${report.skipped.length} skipped)`,
);
this.logReport(report);
return report;
} catch (e) {
this.lastError = e instanceof ExportError ? e.message : String(e);
this.log(`sync (${reason}) failed: ${this.lastError}`);
return undefined;
} finally {
this.syncing = false;
this.notify();
if (this.pending) {
this.pending = false;
queueMicrotask(() => void this.sync("coalesced"));
}
}
}
private logReport(report: SyncReport): void {
const bySource = Object.entries(report.perSource)
.sort((a, b) => b[1] - a[1])
.map(([s, n]) => `${s}:${n}`)
.join(" ");
if (bySource) this.log(` sources — ${bySource}`);
for (const w of report.warnings.slice(0, 5)) this.log(` ${w}`);
}
/** Reconfigure timers/watchers after a config change (e.g. the UI saved new filters). */
async reconfigure(): Promise<void> {
this.schedulePoll();
this.setupWatchers();
await this.sync("config-change");
}
/** Remove every entry this provider owns (CLI `uninstall`). */
async uninstall(): Promise<void> {
await deleteProvider(this.pf);
this.cache.lastSync = undefined;
saveCache(this.cache);
this.log("uninstalled: provider entries removed");
}
// ---------------------------------------------------------------- status
subscribe(cb: (status: EngineStatus) => void): () => void {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
private notify(): void {
const status = this.status();
for (const cb of this.listeners) {
try {
cb(status);
} catch {
// a dead SSE listener must not break sync
}
}
}
status(): EngineStatus {
return {
platform: this.platform,
location: this.lastLocation,
exportError: this.lastError,
lastSync: this.cache.lastSync,
lastReport: this.lastReport,
paths: statePaths(),
syncing: this.syncing,
};
}
}
-55
View File
@@ -1,55 +0,0 @@
// The contract between the two halves of this plugin. The C# Playnite exporter (see `exporter/`)
// writes a `punktfunk-library.json` in this exact shape into its Playnite plugin-data directory on
// every library change; this TypeScript plugin reads it and reconciles it into the host library.
//
// Keep this in lockstep with `exporter/PunktfunkSyncPlugin.cs` — the C# `LibraryExport`/`ExportedGame`
// DTOs serialise to these property names. Bump `SCHEMA` on any breaking change and gate on it in the
// reader; the exporter stamps the version it wrote.
/** The file the exporter writes (inside `…/Playnite/ExtensionsData/<pluginId>/`). */
export const EXPORT_FILE = "punktfunk-library.json";
/** Schema version this reader understands. The exporter stamps `schema`; a newer major is refused. */
export const SCHEMA = 1;
/** One game as the Playnite exporter sees it. Art fields are ABSOLUTE paths on the host box (already
* resolved from Playnite's database-relative form via `IPlayniteAPI.Database.GetFullFilePath`). */
export interface ExportedGame {
/** Playnite's stable game `Guid` (lowercase, no braces) — our reconcile `external_id`. */
id: string;
name: string;
/** Whether Playnite considers the game installed (drives the default `installedOnly` filter). */
installed: boolean;
/** Playnite's per-game "Hidden" flag. */
hidden: boolean;
/** The library source name ("Steam", "GOG", "Epic", "Xbox", emulator name, …) or null. */
source: string | null;
/** Platform display names ("PC (Windows)", "Nintendo Switch", …). */
platforms: string[];
/** Absolute path to the cover image, or null. */
cover: string | null;
/** Absolute path to the background image, or null. */
background: string | null;
/** Absolute path to the icon, or null. */
icon: string | null;
}
/** The whole export document. */
export interface LibraryExport {
schema: number;
/** ISO-8601 timestamp the exporter wrote this. */
generatedAt: string;
playnite: {
version: string | null;
/** "Desktop" | "Fullscreen" — informational. */
mode: string | null;
};
games: ExportedGame[];
}
/** A cheap structural check — enough to reject a truncated/foreign file before we trust it. */
export const isLibraryExport = (v: unknown): v is LibraryExport => {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return typeof o.schema === "number" && Array.isArray(o.games);
};
-20
View File
@@ -1,20 +0,0 @@
// The host side of the reconcile. We PUT the declarative entry list to the provider endpoint and let
// the host diff by `external_id` (stable ids, orphan drop, manual entries untouched). Done through
// `pf.request` rather than the typed `pf.api.*` deliberately: under the packaged runner the facade is
// built by the runner's *bundled* SDK copy, whose generated client may predate an endpoint — the
// untyped request seam is version-skew-proof (same reasoning as `servePluginUi`).
import type { Punktfunk } from "@punktfunk/host";
import { PROVIDER_ID } from "./const.js";
import type { ProviderEntryInput } from "./wire.js";
/** Full-replace reconcile: the host makes the library match `entries` exactly for this provider. */
export const reconcileProvider = (
pf: Punktfunk,
entries: ProviderEntryInput[],
): Promise<unknown> =>
pf.request("PUT", `/library/provider/${PROVIDER_ID}`, entries);
/** Clean uninstall: remove every entry this provider owns. */
export const deleteProvider = (pf: Punktfunk): Promise<unknown> =>
pf.request("DELETE", `/library/provider/${PROVIDER_ID}`);
-63
View File
@@ -1,63 +0,0 @@
// The plugin entry. `main` is the plain-async form (NOT the Effect form): the packaged runner bundles
// its own effect/SDK copy and cross-instance Effect identity is unverified, so the async facade
// sidesteps it entirely. The runner supervises this with restart-on-crash and a structured SIGTERM
// shutdown; a direct `bun src/index.ts` run works too (bottom of file).
//
// Lifecycle: connect (done by the runner/facade) → start the sync engine → serve the UI (console-hosted
// by default, standalone if configured) → run until SIGTERM → deregister the UI and stop the engine.
// Provider entries are deliberately LEFT in the library on shutdown so the games survive plugin
// restarts and host reboots; a clean uninstall is the explicit `uninstall` CLI command.
import { connect, definePlugin, type Punktfunk } from "@punktfunk/host";
import { PLUGIN_NAME } from "./const.js";
import { Engine } from "./engine/index.js";
import { log } from "./log.js";
import { serveConsoleUi } from "./server/index.js";
import { serveStandaloneUi } from "./server/standalone.js";
import { loadConfig } from "./state.js";
import { readVersion } from "./version.js";
const plugin = definePlugin({
name: PLUGIN_NAME,
main: async (pf: Punktfunk) => {
const engine = new Engine({ pf });
const config = loadConfig();
// Headless engine first — the library syncs even if the UI can't start.
await engine.start();
let closeUi: () => Promise<void> = async () => {};
try {
if (config.ui.standalone) {
const handle = serveStandaloneUi(engine, config);
closeUi = handle.close;
} else {
const handle = await serveConsoleUi(pf, engine, {
version: readVersion(),
});
closeUi = handle.close;
log(`UI registered with the console (nav entry "${PLUGIN_NAME}")`);
}
} catch (e) {
log(`UI server failed to start (engine keeps running headless): ${e}`);
}
await new Promise<void>((resolve) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
log("shutting down");
await closeUi();
engine.stop();
},
});
export default plugin;
// Allow a direct `bun src/index.ts` run outside the managed runner (dev).
if (import.meta.main) {
const pf = await connect();
await (plugin.main as (pf: Punktfunk) => Promise<void>)(pf);
pf.close();
}
-10
View File
@@ -1,10 +0,0 @@
// A tiny stamped logger, matching the runner's line format so the plugin's output reads consistently
// in the runner journal.
export type Logger = (line: string) => void;
export const makeLogger = (prefix = "playnite"): Logger => {
return (line: string) =>
console.log(`${new Date().toISOString()} [${prefix}] ${line}`);
};
export const log: Logger = makeLogger();
-75
View File
@@ -1,75 +0,0 @@
// Where the plugin's own files live. The host config-dir resolution mirrors the SDK's `configDir`
// (`@punktfunk/host` does not re-export it) so we always land in the same place the host and runner
// use; the plugin owns a `plugin-state/playnite/` subtree under it, created 0700.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
/** The host's config dir — the same resolution the host and SDK use. */
export const hostConfigDir = (): 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");
};
/**
* This plugin's private directory: `<config_dir>/plugin-state/playnite`.
*
* The state lives under `plugin-state/`, not straight under the config dir, because the managed
* runner is de-privileged on Windows (`NT AUTHORITY\LocalService`) and the config dir is locked
* read-only there — `punktfunk-host plugins enable` grants the runner write on exactly
* `plugin-state`. (Mirrors `@punktfunk/host`'s `pluginStateDir`; reimplemented locally like
* `hostConfigDir`, since we don't gate on an SDK version bump.) On Linux the runner owns the config
* dir, so the same path is writable with no special step.
*/
export const pluginDir = (): string =>
path.join(hostConfigDir(), "plugin-state", "playnite");
/** `<config_dir>/plugin-state/playnite/config.json` — operator-editable, atomic-written. */
export const configPath = (): string => path.join(pluginDir(), "config.json");
/** `<config_dir>/plugin-state/playnite/cache.json` — disposable derived state (last fingerprint). */
export const cachePath = (): string => path.join(pluginDir(), "cache.json");
/**
* The cross-account **ingest inbox** the Playnite exporter drops the library export into:
* `<config_dir>/ingest/playnite`. (Mirrors `@punktfunk/host`'s `pluginIngestDir`.)
*
* The exporter runs inside Playnite as the interactive user; the de-privileged LocalService runner
* cannot read that user's `%APPDATA%\Playnite\ExtensionsData\…`. `punktfunk-host plugins enable`
* makes `ingest` user-writable, so the exporter drops the file here for the runner to read. Read
* this BEFORE the ExtensionsData scan (which only works for a same-user/SYSTEM runner or on Linux).
*/
export const ingestDir = (): string =>
path.join(hostConfigDir(), "ingest", "playnite");
/**
* Locate the built SPA directory (`dist/ui`) at runtime. The package ships as ONE self-contained
* bundle so it installs from the Gitea registry with no external deps — which means `import.meta.url`
* can be `dist/index.js` (bundled) or `dist/server/index.js` (unbundled dev). We walk up from the
* calling module looking for a `ui/index.html`, so both layouts resolve. `fromUrl` is the caller's
* `import.meta.url`.
*/
export const resolveUiDir = (fromUrl: string): string => {
let dir = path.dirname(fileURLToPath(fromUrl));
for (let i = 0; i < 6; i++) {
for (const cand of [path.join(dir, "ui"), path.join(dir, "dist", "ui")]) {
try {
if (fs.existsSync(path.join(cand, "index.html"))) return cand;
} catch {
// keep walking
}
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return path.join(path.dirname(fileURLToPath(fromUrl)), "ui");
};
-162
View File
@@ -1,162 +0,0 @@
// Locating and reading the exporter's output. The companion Playnite extension (`exporter/`) writes
// `punktfunk-library.json` into its own Playnite plugin-data directory, i.e.
// `<PlayniteData>/ExtensionsData/<pluginId-guid>/punktfunk-library.json`. We don't hardcode that guid
// — we glob `ExtensionsData/*/punktfunk-library.json` and take the newest, so the reader is decoupled
// from the exporter's id.
//
// The plugin runs on the same box as Playnite (it's the streaming host), so these are plain local
// reads. The wrinkle is *which account* can see the file. The managed runner is de-privileged
// (`NT AUTHORITY\LocalService`) and cannot read the interactive user's `%APPDATA%`, so the reliable
// source is the **ingest inbox** (`<config_dir>/ingest/playnite/punktfunk-library.json`) the
// exporter drops the file into — checked FIRST. The `%APPDATA%`/`C:\Users\*` ExtensionsData scan
// still works for a same-user/SYSTEM runner or on Linux, and an explicit `playniteDir` override
// joins the candidate list.
import * as fs from "node:fs";
import * as path from "node:path";
import type { Config } from "./config.js";
import {
EXPORT_FILE,
isLibraryExport,
type LibraryExport,
SCHEMA,
} from "./export-schema.js";
import { ingestDir } from "./paths.js";
const exists = (p: string): boolean => {
try {
fs.accessSync(p);
return true;
} catch {
return false;
}
};
/** Candidate Playnite data directories, most-specific first (config override handled by the caller). */
export const candidatePlayniteDirs = (): string[] => {
const out: string[] = [];
if (process.platform === "win32") {
const appdata = process.env.APPDATA;
if (appdata) out.push(path.join(appdata, "Playnite"));
// SYSTEM/other-user runner: scan every local profile's roaming AppData.
const usersRoot = path.join(process.env.SystemDrive ?? "C:", "\\", "Users");
try {
for (const user of fs.readdirSync(usersRoot)) {
out.push(path.join(usersRoot, user, "AppData", "Roaming", "Playnite"));
}
} catch {
// no C:\Users (or not Windows-like) — fine
}
} else {
// Playnite is Windows-only, but a Wine/portable layout may set these — best effort.
const home = process.env.HOME;
if (home) {
out.push(path.join(home, ".local", "share", "Playnite"));
}
}
// De-dupe, preserve order.
return [...new Set(out)];
};
export interface Location {
/** The resolved Playnite data dir, or null if none found. */
dir: string | null;
/** The candidate dirs we considered (for the UI's diagnostics view). */
candidates: string[];
/** The resolved export file, or null if the exporter hasn't written one yet. */
file: string | null;
/** mtime (epoch ms) of the export file, or null. */
mtime: number | null;
}
/** Find the newest `punktfunk-library.json` under a Playnite dir's `ExtensionsData`. */
const findExportUnder = (
dir: string,
): { file: string; mtime: number } | null => {
const base = path.join(dir, "ExtensionsData");
let best: { file: string; mtime: number } | null = null;
let subdirs: string[];
try {
subdirs = fs.readdirSync(base);
} catch {
return null;
}
for (const sub of subdirs) {
const file = path.join(base, sub, EXPORT_FILE);
try {
const st = fs.statSync(file);
if (!best || st.mtimeMs > best.mtime) best = { file, mtime: st.mtimeMs };
} catch {
// no export in this extension's dir
}
}
return best;
};
/** Resolve where the export lives. `config.playniteDir` wins; otherwise probe the candidates. */
export const locateExport = (config: Config): Location => {
const override = config.playniteDir;
const candidates = override
? [override, ...candidatePlayniteDirs()]
: candidatePlayniteDirs();
// The ingest inbox first: the exporter drops the library here (a flat file, not an
// `ExtensionsData/<guid>` tree) so the de-privileged LocalService runner can read it — it can't
// reach the interactive user's `%APPDATA%`. This is the reliable path on a managed Windows host.
const inbox = ingestDir();
const inboxFile = path.join(inbox, EXPORT_FILE);
try {
const st = fs.statSync(inboxFile);
return {
dir: inbox,
candidates: [inbox, ...candidates],
file: inboxFile,
mtime: st.mtimeMs,
};
} catch {
// no ingest drop yet — fall back to scanning Playnite's own data dirs
}
// Then, a Playnite data dir that actually holds an export file (same-user/SYSTEM runner, Linux).
for (const dir of candidates) {
const hit = findExportUnder(dir);
if (hit)
return {
dir,
candidates: [inbox, ...candidates],
file: hit.file,
mtime: hit.mtime,
};
}
// Else, the first dir that at least exists (so the UI can say "Playnite found, exporter not
// installed yet" vs "Playnite not found").
const dir = candidates.find(exists) ?? null;
return { dir, candidates: [inbox, ...candidates], file: null, mtime: null };
};
export class ExportError extends Error {}
/** Read + validate the export file. Throws `ExportError` on a missing/corrupt/too-new file. */
export const readExport = (file: string): LibraryExport => {
let raw: string;
try {
raw = fs.readFileSync(file, "utf8");
} catch (e) {
throw new ExportError(`cannot read ${file}: ${e}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new ExportError(`${file} is not valid JSON: ${e}`);
}
if (!isLibraryExport(parsed)) {
throw new ExportError(`${file} is not a punktfunk library export`);
}
if (Math.floor(parsed.schema) > SCHEMA) {
throw new ExportError(
`${file} is schema ${parsed.schema}; this plugin understands up to ${SCHEMA} — update the plugin`,
);
}
return parsed;
};
-30
View File
@@ -1,30 +0,0 @@
// The console-hosted UI server (primary path). `servePluginUi` from the SDK owns the whole plugin side
// — a loopback ephemeral port behind a per-boot secret, registration + lease renewal with the host,
// and the console reverse-proxy contract — so we just hand it the built SPA directory and the
// plugin-local API router. The operator signs into the console once; the "Playnite" nav entry appears
// automatically. Zero human auth here.
import {
type PluginUiHandle,
type Punktfunk,
servePluginUi,
} from "@punktfunk/host";
import { PLUGIN_NAME, UI_ICON, UI_TITLE } from "../const.js";
import type { Engine } from "../engine/index.js";
import { resolveUiDir } from "../paths.js";
import { makeRouter } from "./router.js";
/** Serve the UI through the console. The SPA dir resolves whether we run bundled or from source. */
export const serveConsoleUi = (
pf: Punktfunk,
engine: Engine,
opts?: { version?: string },
): Promise<PluginUiHandle> =>
servePluginUi(pf, {
id: PLUGIN_NAME,
title: UI_TITLE,
icon: UI_ICON,
version: opts?.version,
staticDir: resolveUiDir(import.meta.url),
fetch: makeRouter(engine),
});
-30
View File
@@ -1,30 +0,0 @@
// scrypt password hashing for the standalone fallback UI. Format: `scrypt$<saltB64url>$<hashB64url>`.
// Verification is constant-time. Only used by the standalone server; the console-hosted path has no
// password of its own.
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
const KEYLEN = 32;
/** Hash a password for storage in `config.ui.passwordHash`. */
export const hashPassword = (password: string): string => {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, KEYLEN);
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
};
/** Constant-time verify a password against a stored `scrypt$...` hash. */
export const verifyPassword = (password: string, stored: string): boolean => {
const parts = stored.split("$");
if (parts.length !== 3 || parts[0] !== "scrypt" || !parts[1] || !parts[2])
return false;
const salt = Buffer.from(parts[1], "base64url");
const expected = Buffer.from(parts[2], "base64url");
let actual: Buffer;
try {
actual = scryptSync(password, salt, expected.length);
} catch {
return false;
}
return actual.length === expected.length && timingSafeEqual(actual, expected);
};
-91
View File
@@ -1,91 +0,0 @@
// The plugin-local REST/SSE API — a pure `(Request) => Response | undefined` the UI talks to. Paths
// arrive prefix-stripped (the console proxy already removed `/plugin-ui/playnite`), so we match
// `/api/...` directly; a non-`/api` path returns `undefined` to fall through to the static SPA. The
// same router backs both the console-proxied server and the standalone fallback.
import { type RawConfig, resolveConfig } from "../config.js";
import type { Engine, EngineStatus } from "../engine/index.js";
import { loadConfig, saveRawConfig } from "../state.js";
const json = (data: unknown, status = 200): Response =>
Response.json(data, { status });
/** A Server-Sent-Events stream that pushes the engine status on every change. */
const sse = (engine: Engine): Response => {
const enc = new TextEncoder();
let unsubscribe = () => {};
let ping: ReturnType<typeof setInterval> | undefined;
const stream = new ReadableStream({
start(controller) {
const send = (status: EngineStatus) => {
try {
controller.enqueue(
enc.encode(`event: status\ndata: ${JSON.stringify(status)}\n\n`),
);
} catch {
// stream already closed
}
};
send(engine.status());
unsubscribe = engine.subscribe(send);
ping = setInterval(() => {
try {
controller.enqueue(enc.encode(": ping\n\n"));
} catch {
// ignore
}
}, 15_000);
(ping as { unref?: () => void }).unref?.();
},
cancel() {
unsubscribe();
if (ping) clearInterval(ping);
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
},
});
};
/** Build the plugin-local API router bound to an engine. */
export const makeRouter =
(engine: Engine) =>
async (req: Request): Promise<Response | undefined> => {
const { pathname } = new URL(req.url);
const method = req.method;
if (!pathname.startsWith("/api/")) return undefined; // static SPA handles it
try {
if (pathname === "/api/status" && method === "GET") {
return json(engine.status());
}
if (pathname === "/api/config" && method === "GET") {
return json(loadConfig());
}
if (pathname === "/api/config" && method === "PUT") {
const body = (await req.json()) as RawConfig;
// Persist the authored shape; re-resolve so the response echoes defaults.
saveRawConfig(body);
const resolved = resolveConfig(body);
await engine.reconfigure();
return json(resolved);
}
if (pathname === "/api/preview" && method === "GET") {
return json(engine.computeDry());
}
if (pathname === "/api/sync" && method === "POST") {
const report = await engine.sync("ui");
return report ? json(report) : json({ status: engine.status() }, 200);
}
if (pathname === "/api/events" && method === "GET") {
return sse(engine);
}
return json({ error: "not found" }, 404);
} catch (e) {
return json({ error: String(e) }, 500);
}
};
-150
View File
@@ -1,150 +0,0 @@
// The standalone fallback UI server: for host-only installs without the console, or as insurance if
// the console surface is unavailable. Serves the SAME SPA + router, but on a fixed port with its own
// password/session auth instead of the console proxy. Fail-closed: a non-loopback bind REQUIRES a
// password (a UI that can rewrite the config — and thus the launch commands — is host-user code).
import * as nodePath from "node:path";
import type { Config } from "../config.js";
import type { Engine } from "../engine/index.js";
import { log } from "../log.js";
import { resolveUiDir } from "../paths.js";
import { verifyPassword } from "./password.js";
import { makeRouter } from "./router.js";
export interface StandaloneHandle {
url: string;
close(): Promise<void>;
}
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
const COOKIE = "pf_playnite_session";
const loginPage = (error?: string): Response =>
new Response(
`<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>Playnite · Punktfunk</title>
<style>body{font:15px system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0b0b0f;color:#e5e7eb}
form{display:grid;gap:.75rem;width:260px}input{padding:.5rem;border-radius:.5rem;border:1px solid #333;background:#111;color:#eee}
button{padding:.5rem;border-radius:.5rem;border:0;background:#6c5bf3;color:#fff;cursor:pointer}.e{color:#f87171;font-size:13px}</style>
<form method=post action="./login"><h1>Playnite Sync</h1>${error ? `<div class=e>${error}</div>` : ""}
<input type=password name=password placeholder="Password" autofocus><button>Sign in</button></form>`,
{
status: error ? 401 : 200,
headers: { "content-type": "text/html; charset=utf-8" },
},
);
/** Resolve a request path to a file inside `root`, or null on traversal (mirrors servePluginUi). */
const staticFile = (root: string, pathname: string): string | null => {
let rel: string;
try {
rel = decodeURIComponent(pathname);
} catch {
return null;
}
if (rel.endsWith("/")) rel += "index.html";
if (!rel.startsWith("/")) rel = `/${rel}`;
const abs = nodePath.resolve(root, `.${rel}`);
const rootAbs = nodePath.resolve(root);
if (abs !== rootAbs && !abs.startsWith(rootAbs + nodePath.sep)) return null;
return abs;
};
const cookieHas = (req: Request, name: string, value: string): boolean => {
const raw = req.headers.get("cookie") ?? "";
return raw.split(/;\s*/).some((c) => c === `${name}=${value}`);
};
/** Serve the standalone UI. Throws (fail-closed) if a non-loopback bind lacks a password. */
export const serveStandaloneUi = (
engine: Engine,
config: Config,
): StandaloneHandle => {
const { port, bind, passwordHash } = config.ui;
const isLoopback = LOOPBACK.has(bind);
if (!isLoopback && !passwordHash) {
throw new Error(
`standalone UI refuses to bind ${bind} without a password (set ui.passwordHash via \`punktfunk-plugin-playnite set-password\`, or bind 127.0.0.1)`,
);
}
const authRequired = Boolean(passwordHash);
const sessionToken = crypto.randomUUID().replace(/-/g, "");
const root = resolveUiDir(import.meta.url);
const router = makeRouter(engine);
const authed = (req: Request) =>
!authRequired || cookieHas(req, COOKIE, sessionToken);
const server = Bun.serve({
hostname: bind,
port,
async fetch(req) {
const { pathname } = new URL(req.url);
if (pathname === "/__health") return Response.json({ ok: true });
if (authRequired && pathname === "/login" && req.method === "POST") {
const form = await req.formData().catch(() => null);
const password = form?.get("password");
if (
typeof password === "string" &&
passwordHash &&
verifyPassword(password, passwordHash)
) {
return new Response(null, {
status: 303,
headers: {
location: "./",
"set-cookie": `${COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
},
});
}
return loginPage("Incorrect password");
}
if (pathname === "/logout" && req.method === "POST") {
return new Response(null, {
status: 303,
headers: {
location: "./",
"set-cookie": `${COOKIE}=; Max-Age=0; Path=/`,
},
});
}
if (!authed(req)) {
if (pathname.startsWith("/api/"))
return Response.json({ error: "unauthorized" }, { status: 401 });
return loginPage();
}
// 1) plugin-local API
const api = await router(req);
if (api) return api;
// 2) static asset
const file = staticFile(root, pathname);
if (file) {
const bf = Bun.file(file);
if (await bf.exists()) return new Response(bf);
}
// 3) SPA fallback
if (
req.method === "GET" &&
(req.headers.get("accept") ?? "").includes("text/html")
) {
const index = Bun.file(nodePath.join(root, "index.html"));
if (await index.exists()) return new Response(index);
}
return new Response("not found", { status: 404 });
},
});
const url = `http://${isLoopback ? "127.0.0.1" : bind}:${server.port}`;
log(
`standalone UI on ${url}${authRequired ? " (password-protected)" : " (loopback, no password)"}`,
);
return {
url,
async close() {
server.stop(true);
},
};
};
-90
View File
@@ -1,90 +0,0 @@
// Config/cache persistence: the plugin owns two files under `<config_dir>/playnite/`, created 0700.
// `config.json` is operator-editable and atomically rewritten (temp + rename); the plugin refuses a
// group/world-writable `config.json` (the same sshd rule the runner and hooks enforce, since the UI
// can rewrite it and the launch commands it produces run as the host user). `cache.json` is disposable
// derived state.
import * as fs from "node:fs";
import * as path from "node:path";
import { type Config, type RawConfig, resolveConfig } from "./config.js";
import { cachePath, configPath, pluginDir } from "./paths.js";
export interface Cache {
/** Fingerprint + count of the last reconcile — lets a re-read skip an unchanged PUT. */
lastSync?: { fingerprint: string; count: number; at: number };
}
export const emptyCache = (): Cache => ({});
/** Create the plugin dir 0700 if absent (idempotent). */
const ensureDir = (): void => {
fs.mkdirSync(pluginDir(), { recursive: true, mode: 0o700 });
};
/** Refuse a group/world-writable config file (POSIX only; Windows config dir is DACL'd). */
const assertNotWorldWritable = (file: string): void => {
if (process.platform === "win32") return;
let mode: number;
try {
mode = fs.statSync(file).mode;
} catch {
return; // absent — nothing to guard
}
if ((mode & 0o022) !== 0) {
throw new Error(
`refusing ${file}: it is group/world-writable (chmod go-w it first) — this file controls commands run as the host user`,
);
}
};
/** Atomic write: temp file in the same dir, then rename. */
const atomicWrite = (file: string, data: string): void => {
ensureDir();
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, data, { mode: 0o600 });
fs.renameSync(tmp, file);
};
/** Load the authored config (defaults filled). A missing file yields the empty config. */
export const loadConfig = (): Config => {
const file = configPath();
let raw = "";
try {
raw = fs.readFileSync(file, "utf8");
} catch {
return resolveConfig({});
}
assertNotWorldWritable(file);
const parsed = JSON.parse(raw) as RawConfig;
return resolveConfig(parsed);
};
/** Persist a config (atomic). */
export const saveConfig = (config: Config): void => {
atomicWrite(configPath(), `${JSON.stringify(config, null, 2)}\n`);
};
/** Save the raw (operator-authored) config verbatim — the shape the UI edits. */
export const saveRawConfig = (raw: RawConfig): void => {
atomicWrite(configPath(), `${JSON.stringify(raw, null, 2)}\n`);
};
export const loadCache = (): Cache => {
try {
return JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache;
} catch {
return emptyCache();
}
};
export const saveCache = (cache: Cache): void => {
atomicWrite(cachePath(), JSON.stringify(cache));
};
/** Absolute paths, exported for the UI/CLI status view. */
export const statePaths = () => ({
dir: pluginDir(),
config: configPath(),
cache: cachePath(),
relConfig: path.basename(configPath()),
});
-16
View File
@@ -1,16 +0,0 @@
// Best-effort read of the plugin's own version from package.json (shown in the console page header).
// `../package.json` resolves to the repo root whether this module runs from `src/` or `dist/`.
import * as fs from "node:fs";
import { fileURLToPath } from "node:url";
export const readVersion = (): string | undefined => {
try {
const file = fileURLToPath(new URL("../package.json", import.meta.url));
const pkg = JSON.parse(fs.readFileSync(file, "utf8")) as {
version?: string;
};
return pkg.version;
} catch {
return undefined;
}
};
-34
View File
@@ -1,34 +0,0 @@
// The host provider-reconcile wire shapes (mirrors `@punktfunk/host`'s generated `ProviderEntryInput`
// et al. — the facade doesn't re-export them, and we PUT the raw array via `pf.request`, so we own the
// shape here). Kept minimal and stable; the host validates it.
/** Cover art — all URLs (or `data:` URLs). The console/clients prefer `portrait` for a grid. */
export interface Artwork {
portrait?: string | null;
hero?: string | null;
logo?: string | null;
header?: string | null;
}
/** How the host launches a title. For this plugin always `{ kind: "command", value: <shell command> }`
* — the host wraps it as `cmd.exe /c <value>` in the interactive user session (Windows). */
export interface LaunchSpec {
kind: "command";
value: string;
}
/** A per-title prep/undo step: `do` runs before launch, `undo` at session end (reverse order). */
export interface PrepCmd {
do: string;
undo?: string | null;
}
/** One title in the declarative reconcile payload (`PUT /api/v1/library/provider/playnite`). */
export interface ProviderEntryInput {
/** The provider's stable id for this title — the reconcile diff key (Playnite's game Guid). */
external_id: string;
title: string;
art?: Artwork;
launch?: LaunchSpec | null;
prep?: PrepCmd[];
}
-36
View File
@@ -1,36 +0,0 @@
// State persistence lands under `<config_dir>/plugin-state/playnite` — the one dir the
// de-privileged Windows runner (LocalService) may write. A regression here (writing straight under
// the config dir) would EPERM under the runner and lose the operator's config.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { loadConfig, saveRawConfig } from "../src/state.js";
let root: string;
let saved: string | undefined;
beforeEach(() => {
saved = process.env.PUNKTFUNK_CONFIG_DIR;
root = fs.mkdtempSync(path.join(os.tmpdir(), "pn-state-"));
process.env.PUNKTFUNK_CONFIG_DIR = root;
});
afterEach(() => {
if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = saved;
fs.rmSync(root, { recursive: true, force: true });
});
describe("state location", () => {
test("persists under plugin-state/playnite and round-trips", () => {
saveRawConfig({ playniteDir: "/games/playnite" });
expect(
fs.existsSync(path.join(root, "plugin-state", "playnite", "config.json")),
).toBe(true);
// Not written straight under the config dir (the LocalService-unwritable location).
expect(fs.existsSync(path.join(root, "playnite", "config.json"))).toBe(
false,
);
expect(loadConfig().playniteDir).toBe("/games/playnite");
});
});
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"moduleResolution": "bundler"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "test", "ui", "dist", "node_modules"]
}
+12
View File
@@ -0,0 +1,12 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.tsx"],
addons: [],
framework: {
name: "@storybook/react-vite",
options: {},
},
};
export default config;
+65
View File
@@ -0,0 +1,65 @@
// Storybook renders the REAL pages on the mock transport: the static import below
// registers the fixture HttpClient layer + status ticker (Storybook bundles are never
// shipped, so fixtures in them are fine), and the decorator gives every story a fresh
// atom registry seeded with mock mode + the story's scenario.
import "../src/styles.css";
import "../src/mocks/http";
import { RegistryProvider } from "@effect/atom-react";
import { definePreview } from "@storybook/react-vite";
import { useEffect } from "react";
import {
apiModeAtom,
type Scenario,
scenarioAtom,
} from "../src/mocks/scenario";
export default definePreview({
addons: [],
// The console pins dark; default the canvas to dark with a toolbar light switch.
initialGlobals: { theme: "dark" },
globalTypes: {
theme: {
description: "Light/dark color scheme",
toolbar: {
title: "Theme",
icon: "circlehollow",
items: [
{ value: "dark", icon: "moon", title: "Dark" },
{ value: "light", icon: "sun", title: "Light" },
],
dynamicTitle: true,
},
},
},
decorators: [
(Story, context) => {
const dark = (context.globals.theme as string) !== "light";
const scenario =
(context.parameters.scenario as Scenario | undefined) ?? "healthy";
const fullscreen = context.parameters.layout === "fullscreen";
// Mirror `.dark` onto <html> so portal-mounted content (selects, dialogs,
// toasts) picks up the palette — the theme keys everything off `html.dark`.
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
}, [dark]);
return (
<RegistryProvider
key={`${scenario}-${dark}`}
initialValues={[
[apiModeAtom, "mock"],
[scenarioAtom, scenario],
]}
>
<div
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
>
<Story />
</div>
</RegistryProvider>
);
},
],
parameters: {
layout: "padded",
},
});
-342
View File
@@ -1,342 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "punktfunk-plugin-playnite-ui",
"dependencies": {
"lucide-react": "^0.469.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.3.2",
"typescript": "^5.9.3",
"vite": "^7.0.0",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@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=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="],
"browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="],
"enhanced-resolve": ["enhanced-resolve@5.24.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw=="],
"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=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}
+2 -1
View File
@@ -1,9 +1,10 @@
<!doctype html> <!doctype html>
<!-- The console pins dark; the standalone dev tab matches it. Removing `dark` yields light. -->
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Playnite · Punktfunk</title> <title>Playnite Punktfunk</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+26 -8
View File
@@ -2,24 +2,42 @@
"name": "punktfunk-plugin-playnite-ui", "name": "punktfunk-plugin-playnite-ui",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "The Playnite plugin SPA — built into ../dist/ui and served by servePluginUi. Self-contained (Tailwind + the Punktfunk brand tokens) so it builds with no design-system registry auth.", "description": "The Playnite plugin SPA \u2014 Punktfunk console polish (@unom/ui + @unom/app-ui), an Effect-native data layer derived from the shared HttpApi contract, and a zero-host mock dev loop. Builds into plugin/dist/ui.",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:live": "VITE_API_MODE=live vite",
"build": "vite build", "build": "vite build",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"storybook": "storybook dev -p 6014",
"build-storybook": "storybook build",
"screenshots": "node tools/screenshots.mjs"
}, },
"dependencies": { "dependencies": {
"@effect/atom-react": "4.0.0-beta.99",
"@fontsource-variable/geist": "^5.2.9",
"@punktfunk/plugin-kit": "^0.1.4",
"@unom/app-ui": "^0.2.0",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.9.1",
"effect": "4.0.0-beta.99",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^19.2.0", "motion": "^12.42.2",
"react-dom": "^19.2.0" "react": "^19.2.7",
"react-dom": "^19.2.7"
}, },
"devDependencies": { "devDependencies": {
"@playnite/contract": "workspace:*",
"@storybook/react-vite": "^10.4.6",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@types/react": "^19.2.0", "@types/node": "^22.20.0",
"@types/react-dom": "^19.2.0", "@types/react": "^19.2.17",
"@vitejs/plugin-react": "^5.0.0", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"playwright": "^1.61.1",
"storybook": "^10.4.6",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.0.0" "vite": "^7.3.6"
} }
} }
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { App } from "./App";
const meta = {
title: "Shell/App",
component: App,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof App>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = {
parameters: { layout: "fullscreen", scenario: "firstRun" },
};
+46 -418
View File
@@ -1,424 +1,52 @@
import { // The app frame: kit router (flat routes, console deep-link bridge) inside the shared
AlertTriangle, // PluginShell chrome. Pages own their data; the shell owns navigation + the toaster.
CheckCircle2,
FolderSearch,
Gamepad2,
Library,
Loader2,
RefreshCw,
Save,
} from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import {
type Config,
getConfig,
putConfig,
runSync,
type Status,
useStatusStream,
} from "./api.js";
const relTime = (ms?: number): string => { import { createPluginRouter, useIsEmbedded } from "@punktfunk/plugin-kit/react";
if (!ms) return "never"; import {
const s = Math.round((Date.now() - ms) / 1000); PluginShell,
if (s < 60) return `${s}s ago`; type PluginShellNavItem,
if (s < 3600) return `${Math.round(s / 60)}m ago`; } from "@unom/app-ui/plugin-shell";
if (s < 86400) return `${Math.round(s / 3600)}h ago`; import { LayoutDashboard, LibraryBig, Settings2 } from "lucide-react";
return `${Math.round(s / 86400)}d ago`; import type { FC } from "react";
import { LibraryPage } from "@/pages/library";
import { OverviewPage } from "@/pages/overview";
import { SettingsPage } from "@/pages/settings";
import { type PageProps, ROUTES, type Route } from "@/routes";
const { usePluginRoute } = createPluginRouter(ROUTES, "overview");
const NAV: ReadonlyArray<PluginShellNavItem> = [
{ id: "overview", label: "Overview", icon: LayoutDashboard },
{ id: "library", label: "Library", icon: LibraryBig },
{ id: "settings", label: "Settings", icon: Settings2 },
];
const PAGES: Record<Route, FC<PageProps>> = {
overview: OverviewPage,
library: LibraryPage,
settings: SettingsPage,
}; };
function Card({ /** Only shown in a standalone tab — embedded in the console, the console is the chrome. */
title, const StandaloneHeader = () => (
icon, <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
children, <LibraryBig className="size-4 text-primary" />
right, Playnite
}: { </div>
title: string; );
icon: ReactNode;
children: ReactNode; export const App = () => {
right?: ReactNode; const { route, navigate } = usePluginRoute();
}) { const embedded = useIsEmbedded();
const Page = PAGES[route];
return ( return (
<section className="rounded-[var(--radius)] border border-border bg-card p-5"> <PluginShell
<header className="mb-4 flex items-center justify-between gap-3"> nav={NAV}
<h2 className="flex items-center gap-2 text-sm font-semibold tracking-wide text-muted uppercase"> active={route}
<span className="text-brand">{icon}</span> onNavigate={(id) => navigate(id as Route)}
{title} standaloneHeader={embedded ? undefined : <StandaloneHeader />}
</h2>
{right}
</header>
{children}
</section>
);
}
function Toggle({
label,
hint,
checked,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4 py-2">
<span>
<span className="block text-sm">{label}</span>
{hint && <span className="block text-xs text-muted">{hint}</span>}
</span>
<button
type="button"
onClick={() => onChange(!checked)}
className={`mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? "bg-brand" : "bg-elevated"}`}
>
<span
className={`block h-5 w-5 rounded-full bg-white transition-transform ${checked ? "translate-x-5" : "translate-x-0.5"}`}
/>
</button>
</label>
);
}
const inputCls =
"w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-brand";
export function App() {
const status = useStatusStream();
const [config, setConfig] = useState<Config>();
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [msg, setMsg] = useState<string>();
useEffect(() => {
getConfig()
.then(setConfig)
.catch((e) => setMsg(String(e)));
}, []);
const patch = (p: Partial<Config>) =>
setConfig((c) => (c ? { ...c, ...p } : c));
const save = async () => {
if (!config) return;
setSaving(true);
setMsg(undefined);
try {
setConfig(await putConfig(config));
setMsg("Saved — re-syncing with the new settings.");
} catch (e) {
setMsg(String(e));
} finally {
setSaving(false);
}
};
const sync = async () => {
setSyncing(true);
setMsg(undefined);
try {
await runSync();
setMsg("Sync requested.");
} catch (e) {
setMsg(String(e));
} finally {
setSyncing(false);
}
};
return (
<div className="mx-auto max-w-3xl px-5 py-8">
<header className="mb-6 flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-brand text-brand-fg">
<Gamepad2 size={22} />
</span>
<div>
<h1 className="text-xl font-semibold">Playnite</h1>
<p className="text-xs text-muted">
Sync your Playnite library into the Punktfunk game library.
</p>
</div>
</header>
{status ? (
<div className="grid gap-5">
<Connection status={status} />
<LibrarySummary status={status} onSync={sync} syncing={syncing} />
{config && (
<Settings
config={config}
patch={patch}
onSave={save}
saving={saving}
/>
)}
</div>
) : (
<p className="text-sm text-muted">Loading</p>
)}
{msg && (
<p className="mt-5 rounded-lg border border-border bg-surface px-4 py-2 text-sm text-muted">
{msg}
</p>
)}
</div>
);
}
function Connection({ status }: { status: Status }) {
const found = Boolean(status.location.file);
return (
<Card title="Playnite connection" icon={<FolderSearch size={16} />}>
{found ? (
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 shrink-0 text-ok" size={18} />
<div className="min-w-0 text-sm">
<p>
Exporter connected updated{" "}
<span className="text-fg">
{relTime(status.location.mtime ?? undefined)}
</span>
.
</p>
<p className="mt-1 truncate font-mono text-xs text-muted">
{status.location.file}
</p>
</div>
</div>
) : (
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 shrink-0 text-warn" size={18} />
<div className="text-sm">
<p>
No exporter output found. Install the{" "}
<span className="text-fg">Punktfunk Sync</span> extension in
Playnite (then restart Playnite).
</p>
{status.exportError && (
<p className="mt-1 text-xs text-muted">{status.exportError}</p>
)}
<p className="mt-2 text-xs text-muted">
Looked under:{" "}
{status.location.dir ?? "no Playnite data dir found"}
</p>
</div>
</div>
)}
</Card>
);
}
function LibrarySummary({
status,
onSync,
syncing,
}: {
status: Status;
onSync: () => void;
syncing: boolean;
}) {
const report = status.lastReport;
const busy = syncing || status.syncing;
const sources = report
? Object.entries(report.perSource).sort((a, b) => b[1] - a[1])
: [];
return (
<Card
title="Library"
icon={<Library size={16} />}
right={
<button
type="button"
onClick={onSync}
disabled={busy}
className="flex items-center gap-2 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>
{busy ? (
<Loader2 className="animate-spin" size={15} />
) : (
<RefreshCw size={15} />
)}
Sync now
</button>
}
> >
<div className="flex items-baseline gap-2"> <Page navigate={navigate} />
<span className="text-3xl font-semibold"> </PluginShell>
{status.lastSync?.count ?? 0}
</span>
<span className="text-sm text-muted">
title(s) synced · last {relTime(status.lastSync?.at)}
</span>
</div>
{sources.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{sources.map(([src, n]) => (
<span
key={src}
className="rounded-full border border-border bg-surface px-2.5 py-1 text-xs"
>
{src} <span className="text-muted">{n}</span>
</span>
))}
</div>
)}
{report && report.skipped.length > 0 && (
<p className="mt-3 text-xs text-muted">
{report.skipped.length} skipped (e.g. {report.skipped[0]?.title}:{" "}
{report.skipped[0]?.reason})
</p>
)}
{report?.warnings.map((w) => (
<p key={w} className="mt-2 text-xs text-warn">
{w}
</p>
))}
</Card>
); );
} };
function Settings({
config,
patch,
onSave,
saving,
}: {
config: Config;
patch: (p: Partial<Config>) => void;
onSave: () => void;
saving: boolean;
}) {
const csv = (a: string[]) => a.join(", ");
const parseCsv = (s: string) =>
s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
return (
<Card
title="Settings"
icon={<Gamepad2 size={16} />}
right={
<button
type="button"
onClick={onSave}
disabled={saving}
className="flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-1.5 text-sm hover:bg-elevated disabled:opacity-50"
>
{saving ? (
<Loader2 className="animate-spin" size={15} />
) : (
<Save size={15} />
)}
Save
</button>
}
>
<div className="divide-y divide-border">
<Toggle
label="Installed games only"
hint="Uncheck to also sync games Playnite hasn't installed yet."
checked={config.filter.installedOnly}
onChange={(v) =>
patch({ filter: { ...config.filter, installedOnly: v } })
}
/>
<Toggle
label="Include hidden games"
checked={config.filter.includeHidden}
onChange={(v) =>
patch({ filter: { ...config.filter, includeHidden: v } })
}
/>
<Toggle
label="Embed cover art"
hint="Inlines each cover as a data URL. Turn off for a lighter library payload."
checked={config.art.mode === "dataurl"}
onChange={(v) =>
patch({ art: { ...config.art, mode: v ? "dataurl" : "off" } })
}
/>
<Toggle
label="Embed background art too"
hint="Backgrounds are large — off by default."
checked={config.art.includeBackground}
onChange={(v) =>
patch({ art: { ...config.art, includeBackground: v } })
}
/>
</div>
<div className="mt-4 grid gap-3">
<label className="block">
<span className="mb-1 block text-xs text-muted">
Only these sources (comma-separated, blank = all)
</span>
<input
className={inputCls}
placeholder="Steam, GOG, Epic"
defaultValue={csv(config.filter.sources)}
onBlur={(e) =>
patch({
filter: { ...config.filter, sources: parseCsv(e.target.value) },
})
}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">Exclude sources</span>
<input
className={inputCls}
placeholder="Xbox"
defaultValue={csv(config.filter.excludeSources)}
onBlur={(e) =>
patch({
filter: {
...config.filter,
excludeSources: parseCsv(e.target.value),
},
})
}
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="mb-1 block text-xs text-muted">
Poll every (min)
</span>
<input
type="number"
min={1}
className={inputCls}
defaultValue={config.sync.pollMinutes}
onBlur={(e) =>
patch({
sync: {
...config.sync,
pollMinutes: Math.max(1, Number(e.target.value) || 5),
},
})
}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">
Playnite dir override
</span>
<input
className={inputCls}
placeholder="(auto-detect)"
defaultValue={config.playniteDir ?? ""}
onBlur={(e) =>
patch({ playniteDir: e.target.value.trim() || undefined })
}
/>
</label>
</div>
</div>
</Card>
);
}
-108
View File
@@ -1,108 +0,0 @@
// Typed client for the plugin-local API. Relative paths — the SPA is mounted under the console proxy
// prefix, so `api/...` resolves to `/plugin-ui/playnite/api/...`. Types mirror `src/engine` +
// `src/config`.
import { useEffect, useState } from "react";
export interface Location {
dir: string | null;
candidates: string[];
file: string | null;
mtime: number | null;
}
export interface Skipped {
id: string;
title: string;
reason: string;
}
export interface Report {
generatedAt: string;
considered: number;
included: number;
skipped: Skipped[];
warnings: string[];
perSource: Record<string, number>;
}
export interface LastSync {
fingerprint: string;
count: number;
at: number;
}
export interface Status {
platform: string;
location: Location;
exportError?: string;
lastSync?: LastSync;
lastReport?: Report;
paths: { dir: string; config: string; cache: string; relConfig: string };
syncing: boolean;
}
export interface Config {
playniteDir?: string;
sync: { pollMinutes: number; watch: boolean; debounceMs: number };
filter: {
installedOnly: boolean;
includeHidden: boolean;
sources: string[];
excludeSources: string[];
};
art: {
mode: "dataurl" | "off";
maxBytes: number;
includeBackground: boolean;
};
ui: {
standalone: boolean;
port: number;
bind: string;
passwordHash?: string;
};
devEntry: boolean;
}
export interface Preview {
location: Location;
report?: Report;
error?: string;
}
const api = async <T>(path: string, opts?: RequestInit): Promise<T> => {
const res = await fetch(path, {
headers: { "content-type": "application/json" },
...opts,
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return (await res.json()) as T;
};
export const getStatus = () => api<Status>("api/status");
export const getConfig = () => api<Config>("api/config");
export const putConfig = (config: Config) =>
api<Config>("api/config", { method: "PUT", body: JSON.stringify(config) });
export const getPreview = () => api<Preview>("api/preview");
export const runSync = () => api<unknown>("api/sync", { method: "POST" });
/** Live engine status via SSE (falls back to a one-shot fetch if EventSource fails). */
export const useStatusStream = (): Status | undefined => {
const [status, setStatus] = useState<Status>();
useEffect(() => {
getStatus()
.then(setStatus)
.catch(() => {});
try {
const es = new EventSource("api/events");
es.addEventListener("status", (e) => {
try {
setStatus(JSON.parse((e as MessageEvent).data));
} catch {
// ignore malformed frame
}
});
return () => es.close();
} catch {
return;
}
}, []);
return status;
};
+53
View File
@@ -0,0 +1,53 @@
// The one loading/error convention for every subscription on every page: the kit's
// ResultGate wired to this plugin's skeleton + error visuals. Pages pass their own
// colocated PageSkeleton; failures render a retryable error EmptyState — except the
// "exporter hasn't written anything yet" case, which pages can answer with onboarding
// via the `unavailable` slot.
import { ResultGate } from "@punktfunk/plugin-kit/react";
import { Button } from "@unom/ui/button";
import { EmptyState } from "@unom/ui/empty-state";
import type { AsyncResult } from "effect/unstable/reactivity";
import { TriangleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { errorText, isExportUnavailable } from "@/lib/errors";
export type GateProps<A, E> = {
readonly result: AsyncResult.AsyncResult<A, E>;
/** The page's colocated skeleton, shown while the first value loads. */
readonly skeleton: ReactNode;
/** Usually `useAtomRefresh(theAtom)`. */
readonly retry?: () => void;
/** Rendered instead of the error box when the exporter has produced nothing yet. */
readonly unavailable?: ReactNode;
readonly children: (value: A) => ReactNode;
};
export const Gate = <A, E>(props: GateProps<A, E>): ReactNode => (
<ResultGate
result={props.result}
waiting={props.skeleton}
retry={props.retry}
failure={(error, retry) =>
props.unavailable !== undefined && isExportUnavailable(error) ? (
props.unavailable
) : (
<EmptyState
variant="error"
icon={TriangleAlert}
title="Something went wrong"
description={errorText(error)}
action={
retry === undefined ? undefined : (
<Button variant="outline" onClick={retry}>
Try again
</Button>
)
}
/>
)
}
>
{props.children}
</ResultGate>
);
+47
View File
@@ -0,0 +1,47 @@
// The sticky save bar every draft-editing page shares: slides up when the draft goes
// dirty, pins to the viewport bottom, offers Save + Discard.
import { AnimatedButton, Button } from "@unom/ui/button";
import { Spinner } from "@unom/ui/spinner";
import { AnimatePresence, motion } from "motion/react";
export type SaveBarProps = {
readonly dirty: boolean;
readonly saving: boolean;
readonly onSave: () => void;
readonly onDiscard: () => void;
};
export const SaveBar = ({ dirty, saving, onSave, onDiscard }: SaveBarProps) => (
<AnimatePresence>
{dirty ? (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
transition={{ duration: 0.3, ease: [0.165, 0.84, 0.44, 1] }}
className="sticky bottom-4 z-20 mt-6"
>
<div className="flex items-center justify-between gap-4 rounded-card border border-border bg-popover/90 px-5 py-3 shadow-lg backdrop-blur">
<span className="text-sm text-muted-foreground">Unsaved changes</span>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onDiscard} disabled={saving}>
Discard
</Button>
{/* variants={{}} neutralizes the button's Section-stagger entry variants —
inside this object-form motion wrapper the `enter` label never
propagates, which would leave the button stuck at opacity 0. */}
<AnimatedButton
onClick={onSave}
disabled={saving}
variants={{}}
whileTap={{ scale: 0.97 }}
>
{saving ? <Spinner className="size-4" /> : null}
Save changes
</AnimatedButton>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
+150
View File
@@ -0,0 +1,150 @@
// Every atom the pages subscribe to. Queries invalidate via reactivity keys; the
// mutation constants below document which keys each mutation must pass at call time
// (AtomHttpApi invalidation is per-call: `set({ ..., reactivityKeys: [...KEYS] })`).
import { EngineStatus, EVENTS_EVENT, EVENTS_PATH } from "@playnite/contract";
import { sseAtom } from "@punktfunk/plugin-kit/react";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { installedMockStatusStream } from "@/mocks/register";
import { apiModeAtom } from "@/mocks/scenario";
import { Api, prefix } from "./client";
// ── Queries ────────────────────────────────────────────────────────────────────
export const statusAtom = Api.query("status", "get", {
reactivityKeys: ["status"],
});
export const configAtom = Api.query("config", "get", {
reactivityKeys: ["config"],
});
export const previewAtom = Api.query("library", "preview", {
reactivityKeys: ["preview"],
});
// ── Mutations + the keys they invalidate ───────────────────────────────────────
export const saveConfig = Api.mutation("config", "put");
/** A config save changes the desired state everywhere derived from it. */
export const SAVE_CONFIG_KEYS = ["config", "preview", "status"] as const;
export const runSync = Api.mutation("sync", "run");
export const RUN_SYNC_KEYS = ["status", "preview"] as const;
// ── Live status (SSE with polling fallback) ────────────────────────────────────
/** The real SSE feed. Only mounted in live mode (the mock branch never `get`s it). */
const liveStatusStream = sseAtom({
url: `${prefix}${EVENTS_PATH}`,
event: EVENTS_EVENT,
schema: EngineStatus,
});
/**
* The status frame stream, mode-independent: SSE in live mode, the fixture ticker in
* mock mode. `undefined` until the first frame arrives.
*/
export const statusStreamAtom: Atom.Atom<EngineStatus | undefined> = Atom.make(
(get) => {
if (get(apiModeAtom) === "mock") {
const mock = installedMockStatusStream();
return mock === undefined ? undefined : get(mock);
}
return get(liveStatusStream);
},
);
/**
* What pages actually render: the freshest engine status — the latest SSE frame once one
* has arrived, the plain GET /api/status result before that (and as error/skeleton
* source, since the SSE stream never fails).
*/
export const liveStatusAtom: Atom.Atom<Atom.Type<typeof statusAtom>> =
Atom.make((get) => {
const frame = get(statusStreamAtom);
if (frame !== undefined) {
return AsyncResult.success<EngineStatus, Atom.Failure<typeof statusAtom>>(
frame,
);
}
return get(statusAtom);
});
// ── Status transitions → activity feed ─────────────────────────────────────────
export type StatusEvent = {
readonly id: string;
readonly at: number;
readonly text: string;
readonly level: "info" | "warn" | "error";
};
const EVENT_LIMIT = 50;
/**
* Folds the status frame stream into a bounded activity list for the Overview feed —
* client-side only, no history endpoint: connect, sync start/finish (with the report's
* count), report warnings, export read errors, and a fresh export appearing.
*/
export const statusEventsAtom: Atom.Atom<ReadonlyArray<StatusEvent>> =
Atom.make((get) => {
let prev: EngineStatus | undefined;
let items: ReadonlyArray<StatusEvent> = [];
let seq = 0;
get.subscribe(
statusStreamAtom,
(frame) => {
if (frame === undefined) return;
const next: Array<StatusEvent> = [];
const push = (text: string, level: StatusEvent["level"] = "info") =>
next.push({ id: `ev-${seq++}`, at: Date.now(), text, level });
if (prev === undefined) {
push(
frame.location.file === null
? "Engine connected — waiting for the Playnite exporter"
: `Engine connected — reading ${frame.location.viaIngest ? "the ingest inbox" : "Playnite's ExtensionsData"}`,
);
if (frame.exportError !== null) push(frame.exportError, "error");
if (frame.syncing) push("Sync running");
} else {
if (!prev.syncing && frame.syncing) push("Sync started");
if (prev.syncing && !frame.syncing) {
const report = frame.lastReport;
if (report === null) {
push("Sync finished");
} else {
push(
`Reconciled ${report.included} ${report.included === 1 ? "title" : "titles"}`,
);
for (const warning of report.warnings) push(warning, "warn");
}
}
if (
frame.exportError !== null &&
frame.exportError !== prev.exportError
) {
push(frame.exportError, "error");
}
if (prev.exportError !== null && frame.exportError === null) {
push("Exporter output is readable again");
}
if (
frame.location.mtime !== null &&
frame.location.mtime !== prev.location.mtime
) {
push("Playnite wrote a new library export");
}
}
prev = frame;
if (next.length > 0) {
items = [...items, ...next].slice(-EVENT_LIMIT);
get.setSelf(items);
}
},
{ immediate: true },
);
return items;
});
+42
View File
@@ -0,0 +1,42 @@
// The one place the UI talks HTTP: a typed client + atom factory derived from the SHARED
// HttpApi contract via AtomHttpApi. No hand-mirrored types, no fetch calls in pages —
// endpoints, payloads, and errors all flow from @playnite/contract.
import { PlayniteApi } from "@playnite/contract";
import { resolvePluginBase } from "@punktfunk/plugin-kit/react";
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
} from "effect/unstable/http";
import { AtomHttpApi } from "effect/unstable/reactivity";
import { installedMockHttpLayer } from "@/mocks/register";
import { apiModeAtom } from "@/mocks/scenario";
/**
* "/plugin-ui/playnite" behind the console proxy, "" in dev — Vite serves at "/", so
* relative fetches hit the dev-server proxy (live mode) or the mock layer (mock mode).
*/
export const prefix = resolvePluginBase();
export class Api extends AtomHttpApi.Service<Api>()("PlayniteApi", {
api: PlayniteApi,
// Mock mode swaps the transport UNDER the same typed client, so pages cannot tell
// fixtures from a live host. The mock layer arrives via mocks/register (dev-only
// import); if it is not installed we fall through to fetch — a loud, honest failure.
httpClient: (get) => {
if (get(apiModeAtom) === "mock") {
const mock = installedMockHttpLayer(get);
if (mock !== undefined) return mock;
}
return FetchHttpClient.layer;
},
// prependUrl("") breaks URL joining — only install the transform when proxied.
...(prefix !== ""
? {
transformClient: HttpClient.mapRequest(
HttpClientRequest.prependUrl(prefix),
),
}
: {}),
}) {}
+97
View File
@@ -0,0 +1,97 @@
// The config draft: a local raw-config working copy seeded from GET /api/config.
// Edits patch the RAW (authored) shape — defaults are never baked in; `resolved`
// decodes the draft through the SHARED PlayniteConfigSchema so controls can display
// effective values (e.g. pollMinutes 5 when the file omits it).
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { type Config, type RawConfig, resolveConfig } from "@playnite/contract";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useState } from "react";
import { errorText } from "@/lib/errors";
import { configAtom, SAVE_CONFIG_KEYS, saveConfig } from "./atoms";
export type ConfigDraft = {
/** False until GET /api/config has succeeded once (render a skeleton). */
readonly loaded: boolean;
/** The working copy of the authored (raw) config. `{}` until loaded. */
readonly draft: RawConfig;
/** The draft decoded through PlayniteConfigSchema (defaults filled), if valid. */
readonly resolved: Config | undefined;
readonly dirty: boolean;
readonly saving: boolean;
readonly patch: (patch: Partial<RawConfig>) => void;
readonly save: () => void;
readonly discard: () => void;
};
type DraftState = {
readonly baseline: RawConfig;
readonly draft: RawConfig;
};
export const useConfigDraft = (): ConfigDraft => {
const result = useAtomValue(configAtom);
const saveResult = useAtomValue(saveConfig);
const write = useAtomSet(saveConfig, { mode: "promiseExit" });
const [state, setState] = useState<DraftState | null>(null);
// Seed once from the first successful load; later refetches (our own saves) must not
// clobber in-progress edits.
useEffect(() => {
if (state === null && AsyncResult.isSuccess(result)) {
const raw = result.value.raw as RawConfig;
setState({ baseline: raw, draft: raw });
}
}, [result, state]);
const draft = state?.draft ?? {};
const dirty =
state !== null &&
JSON.stringify(state.draft) !== JSON.stringify(state.baseline);
const resolved = useMemo(() => {
try {
return resolveConfig(draft);
} catch {
return undefined;
}
}, [draft]);
const patch = useCallback((p: Partial<RawConfig>) => {
setState((s) => (s === null ? s : { ...s, draft: { ...s.draft, ...p } }));
}, []);
const discard = useCallback(() => {
setState((s) => (s === null ? s : { ...s, draft: s.baseline }));
}, []);
const save = useCallback(() => {
if (state === null) return;
const raw = state.draft;
void write({ payload: raw, reactivityKeys: [...SAVE_CONFIG_KEYS] }).then(
(exit) => {
if (Exit.isSuccess(exit)) {
setState({ baseline: raw, draft: raw });
toast.success("Configuration saved");
} else {
toast.error("Save failed", {
description: errorText(Cause.squash(exit.cause)),
});
}
},
);
}, [state, write]);
return {
loaded: state !== null,
draft,
resolved,
dirty,
saving: AsyncResult.isWaiting(saveResult),
patch,
save,
discard,
};
};
+24
View File
@@ -0,0 +1,24 @@
// One place to turn a typed API error (or any transport failure) into a sentence.
export const errorText = (error: unknown): string => {
if (typeof error === "object" && error !== null) {
const e = error as {
_tag?: string;
issues?: string;
message?: string;
};
if (e._tag === "ConfigInvalid" && typeof e.issues === "string") {
return e.issues;
}
if (e._tag === "SyncInProgress") return "A sync pass is already running";
if (typeof e.message === "string" && e.message.length > 0) return e.message;
if (typeof e._tag === "string") return e._tag;
}
return String(error);
};
/** True for the "the Playnite exporter hasn't produced anything yet" failure — the one
* error the UI answers with onboarding rather than a red box. */
export const isExportUnavailable = (error: unknown): boolean =>
typeof error === "object" &&
error !== null &&
(error as { _tag?: string })._tag === "ExportUnavailable";
+14 -3
View File
@@ -1,10 +1,21 @@
import "./styles.css";
import { RegistryProvider } from "@effect/atom-react";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App.js"; import { App } from "./App";
import "./styles.css"; import { defaultApiMode } from "./mocks/scenario";
// Zero-host dev loop: install the mock transport BEFORE the first render so the very
// first atom reads hit fixtures. The whole branch is dead-code-eliminated from production
// builds (import.meta.env.DEV is false), so no fixture code ships.
if (import.meta.env.DEV && defaultApiMode === "mock") {
await import("./mocks/http");
}
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <RegistryProvider>
<App />
</RegistryProvider>
</StrictMode>, </StrictMode>,
); );
+254
View File
@@ -0,0 +1,254 @@
// Fixture data for the zero-host dev loop + Storybook. Typed AGAINST THE CONTRACT — if
// the contract moves, this file fails typecheck, exactly like the real server would.
// Never imported by production app code (see mocks/register.ts for the seam).
//
// Covers are `https://` URLs on purpose: Playnite passes a remote art reference through
// verbatim, so this is a shape the real exporter genuinely produces — and it keeps the
// fixtures loadable without the plugin's local-file art proxy.
import type {
EngineStatus,
PreviewResult,
RawConfig,
SyncReport,
} from "@playnite/contract";
import type { Scenario } from "./scenario";
type ProviderEntry = PreviewResult["entries"][number];
// ── Games ──────────────────────────────────────────────────────────────────────
const portrait = (title: string): string =>
`https://placehold.co/600x900/1c1530/a79ff8?text=${encodeURIComponent(title).replace(/%20/g, "+")}`;
const guid = (n: number): string =>
`${n.toString(16).padStart(8, "0")}-1111-4111-8111-111111111111`;
type Fixture = {
readonly id: string;
readonly title: string;
readonly source: string;
};
const FIXTURES: ReadonlyArray<Fixture> = [
{ id: guid(1), title: "Hollow Knight", source: "Steam" },
{ id: guid(2), title: "Disco Elysium", source: "GOG" },
{ id: guid(3), title: "Hades", source: "Steam" },
{ id: guid(4), title: "Outer Wilds", source: "Epic" },
{ id: guid(5), title: "Return of the Obra Dinn", source: "Steam" },
{ id: guid(6), title: "Baldur's Gate 3", source: "GOG" },
{ id: guid(7), title: "Slay the Spire", source: "Steam" },
{ id: guid(8), title: "Forza Horizon 5", source: "Xbox" },
{ id: guid(9), title: "Tunic", source: "Epic" },
{ id: guid(10), title: "Celeste", source: "itch.io" },
{ id: guid(11), title: "Super Metroid", source: "RetroArch" },
{ id: guid(12), title: "Chrono Trigger", source: "RetroArch" },
];
const entryOf = (f: Fixture): ProviderEntry => ({
external_id: f.id,
title: f.title,
art: { portrait: portrait(f.title) },
launch: {
kind: "command",
value: `start "" "playnite://playnite/start/${f.id}"`,
},
});
/** The source each fixture game belongs to, for the report's per-source counts. */
const SOURCE_OF = new Map(FIXTURES.map((f) => [f.id, f.source]));
// ── Config ─────────────────────────────────────────────────────────────────────
const HEALTHY_CONFIG: RawConfig = {
filter: { excludeSources: ["Xbox"] },
art: { mode: "host" },
};
export const configFor = (scenario: Scenario): RawConfig =>
scenario === "firstRun" ? {} : HEALTHY_CONFIG;
// ── Preview + report (derived from the current raw config, so toggles round-trip) ──
const SKIPPED = [
{
id: guid(8),
title: "Forza Horizon 5",
reason: 'source "Xbox" excluded',
},
{
id: guid(20),
title: "Cyberpunk 2077",
reason: "not installed",
},
{
id: guid(21),
title: "A Hidden Thing",
reason: "hidden in Playnite",
},
];
const GENERATED_AT = "2026-07-20T09:14:00Z";
export const previewFor = (
scenario: Scenario,
raw: RawConfig,
): PreviewResult => {
if (scenario === "firstRun") {
return {
entries: [],
report: {
generatedAt: "",
schemaVersion: 1,
considered: 0,
included: 0,
skipped: [],
excluded: [],
warnings: [],
perSource: {},
},
};
}
const overrides = raw.gameOverrides ?? {};
const excludedIds = new Set(
Object.entries(overrides)
.filter(([, o]) => o.exclude === true)
.map(([id]) => id),
);
const skippedIds = new Set(SKIPPED.map((s) => s.id));
const candidates = FIXTURES.filter((f) => !skippedIds.has(f.id));
const kept = candidates.filter((f) => !excludedIds.has(f.id));
const excluded = candidates
.filter((f) => excludedIds.has(f.id))
.map((f) => ({ id: f.id, title: f.title }));
const perSource: Record<string, number> = {};
for (const f of kept) {
const source = SOURCE_OF.get(f.id) ?? "(none)";
perSource[source] = (perSource[source] ?? 0) + 1;
}
const report: SyncReport = {
generatedAt: GENERATED_AT,
schemaVersion: 1,
considered: FIXTURES.length + 2,
included: kept.length,
skipped: SKIPPED,
excluded,
warnings:
scenario === "errors"
? [
'3001 entries with art.mode "dataurl" — inlined covers will exceed the host\'s reconcile body limit. Switch to art.mode "host" (the default), which serves covers by path.',
]
: [],
perSource,
};
return { entries: kept.map(entryOf), report };
};
export const reportFor = (scenario: Scenario, raw: RawConfig): SyncReport =>
previewFor(scenario, raw).report;
// ── Engine status + the SSE stand-in ticker ────────────────────────────────────
const INGEST_DIR = "C:\\ProgramData\\punktfunk\\ingest\\playnite";
const APPDATA_DIR = "C:\\Users\\enrico\\AppData\\Roaming\\Playnite";
const PATHS = {
dir: "C:\\ProgramData\\punktfunk\\plugin-state\\playnite",
config: "C:\\ProgramData\\punktfunk\\plugin-state\\playnite\\config.json",
cache: "C:\\ProgramData\\punktfunk\\plugin-state\\playnite\\cache.json",
ingest: `${INGEST_DIR}\\punktfunk-library.json`,
} as const;
const CONNECTED_LOCATION: EngineStatus["location"] = {
dir: INGEST_DIR,
candidates: [INGEST_DIR, APPDATA_DIR],
file: `${INGEST_DIR}\\punktfunk-library.json`,
mtime: Date.parse(GENERATED_AT),
viaIngest: true,
};
const NO_EXPORT_LOCATION: EngineStatus["location"] = {
dir: APPDATA_DIR,
candidates: [INGEST_DIR, APPDATA_DIR],
file: null,
mtime: null,
viaIngest: false,
};
export const statusFor = (scenario: Scenario): EngineStatus => {
const report = reportFor(scenario, configFor(scenario));
const base: EngineStatus = {
platform: "win32",
location: CONNECTED_LOCATION,
exportError: null,
playnite: { version: "10.36", mode: "Desktop" },
artMode: "host",
syncing: false,
lastSync: {
fingerprint: "b3c1a94efd02",
count: report.included,
at: Date.now() - 8 * 60 * 1000,
},
lastReport: report,
paths: PATHS,
};
switch (scenario) {
case "firstRun":
return {
...base,
location: NO_EXPORT_LOCATION,
exportError:
"no exporter output under C:\\Users\\enrico\\AppData\\Roaming\\Playnite — is the Punktfunk Sync extension installed in Playnite?",
playnite: null,
lastSync: null,
lastReport: null,
};
case "syncing":
return { ...base, syncing: true };
case "errors":
return {
...base,
exportError:
'C:\\ProgramData\\punktfunk\\ingest\\playnite\\punktfunk-library.json is not a usable Punktfunk library export: SchemaError(Expected a punktfunk-library.json schema this plugin understands (≤ 1), got 2 at ["schema"])',
lastSync: {
fingerprint: "b3c1a94efd02",
count: report.included,
at: Date.now() - 45 * 60 * 1000,
},
};
default:
return base;
}
};
/**
* Frames for the mock status ticker (~2s steps). Multi-frame scenarios loop
* idle → syncing → done so the Overview feed and sync button stay alive without a host.
*/
export const tickerFrames = (
scenario: Scenario,
): ReadonlyArray<EngineStatus> => {
const base = statusFor(scenario);
switch (scenario) {
case "firstRun":
case "syncing":
return [base];
case "errors":
return [base, { ...base, syncing: true }, { ...base, syncing: false }];
default:
return [
base,
{ ...base, syncing: true },
{
...base,
syncing: false,
lastSync: {
fingerprint: "c4d2ba50fe13",
count: base.lastReport?.included ?? 0,
at: Date.now(),
},
},
];
}
};
+124
View File
@@ -0,0 +1,124 @@
// The mock transport: a full in-memory implementation of the PlayniteApi surface as an
// HttpClient layer, plus the SSE stand-in ticker. Importing this module REGISTERS both
// (see mocks/register.ts); only main.tsx (behind import.meta.env.DEV) and Storybook's
// preview import it, so production app chunks never contain fixtures.
import type { EngineStatus, RawConfig } from "@playnite/contract";
import { Duration, Effect, Layer } from "effect";
import {
HttpClient,
type HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http";
import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
import * as fx from "./fixtures";
import { registerMocks } from "./register";
import { type Scenario, scenarioAtom } from "./scenario";
/** PUT /api/config lands here so config edits round-trip within a registry.
* keepAlive: written/read outside the reactive graph — see mocks/scenario.ts. */
const mockRawConfigAtom = Atom.keepAlive(Atom.make<RawConfig | null>(null));
const currentRawConfig = (
registry: AtomRegistry.AtomRegistry,
scenario: Scenario,
): RawConfig => registry.get(mockRawConfigAtom) ?? fx.configFor(scenario);
const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => {
const body = request.body;
if (body._tag === "Uint8Array") {
return JSON.parse(new TextDecoder().decode(body.body));
}
if (body._tag === "Raw") return body.body;
return undefined;
};
/** The 503 the server returns while the Playnite exporter has produced nothing. */
const EXPORT_UNAVAILABLE = {
_tag: "ExportUnavailable",
message:
"no exporter output under C:\\Users\\enrico\\AppData\\Roaming\\Playnite — is the Punktfunk Sync extension installed in Playnite?",
};
export const mockHttpClientLayer = (
get: Atom.AtomContext,
): Layer.Layer<HttpClient.HttpClient> => {
const registry = get.registry;
return Layer.succeed(HttpClient.HttpClient)(
HttpClient.make((request, url) =>
Effect.gen(function* () {
// Real-ish latency so skeletons/spinners are honest in dev + Storybook.
yield* Effect.sleep(Duration.millis(150 + Math.random() * 250));
const scenario = registry.get(scenarioAtom);
const json = (body: unknown, status = 200) =>
HttpClientResponse.fromWeb(request, Response.json(body, { status }));
switch (`${request.method} ${url.pathname}`) {
case "GET /api/status":
return json(fx.statusFor(scenario));
case "GET /api/config":
return json({ raw: currentRawConfig(registry, scenario) });
case "PUT /api/config": {
if (scenario === "errors") {
return json(
{
_tag: "ConfigInvalid",
issues:
'art.mode: expected "host" | "dataurl" | "off", got "inline"',
},
400,
);
}
const raw = requestJson(request) as RawConfig;
registry.set(mockRawConfigAtom, raw);
return json({ raw });
}
case "GET /api/preview": {
if (scenario === "firstRun") return json(EXPORT_UNAVAILABLE, 503);
return json(
fx.previewFor(scenario, currentRawConfig(registry, scenario)),
);
}
case "POST /api/sync": {
if (scenario === "syncing") {
return json({ _tag: "SyncInProgress" }, 409);
}
if (scenario === "firstRun") return json(EXPORT_UNAVAILABLE, 503);
return json(
fx.reportFor(scenario, currentRawConfig(registry, scenario)),
);
}
default:
return json(
{ error: `mock: unhandled ${request.method} ${url.pathname}` },
404,
);
}
}),
),
);
};
/** The SSE stand-in: cycles fixture frames (~2s steps) per the active scenario. */
export const mockStatusStreamAtom: Atom.Atom<EngineStatus | undefined> =
Atom.make((get) => {
const frames = fx.tickerFrames(get(scenarioAtom));
if (frames.length > 1) {
let i = 0;
const timer = setInterval(() => {
i += 1;
get.setSelf(frames[i % frames.length]);
}, 2000);
get.addFinalizer(() => clearInterval(timer));
}
return frames[0];
});
registerMocks({
httpLayer: mockHttpClientLayer,
statusStream: mockStatusStreamAtom,
});
+34
View File
@@ -0,0 +1,34 @@
// The dev-only seam between the always-shipped data layer and the mock transport.
// client.ts/atoms.ts reference THIS module (tiny, fixture-free); mocks/http.ts calls
// `registerMocks` at import time and is only ever imported by main.tsx behind
// `import.meta.env.DEV` and by Storybook's preview — so production app chunks stay free
// of fixture code without any conditional imports in the data layer.
import type { EngineStatus } from "@playnite/contract";
import type { Layer } from "effect";
import type { HttpClient } from "effect/unstable/http";
import type { Atom } from "effect/unstable/reactivity";
export type MockHttpLayerFactory = (
get: Atom.AtomContext,
) => Layer.Layer<HttpClient.HttpClient>;
type Registered = {
readonly httpLayer: MockHttpLayerFactory;
readonly statusStream: Atom.Atom<EngineStatus | undefined>;
};
let registered: Registered | undefined;
export const registerMocks = (mocks: Registered): void => {
registered = mocks;
};
/** The mock HttpClient layer, when mocks/http.ts has been imported. */
export const installedMockHttpLayer = (
get: Atom.AtomContext,
): Layer.Layer<HttpClient.HttpClient> | undefined => registered?.httpLayer(get);
/** The mock status ticker (the SSE stand-in), when mocks/http.ts has been imported. */
export const installedMockStatusStream = ():
| Atom.Atom<EngineStatus | undefined>
| undefined => registered?.statusStream;
+43
View File
@@ -0,0 +1,43 @@
// Mock-mode switches. Fixture-free ON PURPOSE: this module sits in the production
// import graph (client.ts reads apiModeAtom), so it must never pull in fixtures.
import { Atom } from "effect/unstable/reactivity";
export type ApiMode = "mock" | "live";
/** The fixture worlds the mock transport can serve (Storybook picks one per story). */
export type Scenario = "healthy" | "firstRun" | "syncing" | "errors";
/**
* Dev defaults to the zero-host mock loop; `bun run dev:live` (VITE_API_MODE=live) and
* production builds hit the real API. Storybook seeds this atom to "mock" per registry.
*/
export const defaultApiMode: ApiMode =
import.meta.env.DEV && import.meta.env.VITE_API_MODE !== "live"
? "mock"
: "live";
// keepAlive: both atoms are read OUTSIDE the reactive graph (the mock transport does a
// request-time registry.get) — without it, a registry-seeded value on a page that never
// subscribes them is collected and the read falls back to the default.
export const apiModeAtom: Atom.Writable<ApiMode> = Atom.keepAlive(
Atom.make<ApiMode>(defaultApiMode),
);
const SCENARIOS: ReadonlyArray<Scenario> = [
"healthy",
"firstRun",
"syncing",
"errors",
];
const isScenario = (value: unknown): value is Scenario =>
SCENARIOS.includes(value as Scenario);
/** Dev knob: `VITE_MOCK_SCENARIO=firstRun bun run dev` starts in another world. */
const defaultScenario: Scenario = isScenario(import.meta.env.VITE_MOCK_SCENARIO)
? import.meta.env.VITE_MOCK_SCENARIO
: "healthy";
export const scenarioAtom: Atom.Writable<Scenario> = Atom.keepAlive(
Atom.make<Scenario>(defaultScenario),
);
+15
View File
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LibraryPage } from "./library";
const meta = {
title: "Pages/Library",
component: LibraryPage,
args: { navigate: () => {} },
} satisfies Meta<typeof LibraryPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
export const Errors: Story = { parameters: { scenario: "errors" } };
+276
View File
@@ -0,0 +1,276 @@
// Library — subscribes previewAtom (the dry-run desired state) + configAtom (for the
// include toggles); mutates saveConfig directly (no draft — a toggle saves immediately).
//
// Covers: Playnite art is usually a LOCAL file on the host, which the browser cannot
// load, so `artUrl` routes those through the plugin's allow-listed /api/art proxy and
// leaves already-loadable http/data references alone.
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
import {
artUrl,
type PreviewResult,
type RawConfig,
type SyncReport,
} from "@playnite/contract";
import { Button } from "@unom/ui/button";
import Card from "@unom/ui/card";
import { EmptyState } from "@unom/ui/empty-state";
import { Switch } from "@unom/ui/form/switch";
import { Skeleton } from "@unom/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@unom/ui/table";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { Gamepad2, LibraryBig, PlugZap } from "lucide-react";
import { motion } from "motion/react";
import { useCallback, useState } from "react";
import { Gate } from "@/components/gate";
import {
configAtom,
previewAtom,
SAVE_CONFIG_KEYS,
saveConfig,
} from "@/data/atoms";
import { prefix } from "@/data/client";
import { errorText } from "@/lib/errors";
import type { PageProps } from "@/routes";
type Entry = PreviewResult["entries"][number];
const PageSkeleton = () => (
<div className="grid grid-cols-2 gap-card md:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }, (_, i) => (
<Skeleton key={i} className="aspect-[2/3] w-full rounded-card" />
))}
</div>
);
const NoExporter = () => (
<EmptyState
icon={PlugZap}
title="No library yet"
description="Install the Punktfunk Sync extension in Playnite and restart it — the games it exports show up here."
className="py-16"
/>
);
/** Toggle helper: rewrites gameOverrides on the CURRENT raw config and saves. */
const useSetIncluded = () => {
const config = useAtomValue(configAtom);
const write = useAtomSet(saveConfig, { mode: "promiseExit" });
return useCallback(
(id: string, included: boolean) => {
if (!AsyncResult.isSuccess(config)) return;
const raw = config.value.raw as RawConfig;
const overrides = { ...(raw.gameOverrides ?? {}) };
// `exclude` is an optionalKey — the key must be OMITTED, never set undefined.
const { exclude: _drop, ...rest } = overrides[id] ?? {};
if (included) {
if (Object.keys(rest).length === 0) delete overrides[id];
else overrides[id] = rest;
} else {
overrides[id] = { ...rest, exclude: true };
}
void write({
payload: { ...raw, gameOverrides: overrides },
reactivityKeys: [...SAVE_CONFIG_KEYS],
}).then((exit) => {
if (!Exit.isSuccess(exit)) {
toast.error("Couldn't update the game", {
description: errorText(Cause.squash(exit.cause)),
});
}
});
},
[config, write],
);
};
const GameCard = ({
entry,
index,
onToggle,
}: {
entry: Entry;
index: number;
onToggle: (id: string, included: boolean) => void;
}) => {
const [imgState, setImgState] = useState<"loading" | "ok" | "error">(
"loading",
);
const portrait = artUrl(prefix, entry.art?.portrait);
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
whileHover={{ scale: 1.02 }}
transition={{
duration: 0.4,
ease: [0.165, 0.84, 0.44, 1],
delay: Math.min(index, 24) * 0.04,
}}
>
<Card
padding={false}
className="overflow-hidden ring-0 transition-shadow duration-300 hover:ring-2 hover:ring-accent/40"
>
<div className="relative aspect-[2/3] w-full overflow-hidden bg-gradient-to-br from-secondary to-muted">
{portrait !== undefined && imgState !== "error" ? (
<>
{imgState === "loading" ? (
<Skeleton className="absolute inset-0 rounded-none" />
) : null}
<img
src={portrait}
alt={entry.title}
loading="lazy"
onLoad={() => setImgState("ok")}
onError={() => setImgState("error")}
className="h-full w-full object-cover"
/>
</>
) : (
<div className="flex h-full items-center justify-center">
<Gamepad2 className="size-8 text-muted-foreground/50" />
</div>
)}
</div>
<div className="space-y-2 p-3">
<div className="flex items-start justify-between gap-2">
<p className="line-clamp-2 text-sm font-medium leading-tight">
{entry.title}
</p>
<Switch
checked
onCheckedChange={() => onToggle(entry.external_id, false)}
aria-label={`Include ${entry.title}`}
/>
</div>
</div>
</Card>
</motion.div>
);
};
const SkippedCard = ({
report,
onToggle,
}: {
report: SyncReport;
onToggle: (id: string, included: boolean) => void;
}) => {
const [open, setOpen] = useState(false);
const total = report.skipped.length + report.excluded.length;
if (total === 0) return null;
return (
<Card padding={false} className="overflow-hidden">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between px-5 py-4 text-left text-sm font-medium hover:bg-secondary/40"
>
<span>Not in the library ({total})</span>
<span className="text-muted-foreground">{open ? "Hide" : "Show"}</span>
</button>
{open ? (
<div className="space-y-5 px-5 pb-5">
{report.skipped.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Reason</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{report.skipped.map((skip) => (
<TableRow key={skip.id}>
<TableCell>{skip.title}</TableCell>
<TableCell className="text-muted-foreground">
{skip.reason}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
{report.excluded.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">
Excluded by you
</p>
{report.excluded.map((excluded) => (
<div
key={excluded.id}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="truncate">{excluded.title}</span>
<Button
variant="outline"
size="sm"
onClick={() => onToggle(excluded.id, true)}
>
Re-include
</Button>
</div>
))}
</div>
) : null}
</div>
) : null}
</Card>
);
};
const LibraryContent = ({ preview }: { preview: PreviewResult }) => {
const setIncluded = useSetIncluded();
if (preview.entries.length === 0 && preview.report.excluded.length === 0) {
return (
<EmptyState
icon={LibraryBig}
title="Nothing to show yet"
description="Playnite exported a library, but every game was filtered out. Loosen the filters under Settings — for example, turn off “only installed games”."
/>
);
}
return (
<div className="space-y-6">
<div className="@container">
<div className="grid grid-cols-2 gap-card @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
{preview.entries.map((entry, index) => (
<GameCard
key={entry.external_id}
entry={entry}
index={index}
onToggle={setIncluded}
/>
))}
</div>
</div>
<SkippedCard report={preview.report} onToggle={setIncluded} />
</div>
);
};
export const LibraryPage = (_: PageProps) => {
const preview = useAtomValue(previewAtom);
const retry = useAtomRefresh(previewAtom);
return (
<Gate
result={preview}
skeleton={<PageSkeleton />}
retry={retry}
unavailable={<NoExporter />}
>
{(p) => <LibraryContent preview={p} />}
</Gate>
);
};
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { OverviewPage } from "./overview";
const meta = {
title: "Pages/Overview",
component: OverviewPage,
args: { navigate: () => {} },
} satisfies Meta<typeof OverviewPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
export const Syncing: Story = { parameters: { scenario: "syncing" } };
export const Errors: Story = { parameters: { scenario: "errors" } };
+290
View File
@@ -0,0 +1,290 @@
// Overview — subscribes liveStatusAtom (SSE-fresh engine status) + statusEventsAtom;
// mutates runSync. Before the Playnite exporter has written anything (no export file), it
// renders the two-step onboarding instead: that is the state every new install starts in.
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
import type { EngineStatus } from "@playnite/contract";
import { EventFeed } from "@unom/app-ui/event-feed";
import { StatCard } from "@unom/app-ui/stat-card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@unom/ui/accordion";
import { AnimatedButton } from "@unom/ui/button";
import Card from "@unom/ui/card";
import { CodeBlock } from "@unom/ui/code-block";
import { EmptyState } from "@unom/ui/empty-state";
import { Skeleton } from "@unom/ui/skeleton";
import { Spinner } from "@unom/ui/spinner";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import {
Image,
Library,
PlugZap,
RefreshCw,
TriangleAlert,
Users,
} from "lucide-react";
import { Gate } from "@/components/gate";
import {
liveStatusAtom,
RUN_SYNC_KEYS,
runSync,
statusAtom,
statusEventsAtom,
} from "@/data/atoms";
import { errorText } from "@/lib/errors";
import type { PageProps } from "@/routes";
const PageSkeleton = () => (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-card xl:grid-cols-4">
<Skeleton className="h-28" />
<Skeleton className="h-28" />
<Skeleton className="h-28" />
<Skeleton className="h-28" />
</div>
<Skeleton className="h-10 w-40" />
<Skeleton className="h-56" />
</div>
);
/** The state a fresh install is in: this plugin runs, the Playnite half does not yet. */
const FirstRun = ({ status }: { status: EngineStatus }) => (
<div className="space-y-6">
<EmptyState
icon={PlugZap}
title="Waiting for Playnite"
description={
"This half of the plugin is running. The other half — the Punktfunk Sync extension — lives inside Playnite and writes your library out on every change. Install it in Playnite (double-click punktfunk-sync.pext) and restart Playnite; your games appear here within seconds, no configuration needed."
}
className="py-16"
/>
<Card>
<h2 className="mb-1 text-lg font-semibold tracking-tight">
Where we're looking
</h2>
<p className="mb-3 text-sm text-muted-foreground">
The exporter drops the library into the first path below the inbox the
host's de-privileged plugin runner is allowed to read. The rest are
Playnite data directories we also probe.
</p>
<CodeBlock copy>{status.location.candidates.join("\n")}</CodeBlock>
</Card>
</div>
);
const exportAge = (status: EngineStatus): string => {
if (status.location.mtime === null) return "no export yet";
const minutes = Math.max(
0,
Math.round((Date.now() - status.location.mtime) / 60_000),
);
if (minutes === 0) return "written just now";
if (minutes < 60) return `written ${minutes} min ago`;
if (minutes < 60 * 24) return `written ${Math.round(minutes / 60)} h ago`;
return `written ${Math.round(minutes / (60 * 24))} d ago`;
};
const lastSyncHint = (status: EngineStatus): string => {
if (status.lastSync === null) return "never synced";
const minutes = Math.max(
0,
Math.round((Date.now() - status.lastSync.at) / 60_000),
);
if (minutes === 0) return "synced just now";
if (minutes < 60) return `synced ${minutes} min ago`;
return `synced ${Math.round(minutes / 60)} h ago`;
};
const SyncButton = ({ status }: { status: EngineStatus }) => {
const syncResult = useAtomValue(runSync);
const sync = useAtomSet(runSync, { mode: "promiseExit" });
const pending = status.syncing || AsyncResult.isWaiting(syncResult);
const onSync = () => {
void sync({ reactivityKeys: [...RUN_SYNC_KEYS] }).then((exit) => {
if (Exit.isSuccess(exit)) {
toast.success(
`Sync complete — ${exit.value.included} ${exit.value.included === 1 ? "title" : "titles"} in the library`,
);
} else {
toast.error("Sync failed", {
description: errorText(Cause.squash(exit.cause)),
});
}
});
};
return (
<AnimatedButton
onClick={onSync}
disabled={pending}
whileTap={{ scale: 0.97 }}
>
{pending ? (
<Spinner className="size-4" />
) : (
<RefreshCw className="size-4" />
)}
{pending ? "Syncing…" : "Sync now"}
</AnimatedButton>
);
};
/** A readable export that failed to decode — schema skew, truncation, a foreign file. */
const ExportProblem = ({ status }: { status: EngineStatus }) => {
if (status.exportError === null) return null;
return (
<Card className="border-error/40">
<div className="flex items-start gap-3">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-error" />
<div className="space-y-1">
<p className="text-sm font-medium">Can't read the Playnite export</p>
<p className="text-sm text-muted-foreground">{status.exportError}</p>
</div>
</div>
</Card>
);
};
const Warnings = ({ status }: { status: EngineStatus }) => {
const warnings = status.lastReport?.warnings ?? [];
if (warnings.length === 0) return null;
return (
<Card padding={false} className="overflow-hidden">
<Accordion type="single" collapsible>
<AccordionItem value="warnings" className="border-b-0">
<AccordionTrigger className="px-5 py-4 text-warn">
<span className="flex items-center gap-2">
<TriangleAlert className="size-4" />
{warnings.length} {warnings.length === 1 ? "warning" : "warnings"}{" "}
from the last sync
</span>
</AccordionTrigger>
<AccordionContent className="px-5 pb-4">
<ul className="space-y-1.5 text-sm text-muted-foreground">
{warnings.map((warning) => (
<li key={warning} className="flex gap-2">
<span aria-hidden className="text-warn">
</span>
{warning}
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
</Card>
);
};
const ActivityFeed = () => {
const events = useAtomValue(statusEventsAtom);
return (
<Card>
<h2 className="mb-3 text-lg font-semibold tracking-tight">Activity</h2>
<EventFeed
items={events}
maxHeight="16rem"
empty="Waiting for engine events…"
/>
</Card>
);
};
const Dashboard = ({ status }: { status: EngineStatus }) => {
const report = status.lastReport;
const inLibrary = status.lastSync?.count ?? report?.included ?? 0;
const skipped = report?.skipped.length ?? 0;
const sources = Object.keys(report?.perSource ?? {}).length;
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-card xl:grid-cols-4">
<StatCard
label="In library"
value={inLibrary}
icon={Library}
hint={lastSyncHint(status)}
/>
<StatCard
label="Sources"
value={sources}
icon={Users}
hint={
sources === 0
? "nothing synced yet"
: Object.entries(report?.perSource ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([source, n]) => `${source} ${n}`)
.join(" · ")
}
/>
<StatCard
label="Skipped"
value={skipped}
icon={TriangleAlert}
tone={skipped > 0 ? "warn" : "default"}
hint={skipped > 0 ? "see Library for reasons" : "nothing skipped"}
/>
<StatCard
label="Cover art"
value={status.artMode}
icon={Image}
tone={status.artMode === "off" ? "default" : "success"}
hint={
status.artMode === "host"
? "served by the host"
: status.artMode === "dataurl"
? "inlined in the payload"
: "covers disabled"
}
/>
</div>
<Card>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium">
Exporter connected
{status.playnite?.version !== null &&
status.playnite?.version !== undefined
? ` — Playnite ${status.playnite.version}`
: ""}
</p>
<p className="text-sm text-muted-foreground">
{status.location.viaIngest
? "Reading the host ingest inbox"
: "Reading Playnite's ExtensionsData"}{" "}
· {exportAge(status)}
{report !== null ? ` · ${report.considered} games exported` : ""}
</p>
</div>
<SyncButton status={status} />
</div>
</Card>
<ExportProblem status={status} />
<Warnings status={status} />
<ActivityFeed />
</div>
);
};
export const OverviewPage = (_: PageProps) => {
const status = useAtomValue(liveStatusAtom);
const retry = useAtomRefresh(statusAtom);
return (
<Gate result={status} skeleton={<PageSkeleton />} retry={retry}>
{(s) =>
s.location.file === null ? (
<FirstRun status={s} />
) : (
<Dashboard status={s} />
)
}
</Gate>
);
};
+14
View File
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SettingsPage } from "./settings";
const meta = {
title: "Pages/Settings",
component: SettingsPage,
args: { navigate: () => {} },
} satisfies Meta<typeof SettingsPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
+301
View File
@@ -0,0 +1,301 @@
// Settings — subscribes the config draft (raw edits, resolved display values) +
// liveStatusAtom (the Files paths); mutates saveConfig via the sticky SaveBar.
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
import type { ArtMode, RawConfig } from "@playnite/contract";
import { PathListEditor } from "@unom/app-ui/path-list-editor";
import { SettingsGroup, SettingsRow } from "@unom/app-ui/settings";
import { CodeBlock } from "@unom/ui/code-block";
import { InputNumber } from "@unom/ui/form/input-number";
import { InputText } from "@unom/ui/form/input-text";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unom/ui/form/select";
import { Switch } from "@unom/ui/form/switch";
import { Skeleton } from "@unom/ui/skeleton";
import { Gate } from "@/components/gate";
import { SaveBar } from "@/components/save-bar";
import { configAtom, liveStatusAtom, statusAtom } from "@/data/atoms";
import { useConfigDraft } from "@/data/drafts";
import type { PageProps } from "@/routes";
type ArtRaw = NonNullable<RawConfig["art"]>;
type SyncRaw = NonNullable<RawConfig["sync"]>;
type FilterRaw = NonNullable<RawConfig["filter"]>;
const PageSkeleton = () => (
<div className="space-y-6">
<Skeleton className="h-72" />
<Skeleton className="h-64" />
<Skeleton className="h-52" />
<Skeleton className="h-40" />
</div>
);
const ART_MODES: ReadonlyArray<{
readonly value: ArtMode;
readonly label: string;
readonly hint: string;
}> = [
{
value: "host",
label: "Served by the host",
hint: "Send each cover's local path and let the host serve the bytes. Scales to any library size — the recommended mode.",
},
{
value: "dataurl",
label: "Inlined in the payload",
hint: "Embed each cover in the library payload. Self-contained, but only fit for small libraries.",
},
{ value: "off", label: "No cover art", hint: "Sync titles with no art." },
];
/** A string list editor over one of the source filters (`sources` / `excludeSources`). */
const SourceList = ({
values,
onChange,
placeholder,
addLabel,
}: {
values: ReadonlyArray<string>;
onChange: (next: ReadonlyArray<string>) => void;
placeholder: string;
addLabel: string;
}) => (
<PathListEditor<string>
items={values}
onChange={onChange}
path={{ get: (s) => s, set: (_, value) => value, placeholder }}
makeItem={(s) => s}
addLabel={addLabel}
className="w-full"
/>
);
export const SettingsPage = (_: PageProps) => {
const config = useAtomValue(configAtom);
const retry = useAtomRefresh(configAtom);
const status = useAtomValue(liveStatusAtom);
const retryStatus = useAtomRefresh(statusAtom);
const draft = useConfigDraft();
const { resolved } = draft;
const patchArt = (patch: Partial<ArtRaw>) =>
draft.patch({ art: { ...(draft.draft.art ?? {}), ...patch } });
const patchSync = (patch: Partial<SyncRaw>) =>
draft.patch({ sync: { ...(draft.draft.sync ?? {}), ...patch } });
const patchFilter = (patch: Partial<FilterRaw>) =>
draft.patch({ filter: { ...(draft.draft.filter ?? {}), ...patch } });
const artMode = resolved?.art.mode ?? "host";
return (
<Gate result={config} skeleton={<PageSkeleton />} retry={retry}>
{() => (
<div className="space-y-6">
<SettingsGroup
title="Which games sync"
description="Playnite exports your whole library; these filters decide what reaches the Punktfunk library."
>
<SettingsRow
label="Only installed games"
description="Skip titles Playnite knows about but hasn't installed."
>
<Switch
checked={resolved?.filter.installedOnly ?? true}
onCheckedChange={(checked) =>
patchFilter({ installedOnly: checked === true })
}
/>
</SettingsRow>
<SettingsRow
label="Include hidden games"
description="Titles you flagged Hidden in Playnite."
>
<Switch
checked={resolved?.filter.includeHidden ?? false}
onCheckedChange={(checked) =>
patchFilter({ includeHidden: checked === true })
}
/>
</SettingsRow>
<SettingsRow
label="Only these sources"
description='Leave empty for every source. Names as Playnite shows them ("Steam", "GOG", "Epic"…); matching ignores case.'
vertical
>
<SourceList
values={resolved?.filter.sources ?? []}
onChange={(sources) => patchFilter({ sources: [...sources] })}
placeholder="Steam"
addLabel="Add source"
/>
</SettingsRow>
<SettingsRow
label="Never these sources"
description="Applied after the list above."
vertical
>
<SourceList
values={resolved?.filter.excludeSources ?? []}
onChange={(excludeSources) =>
patchFilter({ excludeSources: [...excludeSources] })
}
placeholder="Xbox"
addLabel="Exclude source"
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup
title="Cover art"
description="Playnite keeps cover art as files on this machine; this decides how it reaches your clients."
>
<SettingsRow
label="Delivery"
description={
ART_MODES.find((m) => m.value === artMode)?.hint ?? ""
}
>
<Select
value={artMode}
onValueChange={(value) => patchArt({ mode: value as ArtMode })}
>
<SelectTrigger size="sm" className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ART_MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
{artMode !== "off" ? (
<SettingsRow
label="Also send backgrounds"
description="Playnite's background image becomes hero art. Usually large."
>
<Switch
checked={resolved?.art.includeBackground ?? false}
onCheckedChange={(checked) =>
patchArt({ includeBackground: checked === true })
}
/>
</SettingsRow>
) : null}
{artMode === "dataurl" ? (
<SettingsRow
label="Size cap per image"
description="Bytes. A cover larger than this is dropped rather than inlined."
>
<InputNumber
className="w-36"
min={1}
step={100_000}
value={resolved?.art.maxBytes ?? 1_500_000}
onChange={(maxBytes) => patchArt({ maxBytes })}
/>
</SettingsRow>
) : null}
</SettingsGroup>
<SettingsGroup
title="Sync"
description="When the plugin re-reads Playnite's export and reconciles the library."
>
<SettingsRow
label="Watch for changes"
description="React to a new export immediately (polling stays on as the fallback)."
>
<Switch
checked={resolved?.sync.watch ?? true}
onCheckedChange={(checked) =>
patchSync({ watch: checked === true })
}
/>
</SettingsRow>
<SettingsRow
label="Poll interval"
description="Minutes between re-reads."
>
<InputNumber
className="w-28"
min={1}
value={resolved?.sync.pollMinutes ?? 5}
onChange={(pollMinutes) => patchSync({ pollMinutes })}
/>
</SettingsRow>
<SettingsRow
label="Debounce"
description="Milliseconds to wait after a change before re-reading."
>
<InputNumber
className="w-28"
min={0}
step={100}
value={resolved?.sync.debounceMs ?? 1500}
onChange={(debounceMs) => patchSync({ debounceMs })}
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup
title="Advanced"
description="Only needed when Playnite isn't where the plugin expects it."
>
<SettingsRow
label="Playnite data directory"
description="Overrides the auto-detected %APPDATA%\Playnite — for a portable install. The host ingest inbox is still read first."
>
<InputText
className="w-72"
spellCheck={false}
value={draft.draft.playniteDir ?? ""}
placeholder="C:\Playnite"
onChange={(event) => {
const dir = event.target.value;
// `playniteDir` is an optionalKey: undefined is dropped by
// JSON.stringify, so the PUT body omits it entirely.
draft.patch({
playniteDir: dir.length > 0 ? dir : undefined,
});
}}
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup
title="Files"
description="Where this plugin keeps its state, and where Playnite drops the library."
>
<Gate
result={status}
skeleton={<Skeleton className="h-24" />}
retry={retryStatus}
>
{(s) => (
<CodeBlock copy>
{`config: ${s.paths.config}\ncache: ${s.paths.cache}\ninbox: ${s.paths.ingest}\nexport: ${s.location.file ?? "(not written yet)"}`}
</CodeBlock>
)}
</Gate>
</SettingsGroup>
<SaveBar
dirty={draft.dirty}
saving={draft.saving}
onSave={draft.save}
onDiscard={draft.discard}
/>
</div>
)}
</Gate>
);
};
+9
View File
@@ -0,0 +1,9 @@
// The flat route list, shared by App.tsx and the pages (kept out of App.tsx so pages
// can type their `navigate` prop without importing the shell).
export const ROUTES = ["overview", "library", "settings"] as const;
export type Route = (typeof ROUTES)[number];
export type PageProps = {
readonly navigate: (route: Route) => void;
};
+12 -34
View File
@@ -1,41 +1,19 @@
/* The plugin UI's single stylesheet entry. The kit theme.css carries the console's
* violet identity (shadcn + @unom token vocabularies, Penner easings, `.dark` palette)
* so the embedded iframe is indistinguishable from the console around it. Do NOT import
* @unom/ui/styles/theme.css that is the library's own neutral palette. */
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "@punktfunk/plugin-kit/theme.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
/* Punktfunk violet identity dark only (the SPA is embedded on the console's dark canvas). The token /* Scan the design system's built output so its class names survive Tailwind's purge.
names feed Tailwind v4 utilities directly: `bg-card`, `text-muted`, `bg-brand`, `border-border` */ * Bare directory globs on purpose rebased file globs proved flaky. */
@theme { @source "../node_modules/@unom/ui/dist";
--color-bg: #0b0b0f; @source "../node_modules/@unom/app-ui/dist";
--color-surface: #131120;
--color-card: #1a1726;
--color-elevated: #221d33;
--color-border: #2a2440;
--color-fg: #e9e6f3;
--color-muted: #a39fbb;
--color-brand: #6c5bf3;
--color-brand-hover: #7d6ef5;
--color-brand-fg: #ffffff;
--color-ok: #34d399;
--color-warn: #fbbf24;
--color-err: #f87171;
--radius: 0.7rem;
}
html,
body { body {
margin: 0; @apply bg-background font-sans text-foreground antialiased;
background: var(--color-bg);
color: var(--color-fg);
font-family:
"Geist", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 6px;
} }
+144
View File
@@ -0,0 +1,144 @@
// Capture page screenshots from the built Storybook — the same harness the console
// uses (punktfunk/web/tools/screenshots.mjs): headless Chromium over storybook-static,
// every Pages/* + Shell/* story, rendered entirely from fixtures. No host required.
//
// bun run build-storybook # produce ./storybook-static
// bun run screenshots # → ./screenshots/<story-id>.png
//
// Env knobs: OUT (output dir), STORYBOOK_STATIC (input dir), SETTLE (ms after the
// page looks ready, default 600), WIDTH/HEIGHT/SCALE (viewport, default 1440x900@2x),
// ONLY (comma-separated story-id substring filter).
import { existsSync } from "node:fs";
import { mkdir, readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { extname, join, normalize, resolve } from "node:path";
import { chromium } from "playwright";
const ROOT = resolve(process.env.STORYBOOK_STATIC ?? "storybook-static");
const OUT = resolve(process.env.OUT ?? "screenshots");
const SETTLE = Number(process.env.SETTLE ?? 600);
const WIDTH = Number(process.env.WIDTH ?? 1440);
const HEIGHT = Number(process.env.HEIGHT ?? 900);
const SCALE = Number(process.env.SCALE ?? 2);
const ONLY = (process.env.ONLY ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Only the page-level + shell stories make sense as screenshots.
const TITLE_PREFIXES = ["Pages/", "Shell/"];
const MIME = {
".html": "text/html",
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".map": "application/json",
".ico": "image/x-icon",
};
function staticServer(rootDir) {
return createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
let path = decodeURIComponent(url.pathname);
if (path.endsWith("/")) path += "index.html";
// Contain the path to rootDir (no traversal).
const filePath = normalize(join(rootDir, path));
if (!filePath.startsWith(rootDir)) {
res.writeHead(403).end();
return;
}
const body = await readFile(filePath);
res.writeHead(200, {
"content-type": MIME[extname(filePath)] ?? "application/octet-stream",
});
res.end(body);
} catch {
res.writeHead(404).end();
}
});
}
async function listStories(rootDir) {
const indexPath = join(rootDir, "index.json");
if (!existsSync(indexPath)) {
throw new Error(
`${indexPath} not found — run \`bun run build-storybook\` first`,
);
}
const index = JSON.parse(await readFile(indexPath, "utf8"));
const entries = Object.values(index.entries ?? index.stories ?? {});
return entries
.filter((e) => e.type === "story" || e.type === undefined)
.filter((e) => TITLE_PREFIXES.some((p) => (e.title ?? "").startsWith(p)))
.filter((e) => ONLY.length === 0 || ONLY.some((f) => e.id.includes(f)))
.sort((a, b) => a.id.localeCompare(b.id));
}
async function main() {
if (!existsSync(ROOT)) {
throw new Error(
`${ROOT} not found — run \`bun run build-storybook\` first`,
);
}
const stories = await listStories(ROOT);
if (stories.length === 0)
throw new Error("no Pages/* or Shell/* stories found");
await mkdir(OUT, { recursive: true });
const server = staticServer(ROOT);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
const browser = await chromium.launch({
args: ["--force-color-profile=srgb"],
});
const context = await browser.newContext({
viewport: { width: WIDTH, height: HEIGHT },
deviceScaleFactor: SCALE,
colorScheme: "dark",
});
let ok = 0;
for (const story of stories) {
const page = await context.newPage();
const url = `http://127.0.0.1:${port}/iframe.html?id=${encodeURIComponent(
story.id,
)}&viewMode=story`;
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
// Story root mounted with real content.
await page.waitForSelector("#storybook-root > *", { timeout: 20_000 });
// Web fonts settled (else text reflows / falls back in the shot).
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(SETTLE);
const file = join(OUT, `${story.id}.png`);
await page.screenshot({ path: file });
console.log(`ok ${story.id} -> ${file}`);
ok++;
} catch (e) {
console.warn(`FAIL ${story.id}: ${e.message}`);
} finally {
await page.close();
}
}
await browser.close();
await new Promise((r) => server.close(r));
console.log(`\n${ok}/${stories.length} stories captured -> ${OUT}`);
if (ok === 0) process.exit(1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+9 -4
View File
@@ -1,15 +1,20 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "strict": true,
"noUnusedLocals": true, "noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true, "noEmit": true,
"types": [] "types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["src", "vite.config.ts"] "include": ["src", ".storybook", "vite.config.ts"]
} }
+25 -5
View File
@@ -1,16 +1,36 @@
// Vite config for the plugin SPA. `base: "./"` because the console serves the built
// bundle from /plugin-ui/playnite/ — asset URLs must stay relative to survive the proxy.
// Dev has two modes (see src/mocks/): plain `bun run dev` runs entirely on fixtures,
// `bun run dev:live` proxies /api to the plugin's dev server (plugin/src/dev.ts, :5886).
import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
// `base: "./"` (relative asset URLs) is the plugin-ui-surface contract: the SPA is mounted under
// `/plugin-ui/playnite/` behind the console proxy. Built into `../dist/ui`, which `servePluginUi`'s
// staticDir points at.
export default defineConfig({ export default defineConfig({
base: "./", base: "./",
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
// Scan only the app entry — without this the dev dep-scanner also crawls any
// storybook-static/ build output sitting in the workspace and errors out.
optimizeDeps: { entries: ["index.html"] },
build: { build: {
outDir: "../dist/ui", outDir: "../plugin/dist/ui",
emptyOutDir: true, emptyOutDir: true,
}, },
server: { port: 5601 }, server: {
port: 5601,
...(process.env.VITE_API_MODE === "live"
? {
proxy: {
"/api": {
target: process.env.PLAYNITE_URL ?? "http://127.0.0.1:5886",
},
},
}
: {}),
},
}); });