Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8acf922de | |||
| b81e03685a |
+32
-13
@@ -1,10 +1,11 @@
|
||||
# CI for @punktfunk/plugin-playnite (Gitea Actions).
|
||||
# build — the TS plugin + SPA: lint, typecheck, test, bundle (mirrors the rom-manager plugin CI).
|
||||
# exporter — the C# Playnite extension → a `.pext`, built net462 with the .NET SDK on Linux (no
|
||||
# Windows/MSBuild needed) and uploaded as a workflow artifact.
|
||||
# publish — npm publish to the Gitea registry on a `v*` tag.
|
||||
# Installs resolve @punktfunk/* from the Gitea registry via the bunfig scope map + REGISTRY_TOKEN;
|
||||
# everything else (effect, react, tailwind…) comes from npm.
|
||||
# CI for the playnite workspace (Gitea Actions).
|
||||
# build — the three bun workspaces (contract + plugin + ui): one root install, lint,
|
||||
# per-package typecheck, domain tests, bundle + SPA. Mirrors rom-manager's.
|
||||
# exporter — the C# Playnite extension → a `.pext`, built net462 with the .NET SDK on
|
||||
# Linux (no Windows/MSBuild needed) and uploaded as a workflow artifact.
|
||||
# publish — npm publish to the Gitea registry on a `v*` tag, from plugin/.
|
||||
# @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
|
||||
|
||||
on:
|
||||
@@ -31,22 +32,38 @@ jobs:
|
||||
run: |
|
||||
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"
|
||||
- name: Install
|
||||
- name: Install (workspace)
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Lint & format
|
||||
run: bunx biome check
|
||||
- name: Typecheck (backend)
|
||||
- name: Typecheck (contract)
|
||||
working-directory: contract
|
||||
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
|
||||
- name: Build backend + SPA
|
||||
- name: Build backend bundle + SPA
|
||||
working-directory: plugin
|
||||
run: bun run build:all
|
||||
- name: Typecheck (UI)
|
||||
working-directory: ui
|
||||
run: bunx tsc --noEmit
|
||||
- name: Sanity — plugin default export is a valid PluginDef
|
||||
working-directory: plugin
|
||||
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:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -90,12 +107,14 @@ jobs:
|
||||
run: |
|
||||
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"
|
||||
- name: Install
|
||||
- name: Install (workspace)
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Tag matches package version
|
||||
working-directory: plugin
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME#v}"
|
||||
PKG="$(node -p "require('./package.json').version")"
|
||||
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME != package version $PKG"; exit 1; }
|
||||
- name: Publish (prepublishOnly builds backend + SPA)
|
||||
working-directory: plugin
|
||||
run: bun publish
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
plugin/dist
|
||||
ui/dist
|
||||
ui/node_modules
|
||||
ui/storybook-static
|
||||
ui/screenshots
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -3,24 +3,25 @@
|
||||
Sync your **[Playnite](https://playnite.link)** library into the Punktfunk host game library. Every
|
||||
store and emulator Playnite manages — Steam, GOG, Epic, Xbox, itch, RetroArch, standalone emulators,
|
||||
manually-added games — shows up in Punktfunk's grid on every client, and launching a title hands it
|
||||
straight back to Playnite, which performs the real launch.
|
||||
straight back to Playnite, which performs the real launch. It ships a web UI that lives **inside the
|
||||
Punktfunk console** (no second password, no second port).
|
||||
|
||||
It has two halves, both on the **same Windows box** as the Punktfunk host:
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐ ┌──────────────────────────────────────────┐
|
||||
┌─────────────────────────────┐ ┌────────────────────────────────────────────┐
|
||||
│ Playnite │ │ Punktfunk host (this plugin, in the │
|
||||
│ └ Punktfunk Sync extension │ JSON │ scripting runner) │
|
||||
│ writes │ ─────▶ │ reads punktfunk-library.json, embeds art, │
|
||||
│ punktfunk-library.json │ │ PUT /library/provider/playnite │
|
||||
└─────────────────────────────┘ └──────────────────────────────────────────┘
|
||||
│ writes │ ─────▶ │ reads punktfunk-library.json, maps it to │
|
||||
│ punktfunk-library.json │ │ entries, PUT /library/provider/playnite │
|
||||
└─────────────────────────────┘ └────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **The Playnite exporter** (`exporter/`, C#) is a tiny [GenericPlugin](https://api.playnite.link/docs/tutorials/extensions/genericPlugins.html)
|
||||
that writes your library to a JSON file whenever it changes. Playnite locks its library database
|
||||
while running, so reading it from *inside* Playnite is the only robust way — this is that adapter,
|
||||
and nothing more.
|
||||
- **This plugin** (TypeScript, on [`@punktfunk/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
|
||||
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
|
||||
```
|
||||
|
||||
Open the Punktfunk console — a **Playnite** entry appears in the nav. (Headless box without the
|
||||
console? The engine is fully functional from a `config.json` alone — see [Headless](#headless--cli).)
|
||||
Open the Punktfunk console — a **Playnite** entry appears in the nav. It will say it's waiting for
|
||||
Playnite until you do step 2.
|
||||
|
||||
### 2 · The Punktfunk Sync extension (in Playnite)
|
||||
|
||||
@@ -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.
|
||||
Restart Playnite once.
|
||||
|
||||
That's it. The console's **Playnite** page shows "Exporter connected" and your games sync within
|
||||
seconds of any library change.
|
||||
That's it. The console's **Playnite** page flips to "Exporter connected" and your games sync within
|
||||
seconds of any library change. No configuration needed.
|
||||
|
||||
> The console page can also **deploy the exporter for you** if you'd rather not handle the `.pext` —
|
||||
> it drops the extension into `%APPDATA%\Playnite\Extensions\` and asks you to restart Playnite.
|
||||
> (Best-effort: it needs read/write access to the interactive user's `%APPDATA%`; the `.pext` is the
|
||||
> reliable fallback.)
|
||||
> **Headless box** without the console? The engine is fully functional from a `config.json` alone —
|
||||
> see [Headless / CLI](#headless--cli).
|
||||
|
||||
## The ingest inbox
|
||||
|
||||
Punktfunk's plugin runner is **de-privileged** on Windows (it runs as `NT AUTHORITY\LocalService`),
|
||||
so it cannot read the interactive user's `%APPDATA%\Playnite\ExtensionsData\…` — which is where a
|
||||
Playnite extension normally writes. So the exporter drops a second copy into the host's **ingest
|
||||
inbox**, `<config_dir>\ingest\playnite\punktfunk-library.json`, which `punktfunk-host plugins enable`
|
||||
makes user-writable. The plugin reads that inbox **first**, and falls back to scanning Playnite's own
|
||||
`ExtensionsData` (which works for a same-user/SYSTEM runner, or on Linux under Wine).
|
||||
|
||||
This is why the plugin works at all under the security tiers; if the console says it's reading the
|
||||
ingest inbox, that's the healthy path.
|
||||
|
||||
## Launching
|
||||
|
||||
@@ -64,17 +75,21 @@ maintain.
|
||||
## Box art
|
||||
|
||||
Playnite stores cover art as **local files** on the host. Rather than shipping image bytes in the
|
||||
reconcile (which doesn't scale — a large library blows past the host's request-body limit), the plugin
|
||||
sends the local file **path** and the host serves the cover through its art proxy
|
||||
reconcile (which doesn't scale — a large library blows past the host's request-body limit), the
|
||||
default is to send the local file **path** and let the host serve the cover through its art proxy
|
||||
(`/library/art/…`, exactly like Steam art). The payload stays tiny at any library size.
|
||||
|
||||
| `art.mode` | Behaviour |
|
||||
|--------------|-----------------------------------------------------------------|
|
||||
| `host` | **Default.** Send the cover's local path; the host serves the bytes. Scales to thousands of titles. Requires a host with the provider-art proxy. |
|
||||
| `dataurl` | Inline the cover as a size-capped `data:` URL (`maxBytes` caps one image). Self-contained, but for **small** libraries only — 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). |
|
||||
|
||||
`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
|
||||
|
||||
@@ -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.sources` | `[]` | If non-empty, keep only these sources (`"Steam"`, `"GOG"`…). Case-insensitive. |
|
||||
| `filter.excludeSources` | `[]` | Drop these sources. |
|
||||
| `gameOverrides` | `{}` | Per-game `{ "<guid>": { "exclude": true } }` — the Library page's toggle. |
|
||||
|
||||
Edit them in the console's **Playnite** page, or in `config.json` directly.
|
||||
Edit them in the console's **Playnite** page, or in `config.json` directly. Only the keys you author
|
||||
are stored: defaults live in the schema (`contract/src/config.ts`) and are never baked into your file.
|
||||
|
||||
## Headless / CLI
|
||||
|
||||
The plugin owns `<config_dir>/playnite/config.json` (see [`config.example.json`](./config.example.json)).
|
||||
It's fully functional from that file alone. A debug CLI mirrors the engine:
|
||||
The plugin owns `<config_dir>/plugin-state/playnite/config.json` and is fully functional from that
|
||||
file alone. A debug CLI mirrors the engine:
|
||||
|
||||
```sh
|
||||
bunx punktfunk-plugin-playnite where # where the exporter output was found (+ probed paths)
|
||||
@@ -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
|
||||
```
|
||||
|
||||
For a host without the console, set `ui.standalone: true` and a password
|
||||
(`bunx punktfunk-plugin-playnite set-password <pw>`); the UI then serves on `ui.port` (default 47994).
|
||||
Provider entries are deliberately **left** in the library when the plugin stops, so your games survive
|
||||
restarts and reboots; `uninstall` is the explicit way to clear them.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A **Windows** Punktfunk host running the scripting runner (`punktfunk-scripting`).
|
||||
- **Playnite** on the same box, with the Punktfunk Sync extension installed.
|
||||
- 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
|
||||
bun install
|
||||
bun run build:all # backend (dist/) + SPA (dist/ui/)
|
||||
bun test
|
||||
bun install # one workspace install (Gitea registry for @punktfunk/@unom scopes)
|
||||
bun test # domain tests (plugin/)
|
||||
bun run typecheck # contract → plugin → ui
|
||||
bun run build # backend bundle + SPA → plugin/dist
|
||||
|
||||
cd ui && bun run dev # UI with ZERO host running (in-browser mock layer + scenarios)
|
||||
cd ui && bun run storybook # per-page stories on the same fixtures (port 6014)
|
||||
cd ui && bun run screenshots # headless captures of every page story
|
||||
cd plugin && bun run dev # auth-free dev API on :5886 …
|
||||
cd ui && bun run dev:live # … and the UI proxied against it
|
||||
```
|
||||
|
||||
`@unom/*` + `@effect/atom-react` are build-time only — the SPA ships as static assets, so the
|
||||
published plugin carries no UI runtime dependencies.
|
||||
|
||||
The exporter (needs the .NET SDK; builds net462 on any OS via reference assemblies):
|
||||
|
||||
```sh
|
||||
@@ -124,6 +163,21 @@ dotnet build exporter/PunktfunkSync.csproj -c Release
|
||||
( cd exporter/bin/Release && zip -j ../../../punktfunk-sync.pext extension.yaml PunktfunkSync.dll )
|
||||
```
|
||||
|
||||
**The export shape is a contract with the C# side.** `contract/src/export.ts` and
|
||||
`exporter/Model.cs` describe the same document; change one and you must change the other, and bump
|
||||
`SCHEMA` if the change is breaking. The reader refuses a document stamped with a higher `SCHEMA` than
|
||||
it understands rather than mis-reading it.
|
||||
|
||||
## Security
|
||||
|
||||
- The **ingest inbox is writable by any local user** — treat what it contains as untrusted. Game ids
|
||||
are validated as .NET Guids before they reach a launch command; the `playnite://` URI is the only
|
||||
thing ever interpolated, and Playnite performs the launch.
|
||||
- `config.json` lives in the hardened `<config_dir>/plugin-state`; the plugin refuses a
|
||||
group/world-writable one.
|
||||
- `GET /api/art` serves only paths referenced by the current export, and only image extensions.
|
||||
- No outbound network at all. No telemetry.
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0.
|
||||
|
||||
+8
-1
@@ -7,7 +7,14 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!dist", "!ui/dist", "!**/node_modules", "!exporter"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!**/node_modules",
|
||||
"!exporter",
|
||||
"!ui/storybook-static",
|
||||
"!ui/screenshots"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Resolve the `@punktfunk` scope from the Gitea npm registry (like `@punktfunk/host` itself), so
|
||||
# `bun add @punktfunk/plugin-playnite` pulls the shared `@punktfunk/host` + `effect` deps out of the
|
||||
# box. During local development the SDK is consumed via `bun link @punktfunk/host`.
|
||||
# Resolve the @punktfunk and @unom scopes from the Gitea npm registry for the whole
|
||||
# workspace (plugin deps + UI design system).
|
||||
[install.scopes]
|
||||
"@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",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src", "test"],
|
||||
"exclude": ["ui", "dist", "node_modules"]
|
||||
"include": ["src"]
|
||||
}
|
||||
+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
|
||||
```
|
||||
|
||||
Shape (kept in lockstep with `../src/export-schema.ts`):
|
||||
Shape (kept in lockstep with `../contract/src/export.ts`):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
|
||||
+10
-46
@@ -1,55 +1,19 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-playnite",
|
||||
"version": "0.1.1",
|
||||
"private": false,
|
||||
"name": "playnite-workspace",
|
||||
"private": true,
|
||||
"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.",
|
||||
"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",
|
||||
"workspaces": [
|
||||
"contract",
|
||||
"plugin",
|
||||
"playnite",
|
||||
"game-library",
|
||||
"game-streaming"
|
||||
"ui"
|
||||
],
|
||||
"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": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"build:ui": "cd ui && bun install --silent && bun run build",
|
||||
"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"
|
||||
"typecheck": "cd contract && bun run typecheck && cd ../plugin && bun run typecheck && cd ../ui && bun run typecheck",
|
||||
"test": "cd plugin && bun test",
|
||||
"build": "cd plugin && bun run build:all",
|
||||
"check": "bunx biome check ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.2",
|
||||
"@types/bun": "^1.3.0",
|
||||
"typescript": "^5.9.3"
|
||||
"@biomejs/biome": "^2.5.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// the `definePlugin` name, the library provider id, and the console-nav id are all the short
|
||||
// `playnite`.
|
||||
// Plugin identity. The package name is `@punktfunk/plugin-playnite` (the runner's scoped
|
||||
// glob), but the `definePluginKit` name, the library provider id, the state/ingest dir
|
||||
// names, and the console-nav id are all the short `playnite`.
|
||||
export const PLUGIN_NAME = "playnite";
|
||||
export const PROVIDER_ID = "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
|
||||
// reconcile expects, plus a report of what was included and why anything was skipped. No IO here (art
|
||||
// is injected via `artFor`), so it's fully unit-testable.
|
||||
// The pure core: turn a decoded library export + the config into the declarative entry
|
||||
// list the host reconcile expects, plus a report of what was included and why anything was
|
||||
// skipped. No IO here (art is injected via `artFor`), so it is fully unit-testable.
|
||||
|
||||
import type { Config } from "../config.js";
|
||||
import type { ExportedGame, LibraryExport } from "../export-schema.js";
|
||||
import type { Artwork, ProviderEntryInput } from "../wire.js";
|
||||
import type {
|
||||
Config,
|
||||
ExportedGame,
|
||||
LibraryExport,
|
||||
SyncReport,
|
||||
} from "@playnite/contract";
|
||||
import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
|
||||
|
||||
export interface SkippedGame {
|
||||
id: string;
|
||||
title: string;
|
||||
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. */
|
||||
/** 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;
|
||||
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
|
||||
* real launch (any store or emulator, uniformly). The host runs `command` values via
|
||||
* `cmd.exe /c <value>` in the interactive session, so `start "" "<uri>"` invokes the URI handler. */
|
||||
/** The launch command for a Playnite game: hand the `playnite://` URI to Playnite so IT
|
||||
* performs the real launch (any store or emulator, uniformly). The host runs `command`
|
||||
* values via `cmd.exe /c <value>` in the interactive session, so `start "" "<uri>"`
|
||||
* invokes the URI handler. */
|
||||
export const launchCommand = (
|
||||
id: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): ProviderEntryInput["launch"] => {
|
||||
): ProviderEntry["launch"] => {
|
||||
const uri = `playnite://playnite/start/${id}`;
|
||||
// Playnite is Windows-only; the Windows host is the supported target. `start ""` gives cmd a
|
||||
// (empty) window title so a quoted URI isn't mistaken for one.
|
||||
if (platform === "win32")
|
||||
// Playnite is Windows-only; the Windows host is the supported target. `start ""` gives
|
||||
// cmd an (empty) window title so a quoted URI isn't mistaken for one.
|
||||
if (platform === "win32") {
|
||||
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}"` };
|
||||
};
|
||||
|
||||
const sourceKey = (g: ExportedGame): string => g.source ?? "(none)";
|
||||
|
||||
export interface ComputeInput {
|
||||
exp: LibraryExport;
|
||||
config: Config;
|
||||
platform?: NodeJS.Platform;
|
||||
readonly exp: LibraryExport;
|
||||
readonly config: Config;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
/** 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 {
|
||||
entries: ProviderEntryInput[];
|
||||
report: SyncReport;
|
||||
readonly entries: ProviderEntry[];
|
||||
readonly report: SyncReport;
|
||||
}
|
||||
|
||||
/** In `dataurl` art mode only: warn above this many entries, since each inlined cover adds to the
|
||||
* reconcile payload and a large library blows past the host's body limit (see README "Box art").
|
||||
* `host` mode (the default) sends paths, not bytes, so it has no such ceiling. */
|
||||
/** In `dataurl` art mode only: warn above this many entries, since each inlined cover adds
|
||||
* to the reconcile payload and a large library blows past the host's body limit. `host`
|
||||
* mode (the default) sends references, not bytes, so it has no such ceiling. */
|
||||
const WARN_ENTRIES = 3000;
|
||||
|
||||
export const computeEntries = ({
|
||||
@@ -78,13 +66,14 @@ export const computeEntries = ({
|
||||
filter.excludeSources.map((s) => s.toLowerCase()),
|
||||
);
|
||||
|
||||
const entries: ProviderEntryInput[] = [];
|
||||
const skipped: SkippedGame[] = [];
|
||||
const entries: ProviderEntry[] = [];
|
||||
const skipped: SyncReport["skipped"][number][] = [];
|
||||
const excluded: SyncReport["excluded"][number][] = [];
|
||||
const warnings: string[] = [];
|
||||
const perSource: Record<string, number> = {};
|
||||
|
||||
for (const g of exp.games) {
|
||||
const title = (g.name ?? "").trim();
|
||||
const title = g.name.trim();
|
||||
const skip = (reason: string) =>
|
||||
skipped.push({ id: g.id, title: title || g.id, reason });
|
||||
|
||||
@@ -113,15 +102,21 @@ export const computeEntries = ({
|
||||
skip(`source "${g.source ?? "(none)"}" excluded`);
|
||||
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,
|
||||
title,
|
||||
launch: launchCommand(g.id, platform),
|
||||
};
|
||||
const art = artFor(g);
|
||||
if (art) entry.art = art;
|
||||
entries.push(entry);
|
||||
// `art` is an optionalKey — omit it, never set it to undefined.
|
||||
...(art ? { art } : {}),
|
||||
});
|
||||
const key = sourceKey(g);
|
||||
perSource[key] = (perSource[key] ?? 0) + 1;
|
||||
}
|
||||
@@ -139,9 +134,11 @@ export const computeEntries = ({
|
||||
entries,
|
||||
report: {
|
||||
generatedAt: exp.generatedAt,
|
||||
schemaVersion: exp.schema,
|
||||
considered: exp.games.length,
|
||||
included: entries.length,
|
||||
skipped,
|
||||
excluded,
|
||||
warnings,
|
||||
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 os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { buildArtwork, fileToDataUrl } from "../src/art.js";
|
||||
import { DEFAULT_ART } from "../src/config.js";
|
||||
import type { ExportedGame } from "../src/export-schema.js";
|
||||
import type { ArtOptions, ExportedGame } from "@playnite/contract";
|
||||
import { artPaths, buildArtwork, fileToDataUrl } from "../src/domain/art.js";
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-art-"));
|
||||
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 }));
|
||||
|
||||
const DEFAULT_ART: ArtOptions = {
|
||||
mode: "host",
|
||||
maxBytes: 1_500_000,
|
||||
includeBackground: false,
|
||||
};
|
||||
|
||||
const game = (over: Partial<ExportedGame>): ExportedGame => ({
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
name: "Game",
|
||||
@@ -49,6 +54,11 @@ describe("fileToDataUrl", () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
test("a remote reference passes through untouched", () => {
|
||||
expect(fileToDataUrl("https://cdn.example/cover.jpg", 10)).toBe(
|
||||
"https://cdn.example/cover.jpg",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildArtwork", () => {
|
||||
@@ -106,3 +116,41 @@ describe("buildArtwork", () => {
|
||||
).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 os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { resolveConfig } from "../src/config.js";
|
||||
import { EXPORT_FILE } from "../src/export-schema.js";
|
||||
import { ExportError, locateExport, readExport } from "../src/playnite.js";
|
||||
import { EXPORT_FILE, resolveConfig } from "@playnite/contract";
|
||||
import { ExportError, locateExport, readExport } from "../src/domain/locate.js";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-loc-"));
|
||||
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 ext = path.join(dir, "ExtensionsData", "abc-guid");
|
||||
fs.mkdirSync(ext, { recursive: true });
|
||||
if (doc !== undefined)
|
||||
if (doc !== undefined) {
|
||||
fs.writeFileSync(path.join(ext, EXPORT_FILE), JSON.stringify(doc));
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("locateExport", () => {
|
||||
path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE),
|
||||
);
|
||||
expect(loc.mtime).toBeGreaterThan(0);
|
||||
expect(loc.viaIngest).toBe(false);
|
||||
});
|
||||
|
||||
test("reports the dir but no file when the exporter hasn't written yet", () => {
|
||||
@@ -58,6 +59,9 @@ describe("locateExport", () => {
|
||||
const loc = locateExport(resolveConfig({ playniteDir: pd }));
|
||||
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;
|
||||
@@ -73,6 +77,25 @@ describe("readExport", () => {
|
||||
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", () => {
|
||||
const dir = layout("future", { ...validDoc, schema: 99 });
|
||||
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
|
||||
@@ -88,4 +111,8 @@ describe("readExport", () => {
|
||||
fs.writeFileSync(foreign, JSON.stringify({ hello: "world" }));
|
||||
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 { resolveConfig } from "../src/config.js";
|
||||
import type { ExportedGame, LibraryExport } from "@playnite/contract";
|
||||
import { resolveConfig } from "@playnite/contract";
|
||||
import {
|
||||
computeEntries,
|
||||
isGuid,
|
||||
launchCommand,
|
||||
} from "../src/engine/reconcile.js";
|
||||
import type { ExportedGame, LibraryExport } from "../src/export-schema.js";
|
||||
} from "../src/domain/reconcile.js";
|
||||
|
||||
const G1 = "11111111-1111-1111-1111-111111111111";
|
||||
const G2 = "22222222-2222-2222-2222-222222222222";
|
||||
@@ -56,10 +56,9 @@ describe("launchCommand", () => {
|
||||
|
||||
describe("computeEntries filtering", () => {
|
||||
test("installedOnly drops uninstalled games", () => {
|
||||
const config = resolveConfig({});
|
||||
const { entries, report } = computeEntries({
|
||||
exp: exp([game({ id: G1 }), game({ id: G2, installed: false })]),
|
||||
config,
|
||||
config: resolveConfig({}),
|
||||
platform: "win32",
|
||||
artFor: noArt,
|
||||
});
|
||||
@@ -146,4 +145,82 @@ describe("computeEntries filtering", () => {
|
||||
});
|
||||
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,328 +0,0 @@
|
||||
// The sync engine: the headless half of the plugin. It ties the pure core (locate → read → compute)
|
||||
// to the real world — reading the exporter's `punktfunk-library.json`, embedding cover art, and the
|
||||
// host reconcile PUT — and drives it on start, on an interval poll, on export-file changes (debounced),
|
||||
// and on demand from the UI/CLI. Fully functional from a hand-written `config.json` alone.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { Punktfunk } from "@punktfunk/host";
|
||||
import { buildArtwork } from "../art.js";
|
||||
import type { Config } from "../config.js";
|
||||
import type { ExportedGame } from "../export-schema.js";
|
||||
import { deleteProvider, reconcileProvider } from "../host.js";
|
||||
import { type Logger, makeLogger } from "../log.js";
|
||||
import { ingestDir } from "../paths.js";
|
||||
import {
|
||||
ExportError,
|
||||
type Location,
|
||||
locateExport,
|
||||
readExport,
|
||||
} from "../playnite.js";
|
||||
import {
|
||||
type Cache,
|
||||
loadCache,
|
||||
loadConfig,
|
||||
saveCache,
|
||||
statePaths,
|
||||
} from "../state.js";
|
||||
import type { ProviderEntryInput } from "../wire.js";
|
||||
import { computeEntries, type SyncReport } from "./reconcile.js";
|
||||
|
||||
export interface EngineDeps {
|
||||
pf: Punktfunk;
|
||||
platform?: NodeJS.Platform;
|
||||
log?: Logger;
|
||||
}
|
||||
|
||||
export interface EngineStatus {
|
||||
platform: NodeJS.Platform;
|
||||
/** Where the export is (or why it wasn't found) — drives the console's "connected?" banner. */
|
||||
location: Location;
|
||||
/** Last read error (schema/corruption), if any. */
|
||||
exportError?: string;
|
||||
lastSync?: Cache["lastSync"];
|
||||
lastReport?: SyncReport;
|
||||
paths: ReturnType<typeof statePaths>;
|
||||
syncing: boolean;
|
||||
}
|
||||
|
||||
/** A stable fingerprint of (export bytes + the config knobs that affect output). Lets an unchanged
|
||||
* re-read skip the expensive art-embed + PUT entirely. */
|
||||
const fingerprint = (bytes: Buffer, config: Config): string =>
|
||||
createHash("sha256")
|
||||
.update(bytes)
|
||||
.update(
|
||||
JSON.stringify({
|
||||
filter: config.filter,
|
||||
art: config.art,
|
||||
devEntry: config.devEntry,
|
||||
platform: process.platform,
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
|
||||
/** A synthetic entry proving the reconcile round-trip end-to-end (dev flag / acceptance). */
|
||||
const devEntry = (platform: NodeJS.Platform): ProviderEntryInput => ({
|
||||
external_id: "00000000-0000-0000-0000-000000000000",
|
||||
title: "Playnite (dev entry)",
|
||||
launch: {
|
||||
kind: "command",
|
||||
value: platform === "win32" ? "cmd /c echo hi" : "echo hi",
|
||||
},
|
||||
});
|
||||
|
||||
export class Engine {
|
||||
private readonly pf: Punktfunk;
|
||||
private readonly platform: NodeJS.Platform;
|
||||
private readonly log: Logger;
|
||||
private cache: Cache;
|
||||
|
||||
private syncing = false;
|
||||
private pending = false;
|
||||
private lastReport?: SyncReport;
|
||||
private lastLocation: Location = {
|
||||
dir: null,
|
||||
candidates: [],
|
||||
file: null,
|
||||
mtime: null,
|
||||
};
|
||||
private lastError?: string;
|
||||
private pollTimer?: ReturnType<typeof setInterval>;
|
||||
private debounceTimer?: ReturnType<typeof setTimeout>;
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private readonly listeners = new Set<(status: EngineStatus) => void>();
|
||||
|
||||
constructor(deps: EngineDeps) {
|
||||
this.pf = deps.pf;
|
||||
this.platform = deps.platform ?? process.platform;
|
||||
this.log = deps.log ?? makeLogger();
|
||||
this.cache = loadCache();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lifecycle
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.sync("startup");
|
||||
this.schedulePoll();
|
||||
this.setupWatchers();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||
for (const w of this.watchers) {
|
||||
try {
|
||||
w.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
}
|
||||
this.watchers = [];
|
||||
}
|
||||
|
||||
private schedulePoll(): void {
|
||||
const config = loadConfig();
|
||||
const ms = Math.max(1, config.sync.pollMinutes) * 60_000;
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
this.pollTimer = setInterval(() => void this.sync("poll"), ms);
|
||||
(this.pollTimer as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
/** Watch the exporter's `ExtensionsData` tree so a library change reconciles within seconds. */
|
||||
private setupWatchers(): void {
|
||||
for (const w of this.watchers) {
|
||||
try {
|
||||
w.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.watchers = [];
|
||||
const config = loadConfig();
|
||||
if (!config.sync.watch) return;
|
||||
const loc = locateExport(config);
|
||||
|
||||
const targets = new Set<string>();
|
||||
// Always watch the ingest inbox when present — the de-privileged runner's source, so an
|
||||
// exporter drop reconciles within seconds even when `loc.dir` resolved elsewhere (or nowhere).
|
||||
const inbox = ingestDir();
|
||||
if (fs.existsSync(inbox)) targets.add(inbox);
|
||||
// Plus the located Playnite dir: prefer its ExtensionsData (where the file lives), else the
|
||||
// dir itself so we notice the export appearing (same-user/SYSTEM runner, or Linux).
|
||||
if (loc.dir) {
|
||||
const extData = path.join(loc.dir, "ExtensionsData");
|
||||
targets.add(fs.existsSync(extData) ? extData : loc.dir);
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
try {
|
||||
this.watchers.push(
|
||||
fs.watch(target, { recursive: true }, () => this.debouncedSync()),
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
this.watchers.push(fs.watch(target, () => this.debouncedSync()));
|
||||
} catch {
|
||||
this.log(`watch: cannot watch ${target} (poll only)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private debouncedSync(): void {
|
||||
const config = loadConfig();
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(
|
||||
() => void this.sync("fs-change"),
|
||||
config.sync.debounceMs,
|
||||
);
|
||||
(this.debounceTimer as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- compute
|
||||
|
||||
/** Locate + read + compute WITHOUT embedding art or touching the host (the UI preview). */
|
||||
computeDry(): { location: Location; report?: SyncReport; error?: string } {
|
||||
const config = loadConfig();
|
||||
const location = locateExport(config);
|
||||
if (!location.file) {
|
||||
return { location, error: "no exporter output found yet" };
|
||||
}
|
||||
try {
|
||||
const exp = readExport(location.file);
|
||||
const { report } = computeEntries({
|
||||
exp,
|
||||
config,
|
||||
platform: this.platform,
|
||||
artFor: () => undefined, // preview: skip the (heavy) art embed
|
||||
});
|
||||
return { location, report };
|
||||
} catch (e) {
|
||||
return { location, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sync
|
||||
|
||||
/** The guarded reconcile: coalesces concurrent triggers, skips the read/embed/PUT when nothing changed. */
|
||||
async sync(reason: string): Promise<SyncReport | undefined> {
|
||||
if (this.syncing) {
|
||||
this.pending = true;
|
||||
return undefined;
|
||||
}
|
||||
this.syncing = true;
|
||||
this.notify();
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const location = locateExport(config);
|
||||
this.lastLocation = location;
|
||||
if (!location.file) {
|
||||
this.lastError = `no exporter output under ${location.dir ?? "any Playnite data dir"} — is the Punktfunk Sync extension installed in Playnite?`;
|
||||
this.log(`sync (${reason}): ${this.lastError}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bytes = fs.readFileSync(location.file);
|
||||
const fp = fingerprint(bytes, config);
|
||||
if (this.cache.lastSync?.fingerprint === fp) {
|
||||
this.lastError = undefined;
|
||||
this.log(
|
||||
`sync (${reason}): no changes (${this.cache.lastSync.count} title(s))`,
|
||||
);
|
||||
return this.lastReport;
|
||||
}
|
||||
|
||||
const exp = readExport(location.file);
|
||||
const { entries, report } = computeEntries({
|
||||
exp,
|
||||
config,
|
||||
platform: this.platform,
|
||||
artFor: (g: ExportedGame) => buildArtwork(g, config.art),
|
||||
});
|
||||
const finalEntries = config.devEntry
|
||||
? [...entries, devEntry(this.platform)]
|
||||
: entries;
|
||||
|
||||
await reconcileProvider(this.pf, finalEntries);
|
||||
this.cache.lastSync = {
|
||||
fingerprint: fp,
|
||||
count: finalEntries.length,
|
||||
at: Date.now(),
|
||||
};
|
||||
saveCache(this.cache);
|
||||
this.lastReport = report;
|
||||
this.lastError = undefined;
|
||||
this.log(
|
||||
`sync (${reason}): reconciled ${finalEntries.length} title(s) (${report.skipped.length} skipped)`,
|
||||
);
|
||||
this.logReport(report);
|
||||
return report;
|
||||
} catch (e) {
|
||||
this.lastError = e instanceof ExportError ? e.message : String(e);
|
||||
this.log(`sync (${reason}) failed: ${this.lastError}`);
|
||||
return undefined;
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
this.notify();
|
||||
if (this.pending) {
|
||||
this.pending = false;
|
||||
queueMicrotask(() => void this.sync("coalesced"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private logReport(report: SyncReport): void {
|
||||
const bySource = Object.entries(report.perSource)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([s, n]) => `${s}:${n}`)
|
||||
.join(" ");
|
||||
if (bySource) this.log(` sources — ${bySource}`);
|
||||
for (const w of report.warnings.slice(0, 5)) this.log(` ${w}`);
|
||||
}
|
||||
|
||||
/** Reconfigure timers/watchers after a config change (e.g. the UI saved new filters). */
|
||||
async reconfigure(): Promise<void> {
|
||||
this.schedulePoll();
|
||||
this.setupWatchers();
|
||||
await this.sync("config-change");
|
||||
}
|
||||
|
||||
/** Remove every entry this provider owns (CLI `uninstall`). */
|
||||
async uninstall(): Promise<void> {
|
||||
await deleteProvider(this.pf);
|
||||
this.cache.lastSync = undefined;
|
||||
saveCache(this.cache);
|
||||
this.log("uninstalled: provider entries removed");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
subscribe(cb: (status: EngineStatus) => void): () => void {
|
||||
this.listeners.add(cb);
|
||||
return () => this.listeners.delete(cb);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
const status = this.status();
|
||||
for (const cb of this.listeners) {
|
||||
try {
|
||||
cb(status);
|
||||
} catch {
|
||||
// a dead SSE listener must not break sync
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status(): EngineStatus {
|
||||
return {
|
||||
platform: this.platform,
|
||||
location: this.lastLocation,
|
||||
exportError: this.lastError,
|
||||
lastSync: this.cache.lastSync,
|
||||
lastReport: this.lastReport,
|
||||
paths: statePaths(),
|
||||
syncing: this.syncing,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,75 +0,0 @@
|
||||
// Where the plugin's own files live. The host config-dir resolution mirrors the SDK's `configDir`
|
||||
// (`@punktfunk/host` does not re-export it) so we always land in the same place the host and runner
|
||||
// use; the plugin owns a `plugin-state/playnite/` subtree under it, created 0700.
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** The host's config dir — the same resolution the host and SDK use. */
|
||||
export const hostConfigDir = (): string => {
|
||||
const explicit = process.env.PUNKTFUNK_CONFIG_DIR;
|
||||
if (explicit) return explicit;
|
||||
if (process.platform === "win32") {
|
||||
const base = process.env.ProgramData ?? process.env.APPDATA ?? ".";
|
||||
return path.join(base, "punktfunk");
|
||||
}
|
||||
const base =
|
||||
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
||||
return path.join(base, "punktfunk");
|
||||
};
|
||||
|
||||
/**
|
||||
* This plugin's private directory: `<config_dir>/plugin-state/playnite`.
|
||||
*
|
||||
* The state lives under `plugin-state/`, not straight under the config dir, because the managed
|
||||
* runner is de-privileged on Windows (`NT AUTHORITY\LocalService`) and the config dir is locked
|
||||
* read-only there — `punktfunk-host plugins enable` grants the runner write on exactly
|
||||
* `plugin-state`. (Mirrors `@punktfunk/host`'s `pluginStateDir`; reimplemented locally like
|
||||
* `hostConfigDir`, since we don't gate on an SDK version bump.) On Linux the runner owns the config
|
||||
* dir, so the same path is writable with no special step.
|
||||
*/
|
||||
export const pluginDir = (): string =>
|
||||
path.join(hostConfigDir(), "plugin-state", "playnite");
|
||||
|
||||
/** `<config_dir>/plugin-state/playnite/config.json` — operator-editable, atomic-written. */
|
||||
export const configPath = (): string => path.join(pluginDir(), "config.json");
|
||||
|
||||
/** `<config_dir>/plugin-state/playnite/cache.json` — disposable derived state (last fingerprint). */
|
||||
export const cachePath = (): string => path.join(pluginDir(), "cache.json");
|
||||
|
||||
/**
|
||||
* The cross-account **ingest inbox** the Playnite exporter drops the library export into:
|
||||
* `<config_dir>/ingest/playnite`. (Mirrors `@punktfunk/host`'s `pluginIngestDir`.)
|
||||
*
|
||||
* The exporter runs inside Playnite as the interactive user; the de-privileged LocalService runner
|
||||
* cannot read that user's `%APPDATA%\Playnite\ExtensionsData\…`. `punktfunk-host plugins enable`
|
||||
* makes `ingest` user-writable, so the exporter drops the file here for the runner to read. Read
|
||||
* this BEFORE the ExtensionsData scan (which only works for a same-user/SYSTEM runner or on Linux).
|
||||
*/
|
||||
export const ingestDir = (): string =>
|
||||
path.join(hostConfigDir(), "ingest", "playnite");
|
||||
|
||||
/**
|
||||
* Locate the built SPA directory (`dist/ui`) at runtime. The package ships as ONE self-contained
|
||||
* bundle so it installs from the Gitea registry with no external deps — which means `import.meta.url`
|
||||
* can be `dist/index.js` (bundled) or `dist/server/index.js` (unbundled dev). We walk up from the
|
||||
* calling module looking for a `ui/index.html`, so both layouts resolve. `fromUrl` is the caller's
|
||||
* `import.meta.url`.
|
||||
*/
|
||||
export const resolveUiDir = (fromUrl: string): string => {
|
||||
let dir = path.dirname(fileURLToPath(fromUrl));
|
||||
for (let i = 0; i < 6; i++) {
|
||||
for (const cand of [path.join(dir, "ui"), path.join(dir, "dist", "ui")]) {
|
||||
try {
|
||||
if (fs.existsSync(path.join(cand, "index.html"))) return cand;
|
||||
} catch {
|
||||
// keep walking
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return path.join(path.dirname(fileURLToPath(fromUrl)), "ui");
|
||||
};
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
// Locating and reading the exporter's output. The companion Playnite extension (`exporter/`) writes
|
||||
// `punktfunk-library.json` into its own Playnite plugin-data directory, i.e.
|
||||
// `<PlayniteData>/ExtensionsData/<pluginId-guid>/punktfunk-library.json`. We don't hardcode that guid
|
||||
// — we glob `ExtensionsData/*/punktfunk-library.json` and take the newest, so the reader is decoupled
|
||||
// from the exporter's id.
|
||||
//
|
||||
// The plugin runs on the same box as Playnite (it's the streaming host), so these are plain local
|
||||
// reads. The wrinkle is *which account* can see the file. The managed runner is de-privileged
|
||||
// (`NT AUTHORITY\LocalService`) and cannot read the interactive user's `%APPDATA%`, so the reliable
|
||||
// source is the **ingest inbox** (`<config_dir>/ingest/playnite/punktfunk-library.json`) the
|
||||
// exporter drops the file into — checked FIRST. The `%APPDATA%`/`C:\Users\*` ExtensionsData scan
|
||||
// still works for a same-user/SYSTEM runner or on Linux, and an explicit `playniteDir` override
|
||||
// joins the candidate list.
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { Config } from "./config.js";
|
||||
import {
|
||||
EXPORT_FILE,
|
||||
isLibraryExport,
|
||||
type LibraryExport,
|
||||
SCHEMA,
|
||||
} from "./export-schema.js";
|
||||
import { ingestDir } from "./paths.js";
|
||||
|
||||
const exists = (p: string): boolean => {
|
||||
try {
|
||||
fs.accessSync(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Candidate Playnite data directories, most-specific first (config override handled by the caller). */
|
||||
export const candidatePlayniteDirs = (): string[] => {
|
||||
const out: string[] = [];
|
||||
if (process.platform === "win32") {
|
||||
const appdata = process.env.APPDATA;
|
||||
if (appdata) out.push(path.join(appdata, "Playnite"));
|
||||
// SYSTEM/other-user runner: scan every local profile's roaming AppData.
|
||||
const usersRoot = path.join(process.env.SystemDrive ?? "C:", "\\", "Users");
|
||||
try {
|
||||
for (const user of fs.readdirSync(usersRoot)) {
|
||||
out.push(path.join(usersRoot, user, "AppData", "Roaming", "Playnite"));
|
||||
}
|
||||
} catch {
|
||||
// no C:\Users (or not Windows-like) — fine
|
||||
}
|
||||
} else {
|
||||
// Playnite is Windows-only, but a Wine/portable layout may set these — best effort.
|
||||
const home = process.env.HOME;
|
||||
if (home) {
|
||||
out.push(path.join(home, ".local", "share", "Playnite"));
|
||||
}
|
||||
}
|
||||
// De-dupe, preserve order.
|
||||
return [...new Set(out)];
|
||||
};
|
||||
|
||||
export interface Location {
|
||||
/** The resolved Playnite data dir, or null if none found. */
|
||||
dir: string | null;
|
||||
/** The candidate dirs we considered (for the UI's diagnostics view). */
|
||||
candidates: string[];
|
||||
/** The resolved export file, or null if the exporter hasn't written one yet. */
|
||||
file: string | null;
|
||||
/** mtime (epoch ms) of the export file, or null. */
|
||||
mtime: number | null;
|
||||
}
|
||||
|
||||
/** Find the newest `punktfunk-library.json` under a Playnite dir's `ExtensionsData`. */
|
||||
const findExportUnder = (
|
||||
dir: string,
|
||||
): { file: string; mtime: number } | null => {
|
||||
const base = path.join(dir, "ExtensionsData");
|
||||
let best: { file: string; mtime: number } | null = null;
|
||||
let subdirs: string[];
|
||||
try {
|
||||
subdirs = fs.readdirSync(base);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const sub of subdirs) {
|
||||
const file = path.join(base, sub, EXPORT_FILE);
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (!best || st.mtimeMs > best.mtime) best = { file, mtime: st.mtimeMs };
|
||||
} catch {
|
||||
// no export in this extension's dir
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
/** Resolve where the export lives. `config.playniteDir` wins; otherwise probe the candidates. */
|
||||
export const locateExport = (config: Config): Location => {
|
||||
const override = config.playniteDir;
|
||||
const candidates = override
|
||||
? [override, ...candidatePlayniteDirs()]
|
||||
: candidatePlayniteDirs();
|
||||
|
||||
// The ingest inbox first: the exporter drops the library here (a flat file, not an
|
||||
// `ExtensionsData/<guid>` tree) so the de-privileged LocalService runner can read it — it can't
|
||||
// reach the interactive user's `%APPDATA%`. This is the reliable path on a managed Windows host.
|
||||
const inbox = ingestDir();
|
||||
const inboxFile = path.join(inbox, EXPORT_FILE);
|
||||
try {
|
||||
const st = fs.statSync(inboxFile);
|
||||
return {
|
||||
dir: inbox,
|
||||
candidates: [inbox, ...candidates],
|
||||
file: inboxFile,
|
||||
mtime: st.mtimeMs,
|
||||
};
|
||||
} catch {
|
||||
// no ingest drop yet — fall back to scanning Playnite's own data dirs
|
||||
}
|
||||
|
||||
// Then, a Playnite data dir that actually holds an export file (same-user/SYSTEM runner, Linux).
|
||||
for (const dir of candidates) {
|
||||
const hit = findExportUnder(dir);
|
||||
if (hit)
|
||||
return {
|
||||
dir,
|
||||
candidates: [inbox, ...candidates],
|
||||
file: hit.file,
|
||||
mtime: hit.mtime,
|
||||
};
|
||||
}
|
||||
// Else, the first dir that at least exists (so the UI can say "Playnite found, exporter not
|
||||
// installed yet" vs "Playnite not found").
|
||||
const dir = candidates.find(exists) ?? null;
|
||||
return { dir, candidates: [inbox, ...candidates], file: null, mtime: null };
|
||||
};
|
||||
|
||||
export class ExportError extends Error {}
|
||||
|
||||
/** Read + validate the export file. Throws `ExportError` on a missing/corrupt/too-new file. */
|
||||
export const readExport = (file: string): LibraryExport => {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(file, "utf8");
|
||||
} catch (e) {
|
||||
throw new ExportError(`cannot read ${file}: ${e}`);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new ExportError(`${file} is not valid JSON: ${e}`);
|
||||
}
|
||||
if (!isLibraryExport(parsed)) {
|
||||
throw new ExportError(`${file} is not a punktfunk library export`);
|
||||
}
|
||||
if (Math.floor(parsed.schema) > SCHEMA) {
|
||||
throw new ExportError(
|
||||
`${file} is schema ${parsed.schema}; this plugin understands up to ${SCHEMA} — update the plugin`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
@@ -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>
|
||||
<!-- The console pins dark; the standalone dev tab matches it. Removing `dark` yields light. -->
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Playnite · Punktfunk</title>
|
||||
<title>Playnite — Punktfunk</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+26
-8
@@ -2,24 +2,42 @@
|
||||
"name": "punktfunk-plugin-playnite-ui",
|
||||
"private": true,
|
||||
"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": {
|
||||
"dev": "vite",
|
||||
"dev:live": "VITE_API_MODE=live vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"storybook": "storybook dev -p 6014",
|
||||
"build-storybook": "storybook build",
|
||||
"screenshots": "node tools/screenshots.mjs"
|
||||
},
|
||||
"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",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"motion": "^12.42.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playnite/contract": "workspace:*",
|
||||
"@storybook/react-vite": "^10.4.6",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@types/node": "^22.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"playwright": "^1.61.1",
|
||||
"storybook": "^10.4.6",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"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 {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FolderSearch,
|
||||
Gamepad2,
|
||||
Library,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
type Config,
|
||||
getConfig,
|
||||
putConfig,
|
||||
runSync,
|
||||
type Status,
|
||||
useStatusStream,
|
||||
} from "./api.js";
|
||||
PluginShell,
|
||||
type PluginShellNavItem,
|
||||
} from "@unom/app-ui/plugin-shell";
|
||||
import { LayoutDashboard, LibraryBig, Settings2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { LibraryPage } from "@/pages/library";
|
||||
import { OverviewPage } from "@/pages/overview";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
import { type PageProps, ROUTES, type Route } from "@/routes";
|
||||
|
||||
const relTime = (ms?: number): string => {
|
||||
if (!ms) return "never";
|
||||
const s = Math.round((Date.now() - ms) / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
||||
return `${Math.round(s / 86400)}d ago`;
|
||||
const { usePluginRoute } = createPluginRouter(ROUTES, "overview");
|
||||
|
||||
const NAV: ReadonlyArray<PluginShellNavItem> = [
|
||||
{ id: "overview", label: "Overview", icon: LayoutDashboard },
|
||||
{ id: "library", label: "Library", icon: LibraryBig },
|
||||
{ id: "settings", label: "Settings", icon: Settings2 },
|
||||
];
|
||||
|
||||
const PAGES: Record<Route, FC<PageProps>> = {
|
||||
overview: OverviewPage,
|
||||
library: LibraryPage,
|
||||
settings: SettingsPage,
|
||||
};
|
||||
|
||||
function Card({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
right,
|
||||
}: {
|
||||
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>
|
||||
/** Only shown in a standalone tab — embedded in the console, the console is the chrome. */
|
||||
const StandaloneHeader = () => (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<LibraryBig className="size-4 text-primary" />
|
||||
Playnite
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
export const App = () => {
|
||||
const { route, navigate } = usePluginRoute();
|
||||
const embedded = useIsEmbedded();
|
||||
const Page = PAGES[route];
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start justify-between gap-4 py-2">
|
||||
<span>
|
||||
<span className="block text-sm">{label}</span>
|
||||
{hint && <span className="block text-xs text-muted">{hint}</span>}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? "bg-brand" : "bg-elevated"}`}
|
||||
<PluginShell
|
||||
nav={NAV}
|
||||
active={route}
|
||||
onNavigate={(id) => navigate(id as Route)}
|
||||
standaloneHeader={embedded ? undefined : <StandaloneHeader />}
|
||||
>
|
||||
<span
|
||||
className={`block h-5 w-5 rounded-full bg-white transition-transform ${checked ? "translate-x-5" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
<Page navigate={navigate} />
|
||||
</PluginShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.js";
|
||||
import "./styles.css";
|
||||
import { App } from "./App";
|
||||
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(
|
||||
<StrictMode>
|
||||
<RegistryProvider>
|
||||
<App />
|
||||
</RegistryProvider>
|
||||
</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 "tw-animate-css";
|
||||
@import "@punktfunk/plugin-kit/theme.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Punktfunk violet identity — dark only (the SPA is embedded on the console's dark canvas). The token
|
||||
names feed Tailwind v4 utilities directly: `bg-card`, `text-muted`, `bg-brand`, `border-border`… */
|
||||
@theme {
|
||||
--color-bg: #0b0b0f;
|
||||
--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;
|
||||
}
|
||||
/* Scan the design system's built output so its class names survive Tailwind's purge.
|
||||
* Bare directory globs on purpose — rebased file globs proved flaky. */
|
||||
@source "../node_modules/@unom/ui/dist";
|
||||
@source "../node_modules/@unom/app-ui/dist";
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
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;
|
||||
@apply bg-background font-sans text-foreground antialiased;
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"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 react from "@vitejs/plugin-react";
|
||||
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({
|
||||
base: "./",
|
||||
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: {
|
||||
outDir: "../dist/ui",
|
||||
outDir: "../plugin/dist/ui",
|
||||
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