Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8acf922de | |||
| b81e03685a | |||
| e832201669 |
+32
-13
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
+3
-3
@@ -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/"
|
||||||
|
|||||||
@@ -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": ""
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}`;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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 },
|
||||||
|
) {}
|
||||||
@@ -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;
|
||||||
@@ -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,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"]
|
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,13 @@ using Playnite.SDK.Plugins;
|
|||||||
namespace PunktfunkSync
|
namespace PunktfunkSync
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes the Playnite library to <c>punktfunk-library.json</c> in this plugin's data directory
|
/// Writes the Playnite library to <c>punktfunk-library.json</c> whenever it changes, to two
|
||||||
/// (<c>…/Playnite/ExtensionsData/<this-id>/</c>) whenever it changes. The Punktfunk
|
/// places: this plugin's data directory (<c>…/Playnite/ExtensionsData/<this-id>/</c>) and —
|
||||||
/// <c>playnite</c> plugin globs for that file and reconciles it into the host game library — this
|
/// best-effort — the host's ingest inbox (<c>%ProgramData%\punktfunk\ingest\playnite\</c>). The
|
||||||
/// side stays a dumb, dependency-free exporter.
|
/// second matters because the host's plugin runner is de-privileged (<c>NT AUTHORITY\LocalService</c>)
|
||||||
|
/// and cannot read this user's <c>%APPDATA%</c>; <c>punktfunk-host plugins enable</c> makes the
|
||||||
|
/// ingest inbox user-writable so we can drop the file where the runner can read it. This side
|
||||||
|
/// stays a dumb, dependency-free exporter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PunktfunkSyncPlugin : GenericPlugin
|
public class PunktfunkSyncPlugin : GenericPlugin
|
||||||
{
|
{
|
||||||
@@ -27,12 +30,19 @@ namespace PunktfunkSync
|
|||||||
public override Guid Id { get; } = Guid.Parse("d9f1b3a2-7c64-4e58-9a1b-6f2e3c4d5a6b");
|
public override Guid Id { get; } = Guid.Parse("d9f1b3a2-7c64-4e58-9a1b-6f2e3c4d5a6b");
|
||||||
|
|
||||||
private readonly string exportPath;
|
private readonly string exportPath;
|
||||||
|
private readonly string ingestPath;
|
||||||
private readonly Timer debounce;
|
private readonly Timer debounce;
|
||||||
|
|
||||||
public PunktfunkSyncPlugin(IPlayniteAPI api) : base(api)
|
public PunktfunkSyncPlugin(IPlayniteAPI api) : base(api)
|
||||||
{
|
{
|
||||||
Properties = new GenericPluginProperties { HasSettings = false };
|
Properties = new GenericPluginProperties { HasSettings = false };
|
||||||
exportPath = Path.Combine(GetPluginUserDataPath(), "punktfunk-library.json");
|
exportPath = Path.Combine(GetPluginUserDataPath(), "punktfunk-library.json");
|
||||||
|
// The host runner is de-privileged and can't read our %APPDATA%; drop a copy in the
|
||||||
|
// host's user-writable ingest inbox too (see WriteAtomic). Keep this in lockstep with the
|
||||||
|
// TS reader's `ingestDir()` (`<config_dir>/ingest/playnite`).
|
||||||
|
ingestPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
|
"punktfunk", "ingest", "playnite", "punktfunk-library.json");
|
||||||
|
|
||||||
// Coalesce bursts of per-item events (a metadata download touches many games) into one write.
|
// Coalesce bursts of per-item events (a metadata download touches many games) into one write.
|
||||||
debounce = new Timer(3000) { AutoReset = false };
|
debounce = new Timer(3000) { AutoReset = false };
|
||||||
@@ -123,14 +133,37 @@ namespace PunktfunkSync
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Write via a temp file + move so a reader never sees a half-written export.</summary>
|
/// <summary>
|
||||||
|
/// Write the export to its ExtensionsData home (always) and to the host ingest inbox
|
||||||
|
/// (best-effort — only when a punktfunk host is present on this box, so we never create a
|
||||||
|
/// stray %ProgramData%\punktfunk on a machine that has no host).
|
||||||
|
/// </summary>
|
||||||
private void WriteAtomic(string json)
|
private void WriteAtomic(string json)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(exportPath));
|
WriteAtomicTo(exportPath, json);
|
||||||
var tmp = exportPath + ".tmp";
|
try
|
||||||
|
{
|
||||||
|
var pfDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
|
"punktfunk");
|
||||||
|
if (Directory.Exists(pfDir)) WriteAtomicTo(ingestPath, json);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// The inbox only exists (and is writable) where `plugins enable` has run; a failure
|
||||||
|
// here just means the de-privileged runner falls back to the ExtensionsData scan.
|
||||||
|
logger.Info($"Punktfunk Sync: ingest drop skipped ({ex.Message})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Write via a temp file + move so a reader never sees a half-written export.</summary>
|
||||||
|
private static void WriteAtomicTo(string path, string json)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||||
|
var tmp = path + ".tmp";
|
||||||
File.WriteAllText(tmp, json);
|
File.WriteAllText(tmp, json);
|
||||||
if (File.Exists(exportPath)) File.Delete(exportPath);
|
if (File.Exists(path)) File.Delete(path);
|
||||||
File.Move(tmp, exportPath);
|
File.Move(tmp, path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -1,55 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@punktfunk/plugin-playnite",
|
"name": "playnite-workspace",
|
||||||
"version": "0.1.0",
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
||||||
@@ -44,6 +45,29 @@ describe("locateExport", () => {
|
|||||||
expect(loc.dir).toBe(dir);
|
expect(loc.dir).toBe(dir);
|
||||||
expect(loc.file).toBeNull();
|
expect(loc.file).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reads the ingest inbox first — the de-privileged runner's source", () => {
|
||||||
|
const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "pf-cfg-"));
|
||||||
|
const prev = process.env.PUNKTFUNK_CONFIG_DIR;
|
||||||
|
process.env.PUNKTFUNK_CONFIG_DIR = cfg;
|
||||||
|
try {
|
||||||
|
const inbox = path.join(cfg, "ingest", "playnite");
|
||||||
|
fs.mkdirSync(inbox, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(inbox, EXPORT_FILE), JSON.stringify(validDoc));
|
||||||
|
// Even with a Playnite ExtensionsData dir present, the ingest drop wins.
|
||||||
|
const pd = layout("with-ingest", validDoc);
|
||||||
|
const loc = locateExport(resolveConfig({ playniteDir: pd }));
|
||||||
|
expect(loc.dir).toBe(inbox);
|
||||||
|
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 {
|
||||||
|
if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
|
||||||
|
else process.env.PUNKTFUNK_CONFIG_DIR = prev;
|
||||||
|
fs.rmSync(cfg, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("readExport", () => {
|
describe("readExport", () => {
|
||||||
@@ -53,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);
|
||||||
@@ -68,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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Vendored
+6
@@ -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
@@ -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
@@ -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
@@ -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({});
|
|
||||||
@@ -1,318 +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 {
|
|
||||||
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);
|
|
||||||
if (!loc.dir) return;
|
|
||||||
// Prefer watching ExtensionsData (where the file lives); fall back to the Playnite dir if the
|
|
||||||
// exporter hasn't created it yet, so we notice it appearing.
|
|
||||||
const extData = path.join(loc.dir, "ExtensionsData");
|
|
||||||
const target = fs.existsSync(extData) ? extData : loc.dir;
|
|
||||||
try {
|
|
||||||
const watcher = fs.watch(target, { recursive: true }, () =>
|
|
||||||
this.debouncedSync(),
|
|
||||||
);
|
|
||||||
this.watchers.push(watcher);
|
|
||||||
} 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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}`);
|
|
||||||
@@ -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
@@ -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();
|
|
||||||
@@ -1,63 +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>/playnite/config.json` — operator-editable, atomic-written. */
|
|
||||||
export const configPath = (): string => path.join(pluginDir(), "config.json");
|
|
||||||
|
|
||||||
/** `<config_dir>/playnite/cache.json` — disposable derived state (last reconcile fingerprint). */
|
|
||||||
export const cachePath = (): string => path.join(pluginDir(), "cache.json");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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");
|
|
||||||
};
|
|
||||||
-135
@@ -1,135 +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 one wrinkle is *which user's* `%APPDATA%`: if the runner runs as the interactive user we
|
|
||||||
// find it directly; if it runs as SYSTEM/another user we fall back to scanning `C:\Users\*`. An
|
|
||||||
// explicit `playniteDir` in the config always wins.
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
// First, a dir that actually holds an export file.
|
|
||||||
for (const dir of candidates) {
|
|
||||||
const hit = findExportUnder(dir);
|
|
||||||
if (hit) return { dir, 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, 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;
|
|
||||||
};
|
|
||||||
@@ -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),
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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()),
|
|
||||||
});
|
|
||||||
@@ -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
@@ -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[];
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
};
|
||||||
+42
-414
@@ -1,424 +1,52 @@
|
|||||||
|
// The app frame: kit router (flat routes, console deep-link bridge) inside the shared
|
||||||
|
// PluginShell chrome. Pages own their data; the shell owns navigation + the toaster.
|
||||||
|
|
||||||
|
import { createPluginRouter, useIsEmbedded } from "@punktfunk/plugin-kit/react";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
PluginShell,
|
||||||
CheckCircle2,
|
type PluginShellNavItem,
|
||||||
FolderSearch,
|
} from "@unom/app-ui/plugin-shell";
|
||||||
Gamepad2,
|
import { LayoutDashboard, LibraryBig, Settings2 } from "lucide-react";
|
||||||
Library,
|
import type { FC } from "react";
|
||||||
Loader2,
|
import { LibraryPage } from "@/pages/library";
|
||||||
RefreshCw,
|
import { OverviewPage } from "@/pages/overview";
|
||||||
Save,
|
import { SettingsPage } from "@/pages/settings";
|
||||||
} from "lucide-react";
|
import { type PageProps, ROUTES, type Route } from "@/routes";
|
||||||
import { type ReactNode, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
type Config,
|
|
||||||
getConfig,
|
|
||||||
putConfig,
|
|
||||||
runSync,
|
|
||||||
type Status,
|
|
||||||
useStatusStream,
|
|
||||||
} from "./api.js";
|
|
||||||
|
|
||||||
const relTime = (ms?: number): string => {
|
const { usePluginRoute } = createPluginRouter(ROUTES, "overview");
|
||||||
if (!ms) return "never";
|
|
||||||
const s = Math.round((Date.now() - ms) / 1000);
|
const NAV: ReadonlyArray<PluginShellNavItem> = [
|
||||||
if (s < 60) return `${s}s ago`;
|
{ id: "overview", label: "Overview", icon: LayoutDashboard },
|
||||||
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
{ id: "library", label: "Library", icon: LibraryBig },
|
||||||
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
{ id: "settings", label: "Settings", icon: Settings2 },
|
||||||
return `${Math.round(s / 86400)}d ago`;
|
];
|
||||||
|
|
||||||
|
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;
|
|
||||||
right?: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section className="rounded-[var(--radius)] border border-border bg-card p-5">
|
|
||||||
<header className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<h2 className="flex items-center gap-2 text-sm font-semibold tracking-wide text-muted uppercase">
|
|
||||||
<span className="text-brand">{icon}</span>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{right}
|
|
||||||
</header>
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function Toggle({
|
export const App = () => {
|
||||||
label,
|
const { route, navigate } = usePluginRoute();
|
||||||
hint,
|
const embedded = useIsEmbedded();
|
||||||
checked,
|
const Page = PAGES[route];
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
hint?: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<label className="flex cursor-pointer items-start justify-between gap-4 py-2">
|
<PluginShell
|
||||||
<span>
|
nav={NAV}
|
||||||
<span className="block text-sm">{label}</span>
|
active={route}
|
||||||
{hint && <span className="block text-xs text-muted">{hint}</span>}
|
onNavigate={(id) => navigate(id as Route)}
|
||||||
</span>
|
standaloneHeader={embedded ? undefined : <StandaloneHeader />}
|
||||||
<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
|
<Page navigate={navigate} />
|
||||||
className={`block h-5 w-5 rounded-full bg-white transition-transform ${checked ? "translate-x-5" : "translate-x-0.5"}`}
|
</PluginShell>
|
||||||
/>
|
|
||||||
</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">
|
|
||||||
<span className="text-3xl font-semibold">
|
|
||||||
{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
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}) {}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
+13
-2
@@ -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>
|
||||||
|
<RegistryProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</RegistryProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
@@ -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" } };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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" } };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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" } };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+10
-5
@@ -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": ".",
|
||||||
"include": ["src", "vite.config.ts"]
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", ".storybook", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-5
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user