refactor: rewrite on @punktfunk/plugin-kit — contract + plugin workspaces

The framework half of this plugin (engine lifecycle, config/state, host
reconcile, UI server, CLI) was ~80% identical to rom-manager's. That code is
now @punktfunk/plugin-kit; this replaces the local copy with it and adopts the
rom-manager blueprint layout.

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

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

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

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

Trap found and fixed here: the HttpApi encoder serialises `undefined` as
`null`, so a `Schema.optional` field the server omits cannot be decoded by the
client — silently in the SSE feed, loudly in the typed client. Every optional
EngineStatus field is `Schema.NullOr` with an explicit value on the wire.
This commit is contained in:
2026-07-20 20:56:12 +02:00
parent e832201669
commit b81e03685a
59 changed files with 3408 additions and 2634 deletions
+3 -1
View File
@@ -1,7 +1,9 @@
node_modules
dist
plugin/dist
ui/dist
ui/node_modules
ui/storybook-static
ui/screenshots
*.log
.DS_Store
+1665 -8
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,5 +1,5 @@
# Resolve the `@punktfunk` scope from the Gitea npm registry (like `@punktfunk/host` itself), so
# `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/"
-24
View File
@@ -1,24 +0,0 @@
{
"filter": {
"installedOnly": true,
"includeHidden": false,
"sources": [],
"excludeSources": ["Xbox"]
},
"art": {
"mode": "host",
"maxBytes": 1500000,
"includeBackground": false
},
"sync": {
"pollMinutes": 5,
"watch": true,
"debounceMs": 1500
},
"ui": {
"standalone": false,
"port": 47994,
"bind": "127.0.0.1"
},
"playniteDir": ""
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@playnite/contract",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "The single source of truth shared by the plugin server and the UI: the cross-process export schema (the C# exporter's other half), the config schema, domain DTOs, the HttpApi contract, and API errors. Never published — bundled into the plugin, aliased into the UI.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"effect": "^4.0.0-beta.99",
"@punktfunk/plugin-kit": "^0.1.4"
},
"devDependencies": {
"effect": "4.0.0-beta.99",
"@punktfunk/plugin-kit": "^0.1.4",
"typescript": "^5.9.3",
"@types/bun": "^1.3.0"
}
}
+96
View File
@@ -0,0 +1,96 @@
// The one HttpApi contract — HttpApiBuilder implements it server-side, AtomHttpApi
// derives the UI's typed client + atoms from it. No hand-mirrored types anywhere.
//
// Two routes sit OUTSIDE the HttpApi because neither is JSON: the SSE status feed
// (`effect/unstable/httpapi` has no event-stream media type at beta.99) and the cover-art
// proxy (image bytes). Both are plain HttpRouter routes; their paths live here so the UI
// and the server still share one definition.
import { ProviderEntry } from "@punktfunk/plugin-kit/wire";
import { Schema } from "effect";
import {
HttpApi,
HttpApiEndpoint,
HttpApiGroup,
HttpApiSchema,
} from "effect/unstable/httpapi";
import { EngineStatus, SyncReport } from "./domain.js";
import { ConfigInvalid, ExportUnavailable, SyncInProgress } from "./errors.js";
/** GET /api/config + PUT /api/config response: the authored raw shape, verbatim.
* The UI resolves it locally by decoding through the SHARED PlayniteConfigSchema. */
export const ConfigPayload = Schema.Struct({ raw: Schema.Unknown });
export type ConfigPayload = typeof ConfigPayload.Type;
/** GET /api/preview: the dry-run desired state (no art embedding, no host write). */
export const PreviewResult = Schema.Struct({
entries: Schema.Array(ProviderEntry),
report: SyncReport,
});
export type PreviewResult = typeof PreviewResult.Type;
export const PlayniteApi = HttpApi.make("playnite")
.add(
HttpApiGroup.make("status").add(
HttpApiEndpoint.get("get", "/api/status", { success: EngineStatus }),
),
)
.add(
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("get", "/api/config", { success: ConfigPayload }),
)
.add(
HttpApiEndpoint.put("put", "/api/config", {
payload: Schema.Unknown,
success: ConfigPayload,
error: ConfigInvalid.pipe(HttpApiSchema.status(400)),
}),
),
)
.add(
HttpApiGroup.make("library").add(
HttpApiEndpoint.get("preview", "/api/preview", {
success: PreviewResult,
error: ExportUnavailable.pipe(HttpApiSchema.status(503)),
}),
),
)
.add(
HttpApiGroup.make("sync").add(
HttpApiEndpoint.post("run", "/api/sync", {
success: SyncReport,
error: Schema.Union([
SyncInProgress.pipe(HttpApiSchema.status(409)),
ExportUnavailable.pipe(HttpApiSchema.status(503)),
]),
}),
),
);
/** The SSE status feed. Frames: `event: status`, data = EngineStatus JSON. */
export const EVENTS_PATH = "/api/events";
export const EVENTS_EVENT = "status";
/**
* The cover-art proxy. Playnite art is LOCAL files on the host, which a browser cannot
* load — so the SPA asks the plugin for them by path. The server only serves a path that
* appears in the currently-ingested export (an allow-list, not an arbitrary file read).
*/
export const ART_PATH = "/api/art";
/**
* Resolve one of the exporter's art references to something an `<img src>` can load.
* Remote art (Playnite passes `http(s)` references through verbatim) and `data:` URLs are
* already loadable; a local path goes through the allow-listed proxy above.
*/
export const artUrl = (
prefix: string,
reference: string | null | undefined,
): string | undefined => {
if (reference === null || reference === undefined || reference === "") {
return undefined;
}
if (/^(https?:|data:)/i.test(reference)) return reference;
return `${prefix}${ART_PATH}?path=${encodeURIComponent(reference)}`;
};
+92
View File
@@ -0,0 +1,92 @@
// The config model. ONE schema: the Encoded side IS the authored file shape (RawConfig),
// the Type side is the fully-resolved shape the engine consumes (Config). Defaults live
// ONLY here (`withDecodingDefaultKey` + encodingStrategy "omit") — the kit's ConfigService
// persists the raw shape verbatim, so a UI save never bakes defaults into the file.
//
// Dropped vs 0.1.x: `ui.*` (the standalone password server is gone — console surface + CLI
// only) and `devEntry` (a dev flag has no place in the prod shape; `preview` shows the
// round-trip instead). Added: `gameOverrides`, the Library page's per-game include toggle.
// Stale keys in existing files are tolerated on decode and dropped by the next save.
import { Effect, Schema } from "effect";
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
schema.pipe(
Schema.withDecodingDefaultKey(Effect.succeed(value), {
encodingStrategy: "omit",
}),
);
/** When and how often we re-read the export and reconcile. */
export const SyncOptions = Schema.Struct({
/** Interval poll in minutes — the safety net under the exporter-driven file watch. */
pollMinutes: withDefault(Schema.Number, 5),
/** Watch the ingest inbox and reconcile on change (the primary trigger). */
watch: withDefault(Schema.Boolean, true),
/** Debounce window for coalescing watch/poll-triggered re-reads (ms). */
debounceMs: withDefault(Schema.Number, 1500),
});
export type SyncOptions = typeof SyncOptions.Type;
/** Which Playnite games become library entries. */
export const FilterOptions = Schema.Struct({
/** Only sync games Playnite marks installed. Off = include not-yet-installed titles. */
installedOnly: withDefault(Schema.Boolean, true),
/** Include games flagged Hidden in Playnite. */
includeHidden: withDefault(Schema.Boolean, false),
/** If non-empty, keep ONLY games whose source is in this list ("Steam", "GOG", …). */
sources: withDefault(Schema.Array(Schema.String), []),
/** Drop games whose source is in this list (applied after `sources`). */
excludeSources: withDefault(Schema.Array(Schema.String), []),
});
export type FilterOptions = typeof FilterOptions.Type;
/**
* How cover art reaches the clients. Playnite art is local files on the host:
* - `host` (default): send the local file PATH; the host serves the bytes through its art
* proxy (`/library/art/…`, like Steam art). The reconcile payload carries no image data,
* so this scales to a library of any size — the recommended mode.
* - `dataurl`: inline each cover as a size-capped `data:` URL. Self-contained but does NOT
* scale — a large library blows past the host's reconcile body limit. Small libraries only.
* - `off`: sync titles with no art.
*/
export const ArtMode = Schema.Literals(["host", "dataurl", "off"]);
export type ArtMode = typeof ArtMode.Type;
export const ArtOptions = Schema.Struct({
mode: withDefault(ArtMode, "host"),
/** Skip an image whose file is larger than this (bytes) — `dataurl` mode only. */
maxBytes: withDefault(Schema.Number, 1_500_000),
/** Also send the Playnite background as `hero` art (usually large — off by default). */
includeBackground: withDefault(Schema.Boolean, false),
});
export type ArtOptions = typeof ArtOptions.Type;
/** A per-game override, keyed by the Playnite game Guid (the reconcile `external_id`). */
export const GameOverride = Schema.Struct({
exclude: Schema.optionalKey(Schema.Boolean),
});
export type GameOverride = typeof GameOverride.Type;
export const PlayniteConfigSchema = Schema.Struct({
/** Override the auto-detected Playnite data directory (`%APPDATA%\\Playnite`). Point it
* at a portable install, or at the interactive user's profile when the runner runs as
* a different user. The ingest inbox is still read FIRST. */
playniteDir: Schema.optionalKey(Schema.String),
sync: withDefault(SyncOptions, {}),
filter: withDefault(FilterOptions, {}),
art: withDefault(ArtOptions, {}),
gameOverrides: withDefault(Schema.Record(Schema.String, GameOverride), {}),
});
/** The fully-resolved config the engine consumes. */
export type Config = typeof PlayniteConfigSchema.Type;
/** The on-disk config as authored (all optional). */
export type RawConfig = typeof PlayniteConfigSchema.Encoded;
/**
* Resolve an authored raw config to the total shape (defaults filled). Throws on an
* invalid shape — the sync path for tests and the UI; the server side uses the kit's
* ConfigService (Effect + typed errors) instead.
*/
export const resolveConfig = (raw: unknown): Config =>
Schema.decodeUnknownSync(PlayniteConfigSchema)(raw);
+88
View File
@@ -0,0 +1,88 @@
// Domain DTOs shared by the plugin server and the UI — Schemas are the source of truth,
// the derived types keep the original domain names so the pure core reads unchanged.
//
// Trap, measured on this API (rom-manager's note only covers half of it): `optionalKey`
// rejects a present-but-`undefined` value and 400s at the RESPONSE encoder — but
// `Schema.optional` is not the fix, because the HttpApi encoder serialises `undefined` as
// `null`, and `string | undefined` then refuses that null when the CLIENT decodes it. The
// failure is silent in the SSE feed (invalid frames are skipped) and loud in the typed
// client. So: every "may be absent" field here is `Schema.NullOr`, and the server always
// sends an explicit value. JSON-native, symmetric, no undefined anywhere on the wire.
import { Schema } from "effect";
/** Where the export was found — drives the console's "exporter connected?" banner. */
export const Location = Schema.Struct({
/** The resolved source dir (the ingest inbox, or a Playnite data dir), or null. */
dir: Schema.NullOr(Schema.String),
/** Every dir considered, in probe order (the UI's diagnostics view). */
candidates: Schema.Array(Schema.String),
/** The resolved export file, or null if the exporter hasn't written one yet. */
file: Schema.NullOr(Schema.String),
/** mtime (epoch ms) of the export file, or null. */
mtime: Schema.NullOr(Schema.Number),
/** True when the file came from `<config_dir>/ingest/playnite` (the reliable lane). */
viaIngest: Schema.Boolean,
});
export type Location = typeof Location.Type;
/** A game the compute rejected, with a human reason (Library page, CLI). */
export const SkippedGame = Schema.Struct({
id: Schema.String,
title: Schema.String,
reason: Schema.String,
});
export type SkippedGame = typeof SkippedGame.Type;
/** A summary of one desired-state compute (Overview + Library pages). */
export const SyncReport = Schema.Struct({
/** ISO timestamp the exporter stamped on the source export. */
generatedAt: Schema.String,
/** Schema version of the export that produced this report. */
schemaVersion: Schema.Number,
/** Games in the export before filtering. */
considered: Schema.Number,
/** Entries in the reconcile payload. */
included: Schema.Number,
skipped: Schema.Array(SkippedGame),
/** Dropped by an explicit per-game override (the Library page's toggle). */
excluded: Schema.Array(
Schema.Struct({ id: Schema.String, title: Schema.String }),
),
warnings: Schema.Array(Schema.String),
/** Included entries per Playnite source ("Steam", "GOG", "(none)", …). */
perSource: Schema.Record(Schema.String, Schema.Number),
});
export type SyncReport = typeof SyncReport.Type;
export const LastSync = Schema.Struct({
fingerprint: Schema.String,
count: Schema.Number,
at: Schema.Number,
});
export type LastSync = typeof LastSync.Type;
/** The engine status the Overview page renders and the SSE feed streams. */
export const EngineStatus = Schema.Struct({
platform: Schema.String,
location: Location,
/** Last read/decode error (missing export, corruption, too-new schema), else null. */
exportError: Schema.NullOr(Schema.String),
/** Playnite's own version + mode, as stamped on the last readable export. */
playnite: Schema.NullOr(
Schema.Struct({
version: Schema.NullOr(Schema.String),
mode: Schema.NullOr(Schema.String),
}),
),
artMode: Schema.String,
syncing: Schema.Boolean,
lastSync: Schema.NullOr(LastSync),
lastReport: Schema.NullOr(SyncReport),
paths: Schema.Struct({
dir: Schema.String,
config: Schema.String,
cache: Schema.String,
ingest: Schema.String,
}),
});
export type EngineStatus = typeof EngineStatus.Type;
+25
View File
@@ -0,0 +1,25 @@
// API errors — Schema-backed so they cross the wire typed; status set per-endpoint via
// HttpApiSchema.status in api.ts.
import { Schema } from "effect";
/** PUT /api/config body failed schema validation. → 400 */
export class ConfigInvalid extends Schema.TaggedErrorClass<ConfigInvalid>()(
"ConfigInvalid",
{ issues: Schema.String },
) {}
/** A sync pass is already running (the trigger was coalesced). → 409 */
export class SyncInProgress extends Schema.TaggedErrorClass<SyncInProgress>()(
"SyncInProgress",
{},
) {}
/**
* No exporter output was found, or the file is unreadable / not a valid export / stamped
* with a schema this plugin does not understand. → 503, because it is a "the other half
* isn't installed yet" state rather than a server fault: the UI turns it into onboarding.
*/
export class ExportUnavailable extends Schema.TaggedErrorClass<ExportUnavailable>()(
"ExportUnavailable",
{ message: Schema.String },
) {}
+72
View File
@@ -0,0 +1,72 @@
// The CROSS-PROCESS contract: the other half of this file is C#.
//
// The Playnite exporter (`exporter/`, a GenericPlugin) writes `punktfunk-library.json` in
// exactly this shape on every library change; this plugin decodes it. Keep it in lockstep
// with `exporter/Model.cs` — the `[SerializationPropertyName]` attributes there pin the
// JSON keys these Schemas read. Bump `SCHEMA` on any breaking change; the exporter stamps
// the version it wrote and the `schema` check below turns a too-new file into a decode
// error rather than a silent mis-read.
//
// Leniency is deliberate and asymmetric: every field except `id`, `schema` and `games` has
// a decoding default, so an exporter that predates (or postdates) a field addition still
// decodes. Per-GAME validity — a malformed Guid, a blank title — is NOT a decode failure:
// those become skip reasons in the pure core, so one bad row can never cost you the whole
// library.
import { Effect, Schema } from "effect";
/** The file the exporter writes (ingest inbox, and its own `ExtensionsData/<guid>/`). */
export const EXPORT_FILE = "punktfunk-library.json";
/** Schema version this reader understands. A newer major is refused (see `LibraryExport`). */
export const SCHEMA = 1;
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
schema.pipe(
Schema.withDecodingDefaultKey(Effect.succeed(value), {
encodingStrategy: "omit",
}),
);
/** One game as the Playnite exporter sees it. Art fields are ABSOLUTE local paths on the
* host box (resolved via `IPlayniteAPI.Database.GetFullFilePath`) — except a remote
* `http(s)` art URL, which the exporter passes through verbatim. */
export const ExportedGame = Schema.Struct({
/** Playnite's stable game `Guid` ("d" format) — our reconcile `external_id`. */
id: Schema.String,
name: withDefault(Schema.String, ""),
/** Whether Playnite considers the game installed (drives the `installedOnly` filter). */
installed: withDefault(Schema.Boolean, false),
/** Playnite's per-game "Hidden" flag. */
hidden: withDefault(Schema.Boolean, false),
/** The library source name ("Steam", "GOG", "Epic", emulator name, …) or null. */
source: withDefault(Schema.NullOr(Schema.String), null),
/** Platform display names ("PC (Windows)", "Nintendo Switch", …). */
platforms: withDefault(Schema.Array(Schema.String), []),
cover: withDefault(Schema.NullOr(Schema.String), null),
background: withDefault(Schema.NullOr(Schema.String), null),
icon: withDefault(Schema.NullOr(Schema.String), null),
});
export type ExportedGame = typeof ExportedGame.Type;
export const PlayniteInfo = Schema.Struct({
version: withDefault(Schema.NullOr(Schema.String), null),
/** "Desktop" | "Fullscreen" — informational. */
mode: withDefault(Schema.NullOr(Schema.String), null),
});
export type PlayniteInfo = typeof PlayniteInfo.Type;
/** The whole export document. Decoding it IS the schema-version guard. */
export const LibraryExport = Schema.Struct({
schema: Schema.Number.pipe(
Schema.check(
Schema.isLessThanOrEqualTo(SCHEMA, {
description: `a punktfunk-library.json schema this plugin understands (≤ ${SCHEMA}) — a higher one means the Playnite exporter is newer than the plugin; update the plugin`,
}),
),
),
/** ISO-8601 timestamp the exporter stamped on this document. */
generatedAt: withDefault(Schema.String, ""),
playnite: withDefault(PlayniteInfo, {}),
games: Schema.Array(ExportedGame),
});
export type LibraryExport = typeof LibraryExport.Type;
+8
View File
@@ -0,0 +1,8 @@
// @playnite/contract — the shared source of truth (the cross-process export schema, the
// config schema, domain DTOs, the HttpApi contract, API errors). Bundled into the plugin,
// aliased into the UI.
export * from "./api.js";
export * from "./config.js";
export * from "./domain.js";
export * from "./errors.js";
export * from "./export.js";
+3 -4
View File
@@ -3,14 +3,13 @@
"target": "ES2022",
"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"]
}
+10 -46
View File
@@ -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"
}
}
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@punktfunk/plugin-playnite",
"version": "0.2.0",
"private": false,
"type": "module",
"description": "Punktfunk plugin: syncs your Playnite library into the host game library as a provider — every store and emulator Playnite manages, launched back through Playnite, with a console-hosted web UI. Pairs with the bundled Playnite exporter extension. Built on @punktfunk/plugin-kit.",
"license": "MIT OR Apache-2.0",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite",
"repository": {
"type": "git",
"url": "https://git.unom.io/unom/punktfunk-plugin-playnite.git"
},
"bugs": {
"url": "https://git.unom.io/unom/punktfunk-plugin-playnite/issues"
},
"keywords": [
"punktfunk",
"plugin",
"playnite",
"game-library",
"game-streaming"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./types/index.d.ts",
"bin": {
"punktfunk-plugin-playnite": "./dist/cli.js"
},
"files": [
"dist",
"types"
],
"publishConfig": {
"registry": "https://git.unom.io/api/packages/unom/npm/"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test",
"build": "bun build src/index.ts src/cli.ts --target=bun --outdir dist --external effect --external '@punktfunk/*'",
"build:ui": "cd ../ui && bun run build",
"build:all": "bun run build && bun run build:ui",
"dev": "bun src/dev.ts",
"prepublishOnly": "bun run build:all"
},
"dependencies": {
"@punktfunk/host": "^0.1.2",
"@punktfunk/plugin-kit": "^0.1.4",
"effect": "^4.0.0-beta.99"
},
"devDependencies": {
"@playnite/contract": "workspace:*",
"@types/bun": "^1.3.0",
"typescript": "^5.9.3"
}
}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bun
// Ops CLI on the kit's dispatcher — the same layer graph as the plugin entry, so every
// command sees the exact services the runner runs. where/preview work offline;
// sync/uninstall talk to the host.
import {
type CliCommand,
ProviderClient,
runPluginCli,
} from "@punktfunk/plugin-kit";
import { Effect } from "effect";
import pkg from "../package.json" with { type: "json" };
import { PLUGIN_NAME, PROVIDER_ID } from "./const.js";
import { type PlayniteServices, playniteLayer } from "./layer.js";
import { PlayniteSync } from "./services/engine.js";
const print = (line: string) => Effect.sync(() => console.log(line));
const commands: Record<string, CliCommand<PlayniteServices>> = {
where: {
summary: "Show where the Playnite exporter's output was found",
offline: true,
run: () =>
Effect.gen(function* () {
const sync = yield* PlayniteSync;
const loc = yield* sync.locate;
yield* print(`Source dir: ${loc.dir ?? "(not found)"}`);
yield* print(`Export file: ${loc.file ?? "(not found)"}`);
yield* print(`Via ingest: ${loc.viaIngest ? "yes" : "no"}`);
if (loc.mtime !== null) {
yield* print(`Last written: ${new Date(loc.mtime).toISOString()}`);
}
yield* print("Candidates probed:");
for (const c of loc.candidates) yield* print(` ${c}`);
if (loc.file === null) {
yield* print(
"\nNo export found. Install the Punktfunk Sync extension in Playnite (see exporter/README.md),",
);
yield* print("or set `playniteDir` in the plugin's config.json.");
}
}),
},
preview: {
summary: "Dry-run: show the entries a sync would reconcile",
offline: true,
run: () =>
Effect.gen(function* () {
const sync = yield* PlayniteSync;
const { entries, report } = yield* sync.preview;
yield* print(
`${report.included}/${report.considered} game(s) would sync (${report.skipped.length} skipped, ${report.excluded.length} excluded by you).`,
);
for (const [source, n] of Object.entries(report.perSource).sort(
(a, b) => b[1] - a[1],
)) {
yield* print(` ${source}: ${n}`);
}
for (const e of entries.slice(0, 40)) {
yield* print(` ${e.title}${e.launch?.value ?? "(no launch)"}`);
}
if (entries.length > 40) {
yield* print(` … and ${entries.length - 40} more`);
}
for (const s of report.skipped.slice(0, 20)) {
yield* print(`SKIP ${s.title}: ${s.reason}`);
}
for (const w of report.warnings) yield* print(`! ${w}`);
}),
},
sync: {
summary: "Reconcile the library provider now",
run: () =>
Effect.gen(function* () {
const sync = yield* PlayniteSync;
const outcome = yield* sync.engine.sync("manual");
yield* print(
outcome._tag === "AlreadyRunning"
? "a sync is already running"
: `${outcome._tag}: ${outcome.report.included} entries`,
);
}),
},
uninstall: {
summary: "Remove every playnite entry from the host library",
run: () =>
Effect.gen(function* () {
const provider = yield* ProviderClient;
yield* provider.remove(PROVIDER_ID);
yield* print("provider entries removed");
}),
},
};
await runPluginCli({
def: {
name: PLUGIN_NAME,
version: pkg.version,
layer: playniteLayer,
main: Effect.void,
},
commands,
});
+3 -3
View File
@@ -1,6 +1,6 @@
// Plugin identity. The package name is `@punktfunk/plugin-playnite` (the runner's scoped glob), but
// 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";
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bun
// Dev-only API server: the plugin's HttpApi on a plain loopback port (:5886), no auth, no
// static files — the `bun run dev:live` proxy target for the Vite UI. Connects to a running
// host when one is up (full sync path); otherwise runs host-less (locate, preview, config
// and the art proxy all work; reconcile fails softly in the engine's safeSync).
// This is NOT part of the shipped plugin (never bundled into dist/index.js or cli.js).
import { connect, type Punktfunk } from "@punktfunk/host";
import {
hostClientFromFacade,
loggingLayer,
pluginInfoLayer,
} from "@punktfunk/plugin-kit";
import { Effect, Layer, ManagedRuntime } from "effect";
import { HttpRouter } from "effect/unstable/http";
import pkg from "../package.json" with { type: "json" };
import { PLUGIN_NAME } from "./const.js";
import { playniteLayer } from "./layer.js";
import { makeApi } from "./services/api.js";
import { PlayniteConfig } from "./services/config.js";
import { PlayniteSync } from "./services/engine.js";
const PORT = Number(process.env.PLAYNITE_DEV_PORT ?? 5886);
const offlineFacade = (): Punktfunk =>
({
request: async (method: string, path: string) => {
throw new Error(`dev: no host running (tried ${method} ${path})`);
},
close: () => {},
}) as unknown as Punktfunk;
const pf = await connect().catch(() => {
console.log("dev: no punktfunk host reachable — running host-less");
return offlineFacade();
});
const base = Layer.mergeAll(
hostClientFromFacade(pf),
pluginInfoLayer({ name: PLUGIN_NAME, version: pkg.version }),
loggingLayer(PLUGIN_NAME),
);
const rt = ManagedRuntime.make(Layer.provideMerge(playniteLayer, base));
// No Effect.scoped here: the engine's loops belong to the runtime's layer scope and must
// outlive this acquisition effect (they close on rt.dispose()).
const { handler, dispose } = await rt.runPromise(
Effect.gen(function* () {
const sync = yield* PlayniteSync;
const config = yield* PlayniteConfig;
yield* sync.engine.start;
return HttpRouter.toWebHandler(makeApi({ sync, config }));
}),
);
const server = Bun.serve({
hostname: "127.0.0.1",
port: PORT,
idleTimeout: 0, // SSE
fetch: (req) => handler(req),
});
console.log(`playnite dev API on http://127.0.0.1:${server.port}/api/status`);
process.once("SIGINT", () => {
void dispose()
.then(() => rt.dispose())
.finally(() => process.exit(0));
});
+100
View File
@@ -0,0 +1,100 @@
// Cover art. Playnite stores art as local files on the host (it passes a remote `http`
// reference through verbatim). Three delivery modes — see `ArtOptions` in the contract:
// - `host` (default): emit the reference AS-IS; the host serves local paths through its
// art proxy (`/library/art/…`), so the reconcile payload carries no image bytes and
// scales to any library size.
// - `dataurl`: inline the cover as a size-capped `data:` URL (self-contained, but only fit
// for small libraries — a big one blows past the host's reconcile body limit).
// - `off`: no art at all.
//
// This module also owns the ALLOW-LIST for the plugin's own `/api/art` proxy: the SPA can't
// load `C:\…\cover.jpg`, so it asks the plugin for those bytes — and the plugin only ever
// serves a path that the current export actually references (`artPaths`).
import * as fs from "node:fs";
import * as path from "node:path";
import type {
ArtOptions,
ExportedGame,
LibraryExport,
} from "@playnite/contract";
import type { Artwork } from "@punktfunk/plugin-kit/wire";
const MIME: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".ico": "image/x-icon",
".tga": "image/x-tga",
};
/** The content type for a local art file, or null for an extension we don't serve. */
export const mimeFor = (file: string): string | null =>
MIME[path.extname(file).toLowerCase()] ?? null;
/** True for a reference the browser/host can already fetch without our help. */
export const isRemote = (reference: string): boolean =>
/^(https?:|data:)/i.test(reference);
/** Read a local image file into a `data:` URL, or null if missing, oversized, or an
* unknown type. */
export const fileToDataUrl = (
file: string | null,
maxBytes: number,
): string | null => {
if (!file) return null;
if (isRemote(file)) return file; // already inline-able
const mime = mimeFor(file);
if (!mime) return null;
try {
const st = fs.statSync(file);
if (!st.isFile() || st.size === 0 || st.size > maxBytes) return null;
return `data:${mime};base64,${fs.readFileSync(file).toString("base64")}`;
} catch {
return null;
}
};
/** Build the artwork block for a game per the art config. `undefined` when there is none. */
export const buildArtwork = (
game: ExportedGame,
art: ArtOptions,
): Artwork | undefined => {
if (art.mode === "off") return undefined;
// `host` mode: emit the references verbatim (the exporter only sets one when the file
// exists). The host serves the bytes, so the payload stays tiny at any library size.
// `dataurl` mode: inline (size-capped).
const [portrait, background] =
art.mode === "host"
? [game.cover, art.includeBackground ? game.background : null]
: [
fileToDataUrl(game.cover, art.maxBytes),
art.includeBackground
? fileToDataUrl(game.background, art.maxBytes)
: null,
];
if (!portrait && !background) return undefined;
// `Artwork` keys are optionalKey — omit them, never set them to undefined.
return {
...(portrait ? { portrait } : {}),
...(background ? { hero: background } : {}),
} satisfies Artwork;
};
/**
* Every LOCAL art path the export references — the allow-list the `/api/art` proxy serves
* from. Remote references are excluded (the browser loads those itself), so the proxy can
* never be talked into reading a file Playnite didn't hand us.
*/
export const artPaths = (exp: LibraryExport): ReadonlySet<string> => {
const out = new Set<string>();
for (const game of exp.games) {
for (const ref of [game.cover, game.background, game.icon]) {
if (ref && !isRemote(ref) && mimeFor(ref) !== null) out.add(ref);
}
}
return out;
};
+161
View File
@@ -0,0 +1,161 @@
// Locating and reading the exporter's output — the half of the cross-process contract
// that runs on the host. Pure of Effect (plain sync fs) so it unit-tests against tmp dirs;
// the engine service wraps it.
//
// WHICH ACCOUNT can see the file is the whole problem. The managed runner is de-privileged
// (`NT AUTHORITY\LocalService`) and cannot read the interactive user's `%APPDATA%`, so the
// reliable source is the **ingest inbox** (`<config_dir>/ingest/playnite/`) that the
// exporter also drops the file into — checked FIRST, always. The `%APPDATA%` /
// `C:\Users\*` `ExtensionsData` scan still works for a same-user/SYSTEM runner or on Linux
// (Wine/portable), and an explicit `playniteDir` override joins the candidate list.
//
// The `ExtensionsData/<guid>/` folder name is NOT hardcoded: we glob
// `ExtensionsData/*/punktfunk-library.json` and take the newest, so the reader stays
// decoupled from the exporter's extension id.
import * as fs from "node:fs";
import * as path from "node:path";
import {
type Config,
EXPORT_FILE,
LibraryExport,
type Location,
} from "@playnite/contract";
import { pluginIngestDir } from "@punktfunk/plugin-kit";
import { Schema } from "effect";
import { PLUGIN_NAME } from "../const.js";
const exists = (p: string): boolean => {
try {
fs.accessSync(p);
return true;
} catch {
return false;
}
};
/** Candidate Playnite data directories, most-specific first (the config override is
* prepended by the caller). */
export const candidatePlayniteDirs = (): string[] => {
const out: string[] = [];
if (process.platform === "win32") {
const appdata = process.env.APPDATA;
if (appdata) out.push(path.join(appdata, "Playnite"));
// SYSTEM/other-user runner: scan every local profile's roaming AppData.
const usersRoot = path.join(process.env.SystemDrive ?? "C:", "\\", "Users");
try {
for (const user of fs.readdirSync(usersRoot)) {
out.push(path.join(usersRoot, user, "AppData", "Roaming", "Playnite"));
}
} catch {
// no C:\Users (or not Windows-like) — fine
}
} else {
// Playnite is Windows-only, but a Wine/portable layout may set these — best effort.
const home = process.env.HOME;
if (home) out.push(path.join(home, ".local", "share", "Playnite"));
}
return [...new Set(out)];
};
/** Find the newest `punktfunk-library.json` under a Playnite dir's `ExtensionsData`. */
const findExportUnder = (
dir: string,
): { file: string; mtime: number } | null => {
const base = path.join(dir, "ExtensionsData");
let best: { file: string; mtime: number } | null = null;
let subdirs: string[];
try {
subdirs = fs.readdirSync(base);
} catch {
return null;
}
for (const sub of subdirs) {
const file = path.join(base, sub, EXPORT_FILE);
try {
const st = fs.statSync(file);
if (!best || st.mtimeMs > best.mtime) best = { file, mtime: st.mtimeMs };
} catch {
// no export in this extension's dir
}
}
return best;
};
/** Resolve where the export lives. The ingest inbox always wins; then a Playnite data dir
* that actually holds an export; else the first candidate that at least exists, so the UI
* can distinguish "Playnite found, exporter not installed" from "Playnite not found". */
export const locateExport = (config: Config): Location => {
const override = config.playniteDir?.trim();
const playniteDirs = override
? [override, ...candidatePlayniteDirs()]
: candidatePlayniteDirs();
const inbox = pluginIngestDir(PLUGIN_NAME);
const candidates = [inbox, ...playniteDirs];
const inboxFile = path.join(inbox, EXPORT_FILE);
try {
const st = fs.statSync(inboxFile);
return {
dir: inbox,
candidates,
file: inboxFile,
mtime: st.mtimeMs,
viaIngest: true,
};
} catch {
// no ingest drop yet — fall back to scanning Playnite's own data dirs
}
for (const dir of playniteDirs) {
const hit = findExportUnder(dir);
if (hit) {
return {
dir,
candidates,
file: hit.file,
mtime: hit.mtime,
viaIngest: false,
};
}
}
return {
dir: playniteDirs.find(exists) ?? null,
candidates,
file: null,
mtime: null,
viaIngest: false,
};
};
/** A missing, corrupt, foreign, or too-new export. Carries a sentence fit for the UI. */
export class ExportError extends Error {}
const decodeExport = Schema.decodeUnknownSync(LibraryExport);
/**
* Read + decode the export file. The schema-version guard lives in the contract Schema
* (`schema` is checked `<= SCHEMA`), so a too-new file surfaces here as a decode failure —
* one place, shared with the UI's type, no second hand-written check to drift.
*/
export const readExport = (file: string): LibraryExport => {
let raw: string;
try {
raw = fs.readFileSync(file, "utf8");
} catch (e) {
throw new ExportError(`cannot read ${file}: ${e}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new ExportError(`${file} is not valid JSON: ${e}`);
}
try {
return decodeExport(parsed);
} catch (e) {
throw new ExportError(
`${file} is not a usable Punktfunk library export: ${e}`,
);
}
};
@@ -1,69 +1,57 @@
// The pure core: turn a validated library export + the config into the declarative entry list the host
// 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,
},
+46
View File
@@ -0,0 +1,46 @@
// The plugin entry — the default export the runner discovers. Everything is Effect inside
// definePluginKit's async-main boundary (see @punktfunk/plugin-kit): start the sync engine,
// serve the console UI, park until interruption.
//
// Provider entries are deliberately LEFT in the host library on shutdown, so the games
// survive plugin restarts and host reboots; a clean uninstall is the explicit CLI command.
import { definePluginKit, serveUi } from "@punktfunk/plugin-kit";
import { Effect } from "effect";
import pkg from "../package.json" with { type: "json" };
import { PLUGIN_NAME, UI_ICON, UI_TITLE } from "./const.js";
import { playniteLayer } from "./layer.js";
import { makeApi } from "./services/api.js";
import { PlayniteConfig } from "./services/config.js";
import { PlayniteSync } from "./services/engine.js";
export { PLUGIN_NAME, PROVIDER_ID, UI_ICON, UI_TITLE } from "./const.js";
const plugin = definePluginKit({
name: PLUGIN_NAME,
version: pkg.version,
layer: playniteLayer,
main: Effect.gen(function* () {
const sync = yield* PlayniteSync;
const config = yield* PlayniteConfig;
// Headless sync first — the library reconciles even if the UI can't come up.
yield* sync.engine.start;
yield* serveUi({
title: UI_TITLE,
icon: UI_ICON,
staticDir: new URL("./ui/", import.meta.url),
api: makeApi({ sync, config }),
}).pipe(
Effect.catch((e) =>
Effect.logWarning(
`ui: could not serve the console surface (${e.cause}) — engine continues headless`,
),
),
);
yield* Effect.never;
}),
});
export default plugin;
+29
View File
@@ -0,0 +1,29 @@
// The plugin's service graph over the kit base (HostClient | PluginInfo) — shared by the
// runner entry (index.ts), the CLI, and the dev server so they all run the same engine.
import type { HostClient } from "@punktfunk/plugin-kit";
import { type PluginInfo, ProviderClient } from "@punktfunk/plugin-kit";
import { Layer } from "effect";
import { PlayniteCache } from "./services/cache.js";
import { PlayniteConfig } from "./services/config.js";
import { PlayniteSync } from "./services/engine.js";
export type PlayniteServices =
| PlayniteConfig
| PlayniteCache
| PlayniteSync
| ProviderClient;
export const playniteLayer: Layer.Layer<
PlayniteServices,
never,
HostClient | PluginInfo
> = PlayniteSync.layer.pipe(
Layer.provideMerge(
Layer.mergeAll(
PlayniteConfig.layer,
PlayniteCache.layer,
ProviderClient.layer,
),
),
);
+117
View File
@@ -0,0 +1,117 @@
// The HttpApi implementation + the two non-JSON routes (SSE status feed, cover-art
// proxy), composed as the Layer `serveUi` mounts. Built from live service VALUES (not
// layers) so the handler runtime shares the plugin runtime's singletons — one engine, one
// config service.
import {
ART_PATH,
ConfigInvalid,
EVENTS_EVENT,
EVENTS_PATH,
ExportUnavailable,
PlayniteApi,
type PlayniteConfigSchema,
SyncInProgress,
} from "@playnite/contract";
import {
type ConfigService,
httpApiEnv,
sseRoute,
} from "@punktfunk/plugin-kit";
import { Effect, Layer } from "effect";
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import type { PlayniteSyncService } from "./engine.js";
export interface ApiDeps {
readonly sync: PlayniteSyncService;
readonly config: ConfigService<typeof PlayniteConfigSchema>;
}
/**
* `GET /api/art?path=<abs>` — the cover-art proxy. Playnite art is local files on the
* host, which the SPA cannot load, so it asks us for the bytes. The engine only answers
* for a path the CURRENT export references (see `artPaths`), so this is an allow-list
* lookup, not an arbitrary file read; anything else is a flat 404.
*/
const artRoute = (
deps: ApiDeps,
): Layer.Layer<never, never, HttpRouter.HttpRouter> =>
HttpRouter.add("GET", ART_PATH, (request) =>
Effect.gen(function* () {
const file = new URL(request.url, "http://localhost").searchParams.get(
"path",
);
if (file === null || file === "") {
return HttpServerResponse.empty({ status: 400 });
}
const art = yield* deps.sync.art(file);
if (art === undefined) return HttpServerResponse.empty({ status: 404 });
return HttpServerResponse.uint8Array(art.bytes, {
contentType: art.contentType,
// Playnite art files are content-addressed by Playnite; the path changes
// when the picture does, so a long private cache is safe.
headers: { "cache-control": "private, max-age=3600" },
});
}),
);
export const makeApi = (
deps: ApiDeps,
): Layer.Layer<never, never, HttpRouter.HttpRouter> => {
const statusGroup = HttpApiBuilder.group(PlayniteApi, "status", (h) =>
h.handle("get", () => deps.sync.status),
);
const configGroup = HttpApiBuilder.group(PlayniteApi, "config", (h) =>
h
.handle("get", () =>
deps.config.loadRaw.pipe(
Effect.map((raw) => ({ raw: raw as unknown })),
Effect.orDie,
),
)
.handle("put", ({ payload }) =>
deps.config.saveRaw(payload).pipe(
Effect.mapError((e) => new ConfigInvalid({ issues: String(e) })),
// Loops restart + resync in the background; the PUT answers immediately.
Effect.tap(() => Effect.forkDetach(deps.sync.engine.reconfigure)),
Effect.map(() => ({ raw: payload as unknown })),
),
),
);
const libraryGroup = HttpApiBuilder.group(PlayniteApi, "library", (h) =>
h.handle("preview", () => deps.sync.preview),
);
const syncGroup = HttpApiBuilder.group(PlayniteApi, "sync", (h) =>
h.handle("run", () =>
deps.sync.engine.sync("manual").pipe(
// A SyncError wraps whatever compute/apply failed with; an unreadable export
// is the one case the UI can act on, so it stays a typed 503.
Effect.mapError((e) =>
e.cause instanceof ExportUnavailable
? e.cause
: new ExportUnavailable({ message: String(e.cause) }),
),
Effect.flatMap((outcome) =>
outcome._tag === "AlreadyRunning"
? Effect.fail(new SyncInProgress())
: Effect.succeed(outcome.report),
),
),
),
);
return Layer.mergeAll(
HttpApiBuilder.layer(PlayniteApi).pipe(
Layer.provide(
Layer.mergeAll(statusGroup, configGroup, libraryGroup, syncGroup),
),
Layer.provide(httpApiEnv),
),
// EngineStatus is an identity codec (plain JSON shape) — default JSON encode.
sseRoute(EVENTS_PATH, deps.sync.statusChanges, { event: EVENTS_EVENT }),
artRoute(deps),
);
};
+28
View File
@@ -0,0 +1,28 @@
// The plugin's disposable derived state (cache.json), on the kit's CacheStore. Playnite's
// library IS the source of truth and lives one process away, so there is nothing to cache
// but the last reconcile fingerprint — an unchanged export then costs one read and no PUT.
// Corrupt/absent → empty; every mutation is write-through.
import { LastSync } from "@playnite/contract";
import {
type CacheStore,
makeCacheStore,
type PluginInfo,
} from "@punktfunk/plugin-kit";
import { Context, Layer, Schema } from "effect";
export const CacheSchema = Schema.Struct({
lastSync: Schema.optionalKey(LastSync),
});
export type Cache = typeof CacheSchema.Type;
export const emptyCache: Cache = {};
export class PlayniteCache extends Context.Service<
PlayniteCache,
CacheStore<typeof CacheSchema>
>()("playnite/PlayniteCache") {
static readonly layer: Layer.Layer<PlayniteCache, never, PluginInfo> =
Layer.effect(PlayniteCache)(
makeCacheStore({ schema: CacheSchema, empty: emptyCache }),
);
}
+19
View File
@@ -0,0 +1,19 @@
// The plugin's config service — the kit's ConfigService over the contract schema.
// Raw round-trip by construction: the file on disk is always the authored shape.
import { PlayniteConfigSchema } from "@playnite/contract";
import {
type ConfigService,
makeConfigService,
type PluginInfo,
} from "@punktfunk/plugin-kit";
import { Context, Layer } from "effect";
export class PlayniteConfig extends Context.Service<
PlayniteConfig,
ConfigService<typeof PlayniteConfigSchema>
>()("playnite/PlayniteConfig") {
static readonly layer: Layer.Layer<PlayniteConfig, never, PluginInfo> =
Layer.effect(PlayniteConfig)(
makeConfigService({ schema: PlayniteConfigSchema }),
);
}
+270
View File
@@ -0,0 +1,270 @@
// PlayniteSync — the plugin's engine service: wires the pure domain (locate → read →
// compute) into the kit's generic SyncEngine (poll/watch/debounce/coalesce/fingerprint)
// and the ProviderClient reconcile. Also owns the EngineStatus assembly the API and the
// SSE feed share, the side-effect-light preview the Library page uses, and the allow-listed
// cover-art reader behind `/api/art`.
import * as fs from "node:fs";
import * as path from "node:path";
import {
type Config,
type EngineStatus,
EXPORT_FILE,
ExportUnavailable,
type LibraryExport,
type Location,
type PlayniteInfo,
type PreviewResult,
resolveConfig,
type SyncReport,
} from "@playnite/contract";
import {
type LastSync,
makeSyncEngine,
ProviderClient,
pluginIngestDir,
type SyncEngine,
} from "@punktfunk/plugin-kit";
import type { ProviderEntry } from "@punktfunk/plugin-kit/wire";
import {
Context,
Duration,
Effect,
Layer,
Ref,
type Scope,
Stream,
} from "effect";
import { PLUGIN_NAME, PROVIDER_ID } from "../const.js";
import { artPaths, buildArtwork, mimeFor } from "../domain/art.js";
import { locateExport, readExport } from "../domain/locate.js";
import { computeEntries } from "../domain/reconcile.js";
import { PlayniteCache } from "./cache.js";
import { PlayniteConfig } from "./config.js";
/** One cover the `/api/art` proxy is willing to serve. */
export interface ArtFile {
readonly bytes: Uint8Array;
readonly contentType: string;
}
export interface PlayniteSyncService {
readonly engine: SyncEngine<SyncReport>;
/** Read + compute the desired state. NO art embedding, NO reconcile. */
readonly preview: Effect.Effect<PreviewResult, ExportUnavailable>;
readonly status: Effect.Effect<EngineStatus>;
/** The SSE feed: a full EngineStatus on every engine transition. */
readonly statusChanges: Stream.Stream<EngineStatus>;
/** Where the export was found — the CLI's `where`, without a full read. */
readonly locate: Effect.Effect<Location>;
/**
* Read one local cover, but ONLY if the currently-ingested export references it.
* `undefined` for anything else — the browser cannot ask this for an arbitrary file.
*/
readonly art: (file: string) => Effect.Effect<ArtFile | undefined>;
}
export class PlayniteSync extends Context.Service<
PlayniteSync,
PlayniteSyncService
>()("playnite/PlayniteSync") {
// Layer.effect runs `make` in the LAYER's scope (Scope is excluded from R) — the
// engine's watch/poll loops live exactly as long as the plugin runtime.
static readonly layer: Layer.Layer<
PlayniteSync,
never,
PlayniteConfig | PlayniteCache | ProviderClient
> = Layer.effect(PlayniteSync)(make());
}
const NO_LOCATION: Location = {
dir: null,
candidates: [],
file: null,
mtime: null,
viaIngest: false,
};
/** The art allow-list, pinned to the export document it was derived from. */
interface AllowList {
readonly file: string;
readonly mtime: number;
readonly paths: ReadonlySet<string>;
}
function make(): Effect.Effect<
PlayniteSyncService,
never,
PlayniteConfig | PlayniteCache | ProviderClient | Scope.Scope
> {
return Effect.gen(function* () {
const cfg = yield* PlayniteConfig;
const cache = yield* PlayniteCache;
const provider = yield* ProviderClient;
const platform = process.platform;
const ingest = pluginIngestDir(PLUGIN_NAME);
// Observed state the status view reports (the old Engine's private fields).
const lastLocation = yield* Ref.make<Location>(NO_LOCATION);
const lastError = yield* Ref.make<string | undefined>(undefined);
const lastPlaynite = yield* Ref.make<PlayniteInfo | undefined>(undefined);
const allowList = yield* Ref.make<AllowList | undefined>(undefined);
const loadOrDefault = cfg.load.pipe(
Effect.orElseSucceed(() => resolveConfig({})),
);
/** Locate + read + decode, refreshing everything the status view and the art proxy
* derive from the export. The one place an export failure is recorded. */
const readCurrent = (
config: Config,
): Effect.Effect<LibraryExport, ExportUnavailable> =>
Effect.gen(function* () {
const location = locateExport(config);
yield* Ref.set(lastLocation, location);
if (location.file === null) {
const message = `no exporter output under ${location.dir ?? "any Playnite data dir"} — is the Punktfunk Sync extension installed in Playnite?`;
yield* Ref.set(lastError, message);
return yield* Effect.fail(new ExportUnavailable({ message }));
}
const file = location.file;
const exp = yield* Effect.try({
try: () => readExport(file),
catch: (cause) =>
new ExportUnavailable({
message: cause instanceof Error ? cause.message : String(cause),
}),
}).pipe(Effect.tapError((e) => Ref.set(lastError, e.message)));
yield* Ref.set(lastError, undefined);
yield* Ref.set(lastPlaynite, exp.playnite);
yield* Ref.set(allowList, {
file,
mtime: location.mtime ?? 0,
paths: artPaths(exp),
});
return exp;
});
const computeWith = (
embedArt: boolean,
): Effect.Effect<
{ readonly entries: ProviderEntry[]; readonly report: SyncReport },
ExportUnavailable
> =>
Effect.gen(function* () {
const config = yield* loadOrDefault;
const exp = yield* readCurrent(config);
return computeEntries({
exp,
config,
platform,
// The preview skips the (heavy, in `dataurl` mode) art embed.
artFor: embedArt
? (g) => buildArtwork(g, config.art)
: () => undefined,
});
});
const engine = yield* makeSyncEngine<
SyncReport,
ReadonlyArray<ProviderEntry>,
never
>({
compute: () => computeWith(true),
apply: (entries) => provider.reconcile(PROVIDER_ID, entries),
lastSync: {
get: cache.get.pipe(Effect.map((c) => c.lastSync)),
set: (last: LastSync) =>
cache.update((c) => ({ ...c, lastSync: last })).pipe(Effect.ignore),
},
settings: loadOrDefault.pipe(
Effect.map((config) => ({
pollInterval: Duration.minutes(Math.max(1, config.sync.pollMinutes)),
watch: config.sync.watch,
debounce: Duration.millis(config.sync.debounceMs),
watchDirs: watchTargets(config),
})),
),
});
/**
* What to watch: the ingest inbox ALWAYS (the de-privileged runner's only source,
* so an exporter drop reconciles within seconds even when the Playnite dir resolved
* elsewhere), plus the located Playnite dir's `ExtensionsData` when there is one.
* The kit's watcher set is best-effort — an absent dir is silently skipped and the
* interval poll covers it.
*/
function watchTargets(config: Config): string[] {
const targets = new Set<string>([ingest]);
const located = locateExport(config);
if (located.dir !== null && !located.viaIngest) {
const extData = path.join(located.dir, "ExtensionsData");
targets.add(fs.existsSync(extData) ? extData : located.dir);
}
return [...targets];
}
const status: Effect.Effect<EngineStatus> = Effect.gen(function* () {
const config = yield* loadOrDefault;
const engineStatus = yield* engine.status;
const location = yield* Ref.get(lastLocation);
const exportError = yield* Ref.get(lastError);
const playnite = yield* Ref.get(lastPlaynite);
return {
platform,
// Before the first sync the status view would otherwise show nothing at all.
location: location.file === null ? locateExport(config) : location,
// Explicit nulls, never undefined — see the trap note in contract/domain.ts.
exportError: exportError ?? null,
playnite: playnite ?? null,
artMode: config.art.mode,
syncing: engineStatus.syncing,
lastSync: engineStatus.lastSync ?? null,
lastReport: engineStatus.lastReport ?? null,
paths: {
dir: path.dirname(cfg.path),
config: cfg.path,
cache: cache.path,
ingest: path.join(ingest, EXPORT_FILE),
},
} satisfies EngineStatus;
});
const art = (file: string): Effect.Effect<ArtFile | undefined> =>
Effect.gen(function* () {
const cached = yield* Ref.get(allowList);
const config = yield* loadOrDefault;
const located = locateExport(config);
// Refresh when we have never read an export, or the file moved under us —
// otherwise a cover added since the last sync would 404 until the next one.
const stale =
cached === undefined ||
located.file !== cached.file ||
(located.mtime ?? 0) !== cached.mtime;
if (stale) yield* readCurrent(config).pipe(Effect.ignore);
const allow = yield* Ref.get(allowList);
if (allow === undefined || !allow.paths.has(file)) return undefined;
const contentType = mimeFor(file);
if (contentType === null) return undefined;
return yield* Effect.try(
(): ArtFile => ({ bytes: fs.readFileSync(file), contentType }),
).pipe(Effect.orElseSucceed(() => undefined));
});
return {
engine,
preview: computeWith(false),
status,
// A fresh subscriber gets the CURRENT status immediately, then one frame per
// engine transition — so the console's live view is right the moment it
// connects, rather than blank until the next sync. Each frame is a full
// snapshot, so the sliver between the two stages costs nothing.
statusChanges: Stream.concat(
Stream.fromEffect(status),
engine.changes.pipe(Stream.mapEffect(() => status)),
),
locate: loadOrDefault.pipe(Effect.map(locateExport)),
art,
} satisfies PlayniteSyncService;
});
}
+51 -3
View File
@@ -2,9 +2,8 @@ import { afterAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as 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);
});
});
+73
View File
@@ -0,0 +1,73 @@
// The config contract: defaults live ONLY in the schema, and the Encoded side is the
// authored file shape. A regression here would let a UI save bake defaults into the
// operator's config.json (the raw round-trip invariant the kit's ConfigService relies on).
import { describe, expect, test } from "bun:test";
import type { RawConfig } from "@playnite/contract";
import { PlayniteConfigSchema, resolveConfig } from "@playnite/contract";
import { Schema } from "effect";
const encode = Schema.encodeUnknownSync(PlayniteConfigSchema);
describe("resolveConfig", () => {
test("an empty file resolves to the documented defaults", () => {
expect(resolveConfig({})).toEqual({
sync: { pollMinutes: 5, watch: true, debounceMs: 1500 },
filter: {
installedOnly: true,
includeHidden: false,
sources: [],
excludeSources: [],
},
art: { mode: "host", maxBytes: 1_500_000, includeBackground: false },
gameOverrides: {},
});
});
test("a partial group keeps the untouched keys at their defaults", () => {
const config = resolveConfig({ art: { mode: "dataurl" } });
expect(config.art.mode).toBe("dataurl");
expect(config.art.maxBytes).toBe(1_500_000);
expect(config.sync.pollMinutes).toBe(5);
});
test("stale keys from an older config are tolerated and dropped", () => {
// 0.1.x wrote `ui` and `devEntry`; both are gone in 0.2.0.
const config = resolveConfig({
ui: { standalone: true, port: 47994 },
devEntry: true,
filter: { installedOnly: false },
});
expect(config.filter.installedOnly).toBe(false);
expect("ui" in config).toBe(false);
expect("devEntry" in config).toBe(false);
});
test("an invalid art mode is refused", () => {
expect(() => resolveConfig({ art: { mode: "inline" } })).toThrow();
});
});
describe("raw round-trip", () => {
test("encoding a resolved config omits every defaulted key", () => {
// This is what makes a UI save non-destructive: defaults never reach the file.
expect(encode(resolveConfig({}))).toEqual({});
});
test("encode is LOSSY for defaulted groups — which is why we never re-encode", () => {
// `encodingStrategy: "omit"` drops a defaulted key unconditionally, so a decode →
// encode round-trip would silently throw away authored values. The kit's
// ConfigService therefore persists the RAW payload verbatim and only *validates*
// by decoding; nothing in the plugin may write `encode(config)` back to disk.
const raw: RawConfig = {
playniteDir: "D:\\Playnite",
filter: { excludeSources: ["Xbox"] },
gameOverrides: {
"11111111-1111-1111-1111-111111111111": { exclude: true },
},
};
// Undefaulted keys survive…
expect(encode(resolveConfig(raw))).toEqual({ playniteDir: "D:\\Playnite" });
// …but the authored values themselves are only safe in the raw shape.
expect(resolveConfig(raw).filter.excludeSources).toEqual(["Xbox"]);
});
});
@@ -2,9 +2,8 @@ import { afterAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as 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([]);
});
});
+6 -5
View File
@@ -1,15 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"lib": ["ES2022"],
"strict": true,
"noUnusedLocals": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"types": []
"resolveJsonModule": true,
"types": ["bun"]
},
"include": ["src", "vite.config.ts"]
"include": ["src", "test"]
}
+6
View File
@@ -0,0 +1,6 @@
// The published type surface: a plugin is a runtime artifact — the only thing anyone
// imports from the package is the default PluginDef (the runner's discovery contract).
import type { PluginDef } from "@punktfunk/host";
declare const plugin: PluginDef;
export default plugin;
-70
View File
@@ -1,70 +0,0 @@
// Cover art. Playnite stores art as local files on the host. Two delivery modes (see ArtMode):
// - `host` (default): emit the local file PATH; the updated host serves the bytes via its art proxy
// (`/library/art/…`), so the reconcile payload carries no image data and scales to any library size.
// - `dataurl`: inline the cover as a size-capped `data:` URL (self-contained, but only fit for small
// libraries — a big one blows past the host's reconcile body limit). `maxBytes` caps a single image.
import * as fs from "node:fs";
import * as path from "node:path";
import type { ArtOptions } from "./config.js";
import type { ExportedGame } from "./export-schema.js";
import type { Artwork } from "./wire.js";
const MIME: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".ico": "image/x-icon",
".tga": "image/x-tga",
};
const mimeFor = (file: string): string | null =>
MIME[path.extname(file).toLowerCase()] ?? null;
/** Read a local image file into a `data:` URL, or null if missing, oversized, or an unknown type. */
export const fileToDataUrl = (
file: string | null,
maxBytes: number,
): string | null => {
if (!file) return null;
const mime = mimeFor(file);
if (!mime) return null;
try {
const st = fs.statSync(file);
if (!st.isFile() || st.size === 0 || st.size > maxBytes) return null;
const b64 = fs.readFileSync(file).toString("base64");
return `data:${mime};base64,${b64}`;
} catch {
return null;
}
};
/** Build the artwork block for a game per the art config. Returns undefined when nothing was embedded. */
export const buildArtwork = (
game: ExportedGame,
art: ArtOptions,
): Artwork | undefined => {
if (art.mode === "off") return undefined;
// `host` mode: emit the local file paths verbatim (the exporter only sets a path when the file
// exists). The host serves the bytes via its art proxy, so the reconcile payload carries no image
// data and scales to any library size. The default.
if (art.mode === "host") {
const out: Artwork = {};
if (game.cover) out.portrait = game.cover;
if (art.includeBackground && game.background) out.hero = game.background;
return out.portrait || out.hero ? out : undefined;
}
// `dataurl` mode: inline (size-capped) — self-contained but only fit for small libraries.
const portrait = fileToDataUrl(game.cover, art.maxBytes);
const hero = art.includeBackground
? fileToDataUrl(game.background, art.maxBytes)
: null;
if (!portrait && !hero) return undefined;
const out: Artwork = {};
if (portrait) out.portrait = portrait;
if (hero) out.hero = hero;
return out;
};
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env bun
// Dev/debug CLI — reuses the pure core and the engine. `where`/`preview` are offline (no host needed);
// `sync`/`uninstall` connect to the host and reconcile. Handy for configuring a headless box or
// checking what a `config.json` will produce before pushing it to the library.
//
// bunx punktfunk-plugin-playnite where # where the exporter output was found
// bunx punktfunk-plugin-playnite preview # the desired library (no host write)
// bunx punktfunk-plugin-playnite sync # reconcile into the live library
// bunx punktfunk-plugin-playnite uninstall # remove all this plugin's entries
// bunx punktfunk-plugin-playnite set-password <pw> # standalone-UI password
import { connect } from "@punktfunk/host";
import { Engine } from "./engine/index.js";
import { computeEntries } from "./engine/reconcile.js";
import { locateExport, readExport } from "./playnite.js";
import { hashPassword } from "./server/password.js";
import { loadConfig, saveConfig } from "./state.js";
const cmdWhere = (): void => {
const config = loadConfig();
const loc = locateExport(config);
console.log(`Playnite data dir: ${loc.dir ?? "(not found)"}`);
console.log(`Export file: ${loc.file ?? "(not found)"}`);
if (loc.mtime)
console.log(`Last written: ${new Date(loc.mtime).toISOString()}`);
console.log("Candidates probed:");
for (const c of loc.candidates) console.log(` ${c}`);
if (!loc.file) {
console.log(
"\nNo export found. Install the Punktfunk Sync extension in Playnite (see exporter/README.md),",
);
console.log("or set `playniteDir` in the plugin config.json.");
}
};
const cmdPreview = (): void => {
const config = loadConfig();
const loc = locateExport(config);
if (!loc.file) {
console.log("No exporter output found — run `where` for details.");
process.exitCode = 1;
return;
}
const exp = readExport(loc.file);
const { entries, report } = computeEntries({
exp,
config,
artFor: () => undefined,
});
console.log(
`Preview: ${report.included}/${report.considered} game(s) would sync (${report.skipped.length} skipped).`,
);
const bySource = Object.entries(report.perSource).sort((a, b) => b[1] - a[1]);
for (const [s, n] of bySource) console.log(` ${s}: ${n}`);
console.log();
for (const e of entries.slice(0, 40))
console.log(` ${e.title}${e.launch?.value ?? "(no launch)"}`);
if (entries.length > 40) console.log(` … and ${entries.length - 40} more`);
if (report.skipped.length) {
console.log(`\n${report.skipped.length} skipped (first 20):`);
for (const s of report.skipped.slice(0, 20))
console.log(` ${s.title}: ${s.reason}`);
}
for (const w of report.warnings) console.log(`! ${w}`);
};
const cmdSync = async (): Promise<void> => {
const pf = await connect();
try {
const report = await new Engine({ pf }).sync("cli");
if (report) {
console.log(
`Synced: ${report.included} title(s), ${report.skipped.length} skipped.`,
);
} else {
console.log("Sync did not reconcile (no change, or see log).");
}
} finally {
pf.close();
}
};
const cmdUninstall = async (): Promise<void> => {
const pf = await connect();
try {
await new Engine({ pf }).uninstall();
console.log("Removed all playnite entries from the library.");
} finally {
pf.close();
}
};
const cmdSetPassword = (password: string | undefined): void => {
if (!password) {
console.error("usage: set-password <password>");
process.exitCode = 2;
return;
}
const config = loadConfig();
config.ui.passwordHash = hashPassword(password);
saveConfig(config);
console.log("Standalone-UI password set (config.ui.passwordHash).");
};
const main = async (): Promise<void> => {
const [cmd, arg] = process.argv.slice(2);
switch (cmd) {
case "where":
return cmdWhere();
case "preview":
return cmdPreview();
case "sync":
return cmdSync();
case "uninstall":
return cmdUninstall();
case "set-password":
return cmdSetPassword(arg);
default:
console.log(
"usage: punktfunk-plugin-playnite <where|preview|sync|uninstall|set-password>",
);
process.exitCode = cmd ? 2 : 0;
}
};
await main();
-117
View File
@@ -1,117 +0,0 @@
// The plugin's configuration model — the single source of truth for the sync engine, editable by hand
// (headless boxes) or by the web UI. Everything the engine needs is derivable from this file plus the
// exporter's `punktfunk-library.json`; `resolveConfig` fills defaults so the rest of the code works
// against a fully-populated shape.
/** When and how often we re-read the export and reconcile. */
export interface SyncOptions {
/** Interval poll in minutes — the backstop under the exporter-driven file watch. */
pollMinutes: number;
/** Watch the exporter's output directory and reconcile on change (the primary trigger). */
watch: boolean;
/** Debounce window for coalescing watch/poll-triggered re-reads (ms). */
debounceMs: number;
}
/** Which Playnite games become library entries. */
export interface FilterOptions {
/** Only sync games Playnite marks installed (default). Off = include not-yet-installed titles too. */
installedOnly: boolean;
/** Include games flagged Hidden in Playnite (default off). */
includeHidden: boolean;
/** If non-empty, keep ONLY games whose source is in this list ("Steam", "GOG", …). */
sources: string[];
/** Drop games whose source is in this list (applied after `sources`). */
excludeSources: string[];
}
/** How cover art reaches the clients. Playnite art is local files on the host:
* - `host` (default): send the local file PATH; the host serves the bytes through its art proxy
* (`/library/art/…`, like Steam art). The reconcile payload carries no image data, so this scales
* to libraries of any size — the recommended mode.
* - `dataurl`: inline each cover as a size-capped `data:` URL. Self-contained but does NOT scale — a
* large library blows past the host's reconcile body limit. Small libraries only.
* - `off`: sync titles with no art. */
export type ArtMode = "host" | "dataurl" | "off";
export interface ArtOptions {
mode: ArtMode;
/** Skip an image whose file is larger than this (bytes) — a `data:` URL bloats the library payload,
* so covers over the cap are dropped rather than inlined. */
maxBytes: number;
/** Also inline the Playnite background as `hero` art (usually large — off by default). */
includeBackground: boolean;
}
export interface UiOptions {
/** Fallback standalone mode: serve the UI on a fixed port behind a password instead of via the
* console proxy. For host-only installs without the console. */
standalone: boolean;
/** Standalone bind port. */
port: number;
/** Standalone bind address (defaults to loopback; a non-loopback bind REQUIRES a password). */
bind: string;
/** scrypt password hash (`scrypt$<saltB64>$<hashB64>`), required for a non-loopback standalone bind. */
passwordHash?: string;
}
/** The on-disk config as authored (all optional — `resolveConfig` fills the rest). */
export interface RawConfig {
/** Override the auto-detected Playnite data directory (`%APPDATA%\Playnite`). Point it at a
* portable install, or at the interactive user's profile when the runner runs as a different user. */
playniteDir?: string;
sync?: Partial<SyncOptions>;
filter?: Partial<FilterOptions>;
art?: Partial<ArtOptions>;
ui?: Partial<UiOptions>;
/** Dev/acceptance flag: reconcile one hardcoded entry so the round-trip is observable. */
devEntry?: boolean;
}
/** The fully-resolved config the engine consumes. */
export interface Config {
playniteDir?: string;
sync: SyncOptions;
filter: FilterOptions;
art: ArtOptions;
ui: UiOptions;
devEntry: boolean;
}
export const DEFAULT_SYNC: SyncOptions = {
pollMinutes: 5,
watch: true,
debounceMs: 1500,
};
export const DEFAULT_FILTER: FilterOptions = {
installedOnly: true,
includeHidden: false,
sources: [],
excludeSources: [],
};
export const DEFAULT_ART: ArtOptions = {
mode: "host",
maxBytes: 1_500_000,
includeBackground: false,
};
export const DEFAULT_UI: UiOptions = {
standalone: false,
port: 47994,
bind: "127.0.0.1",
};
/** Fill defaults over an authored config so the rest of the engine sees a total shape. */
export const resolveConfig = (raw: RawConfig): Config => ({
playniteDir: raw.playniteDir?.trim() || undefined,
sync: { ...DEFAULT_SYNC, ...raw.sync },
filter: { ...DEFAULT_FILTER, ...raw.filter },
art: { ...DEFAULT_ART, ...raw.art },
ui: { ...DEFAULT_UI, ...raw.ui },
devEntry: raw.devEntry ?? false,
});
/** The empty starting config for a fresh install. */
export const emptyConfig = (): Config => resolveConfig({});
-328
View File
@@ -1,328 +0,0 @@
// The sync engine: the headless half of the plugin. It ties the pure core (locate → read → compute)
// to the real world — reading the exporter's `punktfunk-library.json`, embedding cover art, and the
// host reconcile PUT — and drives it on start, on an interval poll, on export-file changes (debounced),
// and on demand from the UI/CLI. Fully functional from a hand-written `config.json` alone.
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import type { Punktfunk } from "@punktfunk/host";
import { buildArtwork } from "../art.js";
import type { Config } from "../config.js";
import type { ExportedGame } from "../export-schema.js";
import { deleteProvider, reconcileProvider } from "../host.js";
import { type Logger, makeLogger } from "../log.js";
import { ingestDir } from "../paths.js";
import {
ExportError,
type Location,
locateExport,
readExport,
} from "../playnite.js";
import {
type Cache,
loadCache,
loadConfig,
saveCache,
statePaths,
} from "../state.js";
import type { ProviderEntryInput } from "../wire.js";
import { computeEntries, type SyncReport } from "./reconcile.js";
export interface EngineDeps {
pf: Punktfunk;
platform?: NodeJS.Platform;
log?: Logger;
}
export interface EngineStatus {
platform: NodeJS.Platform;
/** Where the export is (or why it wasn't found) — drives the console's "connected?" banner. */
location: Location;
/** Last read error (schema/corruption), if any. */
exportError?: string;
lastSync?: Cache["lastSync"];
lastReport?: SyncReport;
paths: ReturnType<typeof statePaths>;
syncing: boolean;
}
/** A stable fingerprint of (export bytes + the config knobs that affect output). Lets an unchanged
* re-read skip the expensive art-embed + PUT entirely. */
const fingerprint = (bytes: Buffer, config: Config): string =>
createHash("sha256")
.update(bytes)
.update(
JSON.stringify({
filter: config.filter,
art: config.art,
devEntry: config.devEntry,
platform: process.platform,
}),
)
.digest("hex");
/** A synthetic entry proving the reconcile round-trip end-to-end (dev flag / acceptance). */
const devEntry = (platform: NodeJS.Platform): ProviderEntryInput => ({
external_id: "00000000-0000-0000-0000-000000000000",
title: "Playnite (dev entry)",
launch: {
kind: "command",
value: platform === "win32" ? "cmd /c echo hi" : "echo hi",
},
});
export class Engine {
private readonly pf: Punktfunk;
private readonly platform: NodeJS.Platform;
private readonly log: Logger;
private cache: Cache;
private syncing = false;
private pending = false;
private lastReport?: SyncReport;
private lastLocation: Location = {
dir: null,
candidates: [],
file: null,
mtime: null,
};
private lastError?: string;
private pollTimer?: ReturnType<typeof setInterval>;
private debounceTimer?: ReturnType<typeof setTimeout>;
private watchers: fs.FSWatcher[] = [];
private readonly listeners = new Set<(status: EngineStatus) => void>();
constructor(deps: EngineDeps) {
this.pf = deps.pf;
this.platform = deps.platform ?? process.platform;
this.log = deps.log ?? makeLogger();
this.cache = loadCache();
}
// ---------------------------------------------------------------- lifecycle
async start(): Promise<void> {
await this.sync("startup");
this.schedulePoll();
this.setupWatchers();
}
stop(): void {
if (this.pollTimer) clearInterval(this.pollTimer);
if (this.debounceTimer) clearTimeout(this.debounceTimer);
for (const w of this.watchers) {
try {
w.close();
} catch {
// already closed
}
}
this.watchers = [];
}
private schedulePoll(): void {
const config = loadConfig();
const ms = Math.max(1, config.sync.pollMinutes) * 60_000;
if (this.pollTimer) clearInterval(this.pollTimer);
this.pollTimer = setInterval(() => void this.sync("poll"), ms);
(this.pollTimer as { unref?: () => void }).unref?.();
}
/** Watch the exporter's `ExtensionsData` tree so a library change reconciles within seconds. */
private setupWatchers(): void {
for (const w of this.watchers) {
try {
w.close();
} catch {
// ignore
}
}
this.watchers = [];
const config = loadConfig();
if (!config.sync.watch) return;
const loc = locateExport(config);
const targets = new Set<string>();
// Always watch the ingest inbox when present — the de-privileged runner's source, so an
// exporter drop reconciles within seconds even when `loc.dir` resolved elsewhere (or nowhere).
const inbox = ingestDir();
if (fs.existsSync(inbox)) targets.add(inbox);
// Plus the located Playnite dir: prefer its ExtensionsData (where the file lives), else the
// dir itself so we notice the export appearing (same-user/SYSTEM runner, or Linux).
if (loc.dir) {
const extData = path.join(loc.dir, "ExtensionsData");
targets.add(fs.existsSync(extData) ? extData : loc.dir);
}
for (const target of targets) {
try {
this.watchers.push(
fs.watch(target, { recursive: true }, () => this.debouncedSync()),
);
} catch {
try {
this.watchers.push(fs.watch(target, () => this.debouncedSync()));
} catch {
this.log(`watch: cannot watch ${target} (poll only)`);
}
}
}
}
private debouncedSync(): void {
const config = loadConfig();
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(
() => void this.sync("fs-change"),
config.sync.debounceMs,
);
(this.debounceTimer as { unref?: () => void }).unref?.();
}
// ---------------------------------------------------------------- compute
/** Locate + read + compute WITHOUT embedding art or touching the host (the UI preview). */
computeDry(): { location: Location; report?: SyncReport; error?: string } {
const config = loadConfig();
const location = locateExport(config);
if (!location.file) {
return { location, error: "no exporter output found yet" };
}
try {
const exp = readExport(location.file);
const { report } = computeEntries({
exp,
config,
platform: this.platform,
artFor: () => undefined, // preview: skip the (heavy) art embed
});
return { location, report };
} catch (e) {
return { location, error: String(e) };
}
}
// ---------------------------------------------------------------- sync
/** The guarded reconcile: coalesces concurrent triggers, skips the read/embed/PUT when nothing changed. */
async sync(reason: string): Promise<SyncReport | undefined> {
if (this.syncing) {
this.pending = true;
return undefined;
}
this.syncing = true;
this.notify();
try {
const config = loadConfig();
const location = locateExport(config);
this.lastLocation = location;
if (!location.file) {
this.lastError = `no exporter output under ${location.dir ?? "any Playnite data dir"} — is the Punktfunk Sync extension installed in Playnite?`;
this.log(`sync (${reason}): ${this.lastError}`);
return undefined;
}
const bytes = fs.readFileSync(location.file);
const fp = fingerprint(bytes, config);
if (this.cache.lastSync?.fingerprint === fp) {
this.lastError = undefined;
this.log(
`sync (${reason}): no changes (${this.cache.lastSync.count} title(s))`,
);
return this.lastReport;
}
const exp = readExport(location.file);
const { entries, report } = computeEntries({
exp,
config,
platform: this.platform,
artFor: (g: ExportedGame) => buildArtwork(g, config.art),
});
const finalEntries = config.devEntry
? [...entries, devEntry(this.platform)]
: entries;
await reconcileProvider(this.pf, finalEntries);
this.cache.lastSync = {
fingerprint: fp,
count: finalEntries.length,
at: Date.now(),
};
saveCache(this.cache);
this.lastReport = report;
this.lastError = undefined;
this.log(
`sync (${reason}): reconciled ${finalEntries.length} title(s) (${report.skipped.length} skipped)`,
);
this.logReport(report);
return report;
} catch (e) {
this.lastError = e instanceof ExportError ? e.message : String(e);
this.log(`sync (${reason}) failed: ${this.lastError}`);
return undefined;
} finally {
this.syncing = false;
this.notify();
if (this.pending) {
this.pending = false;
queueMicrotask(() => void this.sync("coalesced"));
}
}
}
private logReport(report: SyncReport): void {
const bySource = Object.entries(report.perSource)
.sort((a, b) => b[1] - a[1])
.map(([s, n]) => `${s}:${n}`)
.join(" ");
if (bySource) this.log(` sources — ${bySource}`);
for (const w of report.warnings.slice(0, 5)) this.log(` ${w}`);
}
/** Reconfigure timers/watchers after a config change (e.g. the UI saved new filters). */
async reconfigure(): Promise<void> {
this.schedulePoll();
this.setupWatchers();
await this.sync("config-change");
}
/** Remove every entry this provider owns (CLI `uninstall`). */
async uninstall(): Promise<void> {
await deleteProvider(this.pf);
this.cache.lastSync = undefined;
saveCache(this.cache);
this.log("uninstalled: provider entries removed");
}
// ---------------------------------------------------------------- status
subscribe(cb: (status: EngineStatus) => void): () => void {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
private notify(): void {
const status = this.status();
for (const cb of this.listeners) {
try {
cb(status);
} catch {
// a dead SSE listener must not break sync
}
}
}
status(): EngineStatus {
return {
platform: this.platform,
location: this.lastLocation,
exportError: this.lastError,
lastSync: this.cache.lastSync,
lastReport: this.lastReport,
paths: statePaths(),
syncing: this.syncing,
};
}
}
-55
View File
@@ -1,55 +0,0 @@
// The contract between the two halves of this plugin. The C# Playnite exporter (see `exporter/`)
// writes a `punktfunk-library.json` in this exact shape into its Playnite plugin-data directory on
// every library change; this TypeScript plugin reads it and reconciles it into the host library.
//
// Keep this in lockstep with `exporter/PunktfunkSyncPlugin.cs` — the C# `LibraryExport`/`ExportedGame`
// DTOs serialise to these property names. Bump `SCHEMA` on any breaking change and gate on it in the
// reader; the exporter stamps the version it wrote.
/** The file the exporter writes (inside `…/Playnite/ExtensionsData/<pluginId>/`). */
export const EXPORT_FILE = "punktfunk-library.json";
/** Schema version this reader understands. The exporter stamps `schema`; a newer major is refused. */
export const SCHEMA = 1;
/** One game as the Playnite exporter sees it. Art fields are ABSOLUTE paths on the host box (already
* resolved from Playnite's database-relative form via `IPlayniteAPI.Database.GetFullFilePath`). */
export interface ExportedGame {
/** Playnite's stable game `Guid` (lowercase, no braces) — our reconcile `external_id`. */
id: string;
name: string;
/** Whether Playnite considers the game installed (drives the default `installedOnly` filter). */
installed: boolean;
/** Playnite's per-game "Hidden" flag. */
hidden: boolean;
/** The library source name ("Steam", "GOG", "Epic", "Xbox", emulator name, …) or null. */
source: string | null;
/** Platform display names ("PC (Windows)", "Nintendo Switch", …). */
platforms: string[];
/** Absolute path to the cover image, or null. */
cover: string | null;
/** Absolute path to the background image, or null. */
background: string | null;
/** Absolute path to the icon, or null. */
icon: string | null;
}
/** The whole export document. */
export interface LibraryExport {
schema: number;
/** ISO-8601 timestamp the exporter wrote this. */
generatedAt: string;
playnite: {
version: string | null;
/** "Desktop" | "Fullscreen" — informational. */
mode: string | null;
};
games: ExportedGame[];
}
/** A cheap structural check — enough to reject a truncated/foreign file before we trust it. */
export const isLibraryExport = (v: unknown): v is LibraryExport => {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return typeof o.schema === "number" && Array.isArray(o.games);
};
-20
View File
@@ -1,20 +0,0 @@
// The host side of the reconcile. We PUT the declarative entry list to the provider endpoint and let
// the host diff by `external_id` (stable ids, orphan drop, manual entries untouched). Done through
// `pf.request` rather than the typed `pf.api.*` deliberately: under the packaged runner the facade is
// built by the runner's *bundled* SDK copy, whose generated client may predate an endpoint — the
// untyped request seam is version-skew-proof (same reasoning as `servePluginUi`).
import type { Punktfunk } from "@punktfunk/host";
import { PROVIDER_ID } from "./const.js";
import type { ProviderEntryInput } from "./wire.js";
/** Full-replace reconcile: the host makes the library match `entries` exactly for this provider. */
export const reconcileProvider = (
pf: Punktfunk,
entries: ProviderEntryInput[],
): Promise<unknown> =>
pf.request("PUT", `/library/provider/${PROVIDER_ID}`, entries);
/** Clean uninstall: remove every entry this provider owns. */
export const deleteProvider = (pf: Punktfunk): Promise<unknown> =>
pf.request("DELETE", `/library/provider/${PROVIDER_ID}`);
-63
View File
@@ -1,63 +0,0 @@
// The plugin entry. `main` is the plain-async form (NOT the Effect form): the packaged runner bundles
// its own effect/SDK copy and cross-instance Effect identity is unverified, so the async facade
// sidesteps it entirely. The runner supervises this with restart-on-crash and a structured SIGTERM
// shutdown; a direct `bun src/index.ts` run works too (bottom of file).
//
// Lifecycle: connect (done by the runner/facade) → start the sync engine → serve the UI (console-hosted
// by default, standalone if configured) → run until SIGTERM → deregister the UI and stop the engine.
// Provider entries are deliberately LEFT in the library on shutdown so the games survive plugin
// restarts and host reboots; a clean uninstall is the explicit `uninstall` CLI command.
import { connect, definePlugin, type Punktfunk } from "@punktfunk/host";
import { PLUGIN_NAME } from "./const.js";
import { Engine } from "./engine/index.js";
import { log } from "./log.js";
import { serveConsoleUi } from "./server/index.js";
import { serveStandaloneUi } from "./server/standalone.js";
import { loadConfig } from "./state.js";
import { readVersion } from "./version.js";
const plugin = definePlugin({
name: PLUGIN_NAME,
main: async (pf: Punktfunk) => {
const engine = new Engine({ pf });
const config = loadConfig();
// Headless engine first — the library syncs even if the UI can't start.
await engine.start();
let closeUi: () => Promise<void> = async () => {};
try {
if (config.ui.standalone) {
const handle = serveStandaloneUi(engine, config);
closeUi = handle.close;
} else {
const handle = await serveConsoleUi(pf, engine, {
version: readVersion(),
});
closeUi = handle.close;
log(`UI registered with the console (nav entry "${PLUGIN_NAME}")`);
}
} catch (e) {
log(`UI server failed to start (engine keeps running headless): ${e}`);
}
await new Promise<void>((resolve) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
log("shutting down");
await closeUi();
engine.stop();
},
});
export default plugin;
// Allow a direct `bun src/index.ts` run outside the managed runner (dev).
if (import.meta.main) {
const pf = await connect();
await (plugin.main as (pf: Punktfunk) => Promise<void>)(pf);
pf.close();
}
-10
View File
@@ -1,10 +0,0 @@
// A tiny stamped logger, matching the runner's line format so the plugin's output reads consistently
// in the runner journal.
export type Logger = (line: string) => void;
export const makeLogger = (prefix = "playnite"): Logger => {
return (line: string) =>
console.log(`${new Date().toISOString()} [${prefix}] ${line}`);
};
export const log: Logger = makeLogger();
-75
View File
@@ -1,75 +0,0 @@
// Where the plugin's own files live. The host config-dir resolution mirrors the SDK's `configDir`
// (`@punktfunk/host` does not re-export it) so we always land in the same place the host and runner
// use; the plugin owns a `plugin-state/playnite/` subtree under it, created 0700.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
/** The host's config dir — the same resolution the host and SDK use. */
export const hostConfigDir = (): string => {
const explicit = process.env.PUNKTFUNK_CONFIG_DIR;
if (explicit) return explicit;
if (process.platform === "win32") {
const base = process.env.ProgramData ?? process.env.APPDATA ?? ".";
return path.join(base, "punktfunk");
}
const base =
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
return path.join(base, "punktfunk");
};
/**
* This plugin's private directory: `<config_dir>/plugin-state/playnite`.
*
* The state lives under `plugin-state/`, not straight under the config dir, because the managed
* runner is de-privileged on Windows (`NT AUTHORITY\LocalService`) and the config dir is locked
* read-only there — `punktfunk-host plugins enable` grants the runner write on exactly
* `plugin-state`. (Mirrors `@punktfunk/host`'s `pluginStateDir`; reimplemented locally like
* `hostConfigDir`, since we don't gate on an SDK version bump.) On Linux the runner owns the config
* dir, so the same path is writable with no special step.
*/
export const pluginDir = (): string =>
path.join(hostConfigDir(), "plugin-state", "playnite");
/** `<config_dir>/plugin-state/playnite/config.json` — operator-editable, atomic-written. */
export const configPath = (): string => path.join(pluginDir(), "config.json");
/** `<config_dir>/plugin-state/playnite/cache.json` — disposable derived state (last fingerprint). */
export const cachePath = (): string => path.join(pluginDir(), "cache.json");
/**
* The cross-account **ingest inbox** the Playnite exporter drops the library export into:
* `<config_dir>/ingest/playnite`. (Mirrors `@punktfunk/host`'s `pluginIngestDir`.)
*
* The exporter runs inside Playnite as the interactive user; the de-privileged LocalService runner
* cannot read that user's `%APPDATA%\Playnite\ExtensionsData\…`. `punktfunk-host plugins enable`
* makes `ingest` user-writable, so the exporter drops the file here for the runner to read. Read
* this BEFORE the ExtensionsData scan (which only works for a same-user/SYSTEM runner or on Linux).
*/
export const ingestDir = (): string =>
path.join(hostConfigDir(), "ingest", "playnite");
/**
* Locate the built SPA directory (`dist/ui`) at runtime. The package ships as ONE self-contained
* bundle so it installs from the Gitea registry with no external deps — which means `import.meta.url`
* can be `dist/index.js` (bundled) or `dist/server/index.js` (unbundled dev). We walk up from the
* calling module looking for a `ui/index.html`, so both layouts resolve. `fromUrl` is the caller's
* `import.meta.url`.
*/
export const resolveUiDir = (fromUrl: string): string => {
let dir = path.dirname(fileURLToPath(fromUrl));
for (let i = 0; i < 6; i++) {
for (const cand of [path.join(dir, "ui"), path.join(dir, "dist", "ui")]) {
try {
if (fs.existsSync(path.join(cand, "index.html"))) return cand;
} catch {
// keep walking
}
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return path.join(path.dirname(fileURLToPath(fromUrl)), "ui");
};
-162
View File
@@ -1,162 +0,0 @@
// Locating and reading the exporter's output. The companion Playnite extension (`exporter/`) writes
// `punktfunk-library.json` into its own Playnite plugin-data directory, i.e.
// `<PlayniteData>/ExtensionsData/<pluginId-guid>/punktfunk-library.json`. We don't hardcode that guid
// — we glob `ExtensionsData/*/punktfunk-library.json` and take the newest, so the reader is decoupled
// from the exporter's id.
//
// The plugin runs on the same box as Playnite (it's the streaming host), so these are plain local
// reads. The wrinkle is *which account* can see the file. The managed runner is de-privileged
// (`NT AUTHORITY\LocalService`) and cannot read the interactive user's `%APPDATA%`, so the reliable
// source is the **ingest inbox** (`<config_dir>/ingest/playnite/punktfunk-library.json`) the
// exporter drops the file into — checked FIRST. The `%APPDATA%`/`C:\Users\*` ExtensionsData scan
// still works for a same-user/SYSTEM runner or on Linux, and an explicit `playniteDir` override
// joins the candidate list.
import * as fs from "node:fs";
import * as path from "node:path";
import type { Config } from "./config.js";
import {
EXPORT_FILE,
isLibraryExport,
type LibraryExport,
SCHEMA,
} from "./export-schema.js";
import { ingestDir } from "./paths.js";
const exists = (p: string): boolean => {
try {
fs.accessSync(p);
return true;
} catch {
return false;
}
};
/** Candidate Playnite data directories, most-specific first (config override handled by the caller). */
export const candidatePlayniteDirs = (): string[] => {
const out: string[] = [];
if (process.platform === "win32") {
const appdata = process.env.APPDATA;
if (appdata) out.push(path.join(appdata, "Playnite"));
// SYSTEM/other-user runner: scan every local profile's roaming AppData.
const usersRoot = path.join(process.env.SystemDrive ?? "C:", "\\", "Users");
try {
for (const user of fs.readdirSync(usersRoot)) {
out.push(path.join(usersRoot, user, "AppData", "Roaming", "Playnite"));
}
} catch {
// no C:\Users (or not Windows-like) — fine
}
} else {
// Playnite is Windows-only, but a Wine/portable layout may set these — best effort.
const home = process.env.HOME;
if (home) {
out.push(path.join(home, ".local", "share", "Playnite"));
}
}
// De-dupe, preserve order.
return [...new Set(out)];
};
export interface Location {
/** The resolved Playnite data dir, or null if none found. */
dir: string | null;
/** The candidate dirs we considered (for the UI's diagnostics view). */
candidates: string[];
/** The resolved export file, or null if the exporter hasn't written one yet. */
file: string | null;
/** mtime (epoch ms) of the export file, or null. */
mtime: number | null;
}
/** Find the newest `punktfunk-library.json` under a Playnite dir's `ExtensionsData`. */
const findExportUnder = (
dir: string,
): { file: string; mtime: number } | null => {
const base = path.join(dir, "ExtensionsData");
let best: { file: string; mtime: number } | null = null;
let subdirs: string[];
try {
subdirs = fs.readdirSync(base);
} catch {
return null;
}
for (const sub of subdirs) {
const file = path.join(base, sub, EXPORT_FILE);
try {
const st = fs.statSync(file);
if (!best || st.mtimeMs > best.mtime) best = { file, mtime: st.mtimeMs };
} catch {
// no export in this extension's dir
}
}
return best;
};
/** Resolve where the export lives. `config.playniteDir` wins; otherwise probe the candidates. */
export const locateExport = (config: Config): Location => {
const override = config.playniteDir;
const candidates = override
? [override, ...candidatePlayniteDirs()]
: candidatePlayniteDirs();
// The ingest inbox first: the exporter drops the library here (a flat file, not an
// `ExtensionsData/<guid>` tree) so the de-privileged LocalService runner can read it — it can't
// reach the interactive user's `%APPDATA%`. This is the reliable path on a managed Windows host.
const inbox = ingestDir();
const inboxFile = path.join(inbox, EXPORT_FILE);
try {
const st = fs.statSync(inboxFile);
return {
dir: inbox,
candidates: [inbox, ...candidates],
file: inboxFile,
mtime: st.mtimeMs,
};
} catch {
// no ingest drop yet — fall back to scanning Playnite's own data dirs
}
// Then, a Playnite data dir that actually holds an export file (same-user/SYSTEM runner, Linux).
for (const dir of candidates) {
const hit = findExportUnder(dir);
if (hit)
return {
dir,
candidates: [inbox, ...candidates],
file: hit.file,
mtime: hit.mtime,
};
}
// Else, the first dir that at least exists (so the UI can say "Playnite found, exporter not
// installed yet" vs "Playnite not found").
const dir = candidates.find(exists) ?? null;
return { dir, candidates: [inbox, ...candidates], file: null, mtime: null };
};
export class ExportError extends Error {}
/** Read + validate the export file. Throws `ExportError` on a missing/corrupt/too-new file. */
export const readExport = (file: string): LibraryExport => {
let raw: string;
try {
raw = fs.readFileSync(file, "utf8");
} catch (e) {
throw new ExportError(`cannot read ${file}: ${e}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new ExportError(`${file} is not valid JSON: ${e}`);
}
if (!isLibraryExport(parsed)) {
throw new ExportError(`${file} is not a punktfunk library export`);
}
if (Math.floor(parsed.schema) > SCHEMA) {
throw new ExportError(
`${file} is schema ${parsed.schema}; this plugin understands up to ${SCHEMA} — update the plugin`,
);
}
return parsed;
};
-30
View File
@@ -1,30 +0,0 @@
// The console-hosted UI server (primary path). `servePluginUi` from the SDK owns the whole plugin side
// — a loopback ephemeral port behind a per-boot secret, registration + lease renewal with the host,
// and the console reverse-proxy contract — so we just hand it the built SPA directory and the
// plugin-local API router. The operator signs into the console once; the "Playnite" nav entry appears
// automatically. Zero human auth here.
import {
type PluginUiHandle,
type Punktfunk,
servePluginUi,
} from "@punktfunk/host";
import { PLUGIN_NAME, UI_ICON, UI_TITLE } from "../const.js";
import type { Engine } from "../engine/index.js";
import { resolveUiDir } from "../paths.js";
import { makeRouter } from "./router.js";
/** Serve the UI through the console. The SPA dir resolves whether we run bundled or from source. */
export const serveConsoleUi = (
pf: Punktfunk,
engine: Engine,
opts?: { version?: string },
): Promise<PluginUiHandle> =>
servePluginUi(pf, {
id: PLUGIN_NAME,
title: UI_TITLE,
icon: UI_ICON,
version: opts?.version,
staticDir: resolveUiDir(import.meta.url),
fetch: makeRouter(engine),
});
-30
View File
@@ -1,30 +0,0 @@
// scrypt password hashing for the standalone fallback UI. Format: `scrypt$<saltB64url>$<hashB64url>`.
// Verification is constant-time. Only used by the standalone server; the console-hosted path has no
// password of its own.
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
const KEYLEN = 32;
/** Hash a password for storage in `config.ui.passwordHash`. */
export const hashPassword = (password: string): string => {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, KEYLEN);
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
};
/** Constant-time verify a password against a stored `scrypt$...` hash. */
export const verifyPassword = (password: string, stored: string): boolean => {
const parts = stored.split("$");
if (parts.length !== 3 || parts[0] !== "scrypt" || !parts[1] || !parts[2])
return false;
const salt = Buffer.from(parts[1], "base64url");
const expected = Buffer.from(parts[2], "base64url");
let actual: Buffer;
try {
actual = scryptSync(password, salt, expected.length);
} catch {
return false;
}
return actual.length === expected.length && timingSafeEqual(actual, expected);
};
-91
View File
@@ -1,91 +0,0 @@
// The plugin-local REST/SSE API — a pure `(Request) => Response | undefined` the UI talks to. Paths
// arrive prefix-stripped (the console proxy already removed `/plugin-ui/playnite`), so we match
// `/api/...` directly; a non-`/api` path returns `undefined` to fall through to the static SPA. The
// same router backs both the console-proxied server and the standalone fallback.
import { type RawConfig, resolveConfig } from "../config.js";
import type { Engine, EngineStatus } from "../engine/index.js";
import { loadConfig, saveRawConfig } from "../state.js";
const json = (data: unknown, status = 200): Response =>
Response.json(data, { status });
/** A Server-Sent-Events stream that pushes the engine status on every change. */
const sse = (engine: Engine): Response => {
const enc = new TextEncoder();
let unsubscribe = () => {};
let ping: ReturnType<typeof setInterval> | undefined;
const stream = new ReadableStream({
start(controller) {
const send = (status: EngineStatus) => {
try {
controller.enqueue(
enc.encode(`event: status\ndata: ${JSON.stringify(status)}\n\n`),
);
} catch {
// stream already closed
}
};
send(engine.status());
unsubscribe = engine.subscribe(send);
ping = setInterval(() => {
try {
controller.enqueue(enc.encode(": ping\n\n"));
} catch {
// ignore
}
}, 15_000);
(ping as { unref?: () => void }).unref?.();
},
cancel() {
unsubscribe();
if (ping) clearInterval(ping);
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
},
});
};
/** Build the plugin-local API router bound to an engine. */
export const makeRouter =
(engine: Engine) =>
async (req: Request): Promise<Response | undefined> => {
const { pathname } = new URL(req.url);
const method = req.method;
if (!pathname.startsWith("/api/")) return undefined; // static SPA handles it
try {
if (pathname === "/api/status" && method === "GET") {
return json(engine.status());
}
if (pathname === "/api/config" && method === "GET") {
return json(loadConfig());
}
if (pathname === "/api/config" && method === "PUT") {
const body = (await req.json()) as RawConfig;
// Persist the authored shape; re-resolve so the response echoes defaults.
saveRawConfig(body);
const resolved = resolveConfig(body);
await engine.reconfigure();
return json(resolved);
}
if (pathname === "/api/preview" && method === "GET") {
return json(engine.computeDry());
}
if (pathname === "/api/sync" && method === "POST") {
const report = await engine.sync("ui");
return report ? json(report) : json({ status: engine.status() }, 200);
}
if (pathname === "/api/events" && method === "GET") {
return sse(engine);
}
return json({ error: "not found" }, 404);
} catch (e) {
return json({ error: String(e) }, 500);
}
};
-150
View File
@@ -1,150 +0,0 @@
// The standalone fallback UI server: for host-only installs without the console, or as insurance if
// the console surface is unavailable. Serves the SAME SPA + router, but on a fixed port with its own
// password/session auth instead of the console proxy. Fail-closed: a non-loopback bind REQUIRES a
// password (a UI that can rewrite the config — and thus the launch commands — is host-user code).
import * as nodePath from "node:path";
import type { Config } from "../config.js";
import type { Engine } from "../engine/index.js";
import { log } from "../log.js";
import { resolveUiDir } from "../paths.js";
import { verifyPassword } from "./password.js";
import { makeRouter } from "./router.js";
export interface StandaloneHandle {
url: string;
close(): Promise<void>;
}
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
const COOKIE = "pf_playnite_session";
const loginPage = (error?: string): Response =>
new Response(
`<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>Playnite · Punktfunk</title>
<style>body{font:15px system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0b0b0f;color:#e5e7eb}
form{display:grid;gap:.75rem;width:260px}input{padding:.5rem;border-radius:.5rem;border:1px solid #333;background:#111;color:#eee}
button{padding:.5rem;border-radius:.5rem;border:0;background:#6c5bf3;color:#fff;cursor:pointer}.e{color:#f87171;font-size:13px}</style>
<form method=post action="./login"><h1>Playnite Sync</h1>${error ? `<div class=e>${error}</div>` : ""}
<input type=password name=password placeholder="Password" autofocus><button>Sign in</button></form>`,
{
status: error ? 401 : 200,
headers: { "content-type": "text/html; charset=utf-8" },
},
);
/** Resolve a request path to a file inside `root`, or null on traversal (mirrors servePluginUi). */
const staticFile = (root: string, pathname: string): string | null => {
let rel: string;
try {
rel = decodeURIComponent(pathname);
} catch {
return null;
}
if (rel.endsWith("/")) rel += "index.html";
if (!rel.startsWith("/")) rel = `/${rel}`;
const abs = nodePath.resolve(root, `.${rel}`);
const rootAbs = nodePath.resolve(root);
if (abs !== rootAbs && !abs.startsWith(rootAbs + nodePath.sep)) return null;
return abs;
};
const cookieHas = (req: Request, name: string, value: string): boolean => {
const raw = req.headers.get("cookie") ?? "";
return raw.split(/;\s*/).some((c) => c === `${name}=${value}`);
};
/** Serve the standalone UI. Throws (fail-closed) if a non-loopback bind lacks a password. */
export const serveStandaloneUi = (
engine: Engine,
config: Config,
): StandaloneHandle => {
const { port, bind, passwordHash } = config.ui;
const isLoopback = LOOPBACK.has(bind);
if (!isLoopback && !passwordHash) {
throw new Error(
`standalone UI refuses to bind ${bind} without a password (set ui.passwordHash via \`punktfunk-plugin-playnite set-password\`, or bind 127.0.0.1)`,
);
}
const authRequired = Boolean(passwordHash);
const sessionToken = crypto.randomUUID().replace(/-/g, "");
const root = resolveUiDir(import.meta.url);
const router = makeRouter(engine);
const authed = (req: Request) =>
!authRequired || cookieHas(req, COOKIE, sessionToken);
const server = Bun.serve({
hostname: bind,
port,
async fetch(req) {
const { pathname } = new URL(req.url);
if (pathname === "/__health") return Response.json({ ok: true });
if (authRequired && pathname === "/login" && req.method === "POST") {
const form = await req.formData().catch(() => null);
const password = form?.get("password");
if (
typeof password === "string" &&
passwordHash &&
verifyPassword(password, passwordHash)
) {
return new Response(null, {
status: 303,
headers: {
location: "./",
"set-cookie": `${COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
},
});
}
return loginPage("Incorrect password");
}
if (pathname === "/logout" && req.method === "POST") {
return new Response(null, {
status: 303,
headers: {
location: "./",
"set-cookie": `${COOKIE}=; Max-Age=0; Path=/`,
},
});
}
if (!authed(req)) {
if (pathname.startsWith("/api/"))
return Response.json({ error: "unauthorized" }, { status: 401 });
return loginPage();
}
// 1) plugin-local API
const api = await router(req);
if (api) return api;
// 2) static asset
const file = staticFile(root, pathname);
if (file) {
const bf = Bun.file(file);
if (await bf.exists()) return new Response(bf);
}
// 3) SPA fallback
if (
req.method === "GET" &&
(req.headers.get("accept") ?? "").includes("text/html")
) {
const index = Bun.file(nodePath.join(root, "index.html"));
if (await index.exists()) return new Response(index);
}
return new Response("not found", { status: 404 });
},
});
const url = `http://${isLoopback ? "127.0.0.1" : bind}:${server.port}`;
log(
`standalone UI on ${url}${authRequired ? " (password-protected)" : " (loopback, no password)"}`,
);
return {
url,
async close() {
server.stop(true);
},
};
};
-90
View File
@@ -1,90 +0,0 @@
// Config/cache persistence: the plugin owns two files under `<config_dir>/playnite/`, created 0700.
// `config.json` is operator-editable and atomically rewritten (temp + rename); the plugin refuses a
// group/world-writable `config.json` (the same sshd rule the runner and hooks enforce, since the UI
// can rewrite it and the launch commands it produces run as the host user). `cache.json` is disposable
// derived state.
import * as fs from "node:fs";
import * as path from "node:path";
import { type Config, type RawConfig, resolveConfig } from "./config.js";
import { cachePath, configPath, pluginDir } from "./paths.js";
export interface Cache {
/** Fingerprint + count of the last reconcile — lets a re-read skip an unchanged PUT. */
lastSync?: { fingerprint: string; count: number; at: number };
}
export const emptyCache = (): Cache => ({});
/** Create the plugin dir 0700 if absent (idempotent). */
const ensureDir = (): void => {
fs.mkdirSync(pluginDir(), { recursive: true, mode: 0o700 });
};
/** Refuse a group/world-writable config file (POSIX only; Windows config dir is DACL'd). */
const assertNotWorldWritable = (file: string): void => {
if (process.platform === "win32") return;
let mode: number;
try {
mode = fs.statSync(file).mode;
} catch {
return; // absent — nothing to guard
}
if ((mode & 0o022) !== 0) {
throw new Error(
`refusing ${file}: it is group/world-writable (chmod go-w it first) — this file controls commands run as the host user`,
);
}
};
/** Atomic write: temp file in the same dir, then rename. */
const atomicWrite = (file: string, data: string): void => {
ensureDir();
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, data, { mode: 0o600 });
fs.renameSync(tmp, file);
};
/** Load the authored config (defaults filled). A missing file yields the empty config. */
export const loadConfig = (): Config => {
const file = configPath();
let raw = "";
try {
raw = fs.readFileSync(file, "utf8");
} catch {
return resolveConfig({});
}
assertNotWorldWritable(file);
const parsed = JSON.parse(raw) as RawConfig;
return resolveConfig(parsed);
};
/** Persist a config (atomic). */
export const saveConfig = (config: Config): void => {
atomicWrite(configPath(), `${JSON.stringify(config, null, 2)}\n`);
};
/** Save the raw (operator-authored) config verbatim — the shape the UI edits. */
export const saveRawConfig = (raw: RawConfig): void => {
atomicWrite(configPath(), `${JSON.stringify(raw, null, 2)}\n`);
};
export const loadCache = (): Cache => {
try {
return JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache;
} catch {
return emptyCache();
}
};
export const saveCache = (cache: Cache): void => {
atomicWrite(cachePath(), JSON.stringify(cache));
};
/** Absolute paths, exported for the UI/CLI status view. */
export const statePaths = () => ({
dir: pluginDir(),
config: configPath(),
cache: cachePath(),
relConfig: path.basename(configPath()),
});
-16
View File
@@ -1,16 +0,0 @@
// Best-effort read of the plugin's own version from package.json (shown in the console page header).
// `../package.json` resolves to the repo root whether this module runs from `src/` or `dist/`.
import * as fs from "node:fs";
import { fileURLToPath } from "node:url";
export const readVersion = (): string | undefined => {
try {
const file = fileURLToPath(new URL("../package.json", import.meta.url));
const pkg = JSON.parse(fs.readFileSync(file, "utf8")) as {
version?: string;
};
return pkg.version;
} catch {
return undefined;
}
};
-34
View File
@@ -1,34 +0,0 @@
// The host provider-reconcile wire shapes (mirrors `@punktfunk/host`'s generated `ProviderEntryInput`
// et al. — the facade doesn't re-export them, and we PUT the raw array via `pf.request`, so we own the
// shape here). Kept minimal and stable; the host validates it.
/** Cover art — all URLs (or `data:` URLs). The console/clients prefer `portrait` for a grid. */
export interface Artwork {
portrait?: string | null;
hero?: string | null;
logo?: string | null;
header?: string | null;
}
/** How the host launches a title. For this plugin always `{ kind: "command", value: <shell command> }`
* — the host wraps it as `cmd.exe /c <value>` in the interactive user session (Windows). */
export interface LaunchSpec {
kind: "command";
value: string;
}
/** A per-title prep/undo step: `do` runs before launch, `undo` at session end (reverse order). */
export interface PrepCmd {
do: string;
undo?: string | null;
}
/** One title in the declarative reconcile payload (`PUT /api/v1/library/provider/playnite`). */
export interface ProviderEntryInput {
/** The provider's stable id for this title — the reconcile diff key (Playnite's game Guid). */
external_id: string;
title: string;
art?: Artwork;
launch?: LaunchSpec | null;
prep?: PrepCmd[];
}
-36
View File
@@ -1,36 +0,0 @@
// State persistence lands under `<config_dir>/plugin-state/playnite` — the one dir the
// de-privileged Windows runner (LocalService) may write. A regression here (writing straight under
// the config dir) would EPERM under the runner and lose the operator's config.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { loadConfig, saveRawConfig } from "../src/state.js";
let root: string;
let saved: string | undefined;
beforeEach(() => {
saved = process.env.PUNKTFUNK_CONFIG_DIR;
root = fs.mkdtempSync(path.join(os.tmpdir(), "pn-state-"));
process.env.PUNKTFUNK_CONFIG_DIR = root;
});
afterEach(() => {
if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = saved;
fs.rmSync(root, { recursive: true, force: true });
});
describe("state location", () => {
test("persists under plugin-state/playnite and round-trips", () => {
saveRawConfig({ playniteDir: "/games/playnite" });
expect(
fs.existsSync(path.join(root, "plugin-state", "playnite", "config.json")),
).toBe(true);
// Not written straight under the config dir (the LocalService-unwritable location).
expect(fs.existsSync(path.join(root, "playnite", "config.json"))).toBe(
false,
);
expect(loadConfig().playniteDir).toBe("/games/playnite");
});
});
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"moduleResolution": "bundler"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "test", "ui", "dist", "node_modules"]
}
-342
View File
@@ -1,342 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "punktfunk-plugin-playnite-ui",
"dependencies": {
"lucide-react": "^0.469.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.3.2",
"typescript": "^5.9.3",
"vite": "^7.0.0",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="],
"browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="],
"enhanced-resolve": ["enhanced-resolve@5.24.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24 -8
View File
@@ -2,24 +2,40 @@
"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 — 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"
},
"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",
"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"
}
}
-424
View File
@@ -1,424 +0,0 @@
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";
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`;
};
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>
);
}
function Toggle({
label,
hint,
checked,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4 py-2">
<span>
<span className="block text-sm">{label}</span>
{hint && <span className="block text-xs text-muted">{hint}</span>}
</span>
<button
type="button"
onClick={() => onChange(!checked)}
className={`mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? "bg-brand" : "bg-elevated"}`}
>
<span
className={`block h-5 w-5 rounded-full bg-white transition-transform ${checked ? "translate-x-5" : "translate-x-0.5"}`}
/>
</button>
</label>
);
}
const inputCls =
"w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-brand";
export function App() {
const status = useStatusStream();
const [config, setConfig] = useState<Config>();
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [msg, setMsg] = useState<string>();
useEffect(() => {
getConfig()
.then(setConfig)
.catch((e) => setMsg(String(e)));
}, []);
const patch = (p: Partial<Config>) =>
setConfig((c) => (c ? { ...c, ...p } : c));
const save = async () => {
if (!config) return;
setSaving(true);
setMsg(undefined);
try {
setConfig(await putConfig(config));
setMsg("Saved — re-syncing with the new settings.");
} catch (e) {
setMsg(String(e));
} finally {
setSaving(false);
}
};
const sync = async () => {
setSyncing(true);
setMsg(undefined);
try {
await runSync();
setMsg("Sync requested.");
} catch (e) {
setMsg(String(e));
} finally {
setSyncing(false);
}
};
return (
<div className="mx-auto max-w-3xl px-5 py-8">
<header className="mb-6 flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-brand text-brand-fg">
<Gamepad2 size={22} />
</span>
<div>
<h1 className="text-xl font-semibold">Playnite</h1>
<p className="text-xs text-muted">
Sync your Playnite library into the Punktfunk game library.
</p>
</div>
</header>
{status ? (
<div className="grid gap-5">
<Connection status={status} />
<LibrarySummary status={status} onSync={sync} syncing={syncing} />
{config && (
<Settings
config={config}
patch={patch}
onSave={save}
saving={saving}
/>
)}
</div>
) : (
<p className="text-sm text-muted">Loading</p>
)}
{msg && (
<p className="mt-5 rounded-lg border border-border bg-surface px-4 py-2 text-sm text-muted">
{msg}
</p>
)}
</div>
);
}
function Connection({ status }: { status: Status }) {
const found = Boolean(status.location.file);
return (
<Card title="Playnite connection" icon={<FolderSearch size={16} />}>
{found ? (
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 shrink-0 text-ok" size={18} />
<div className="min-w-0 text-sm">
<p>
Exporter connected updated{" "}
<span className="text-fg">
{relTime(status.location.mtime ?? undefined)}
</span>
.
</p>
<p className="mt-1 truncate font-mono text-xs text-muted">
{status.location.file}
</p>
</div>
</div>
) : (
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 shrink-0 text-warn" size={18} />
<div className="text-sm">
<p>
No exporter output found. Install the{" "}
<span className="text-fg">Punktfunk Sync</span> extension in
Playnite (then restart Playnite).
</p>
{status.exportError && (
<p className="mt-1 text-xs text-muted">{status.exportError}</p>
)}
<p className="mt-2 text-xs text-muted">
Looked under:{" "}
{status.location.dir ?? "no Playnite data dir found"}
</p>
</div>
</div>
)}
</Card>
);
}
function LibrarySummary({
status,
onSync,
syncing,
}: {
status: Status;
onSync: () => void;
syncing: boolean;
}) {
const report = status.lastReport;
const busy = syncing || status.syncing;
const sources = report
? Object.entries(report.perSource).sort((a, b) => b[1] - a[1])
: [];
return (
<Card
title="Library"
icon={<Library size={16} />}
right={
<button
type="button"
onClick={onSync}
disabled={busy}
className="flex items-center gap-2 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>
{busy ? (
<Loader2 className="animate-spin" size={15} />
) : (
<RefreshCw size={15} />
)}
Sync now
</button>
}
>
<div className="flex items-baseline gap-2">
<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
View File
@@ -1,108 +0,0 @@
// Typed client for the plugin-local API. Relative paths — the SPA is mounted under the console proxy
// prefix, so `api/...` resolves to `/plugin-ui/playnite/api/...`. Types mirror `src/engine` +
// `src/config`.
import { useEffect, useState } from "react";
export interface Location {
dir: string | null;
candidates: string[];
file: string | null;
mtime: number | null;
}
export interface Skipped {
id: string;
title: string;
reason: string;
}
export interface Report {
generatedAt: string;
considered: number;
included: number;
skipped: Skipped[];
warnings: string[];
perSource: Record<string, number>;
}
export interface LastSync {
fingerprint: string;
count: number;
at: number;
}
export interface Status {
platform: string;
location: Location;
exportError?: string;
lastSync?: LastSync;
lastReport?: Report;
paths: { dir: string; config: string; cache: string; relConfig: string };
syncing: boolean;
}
export interface Config {
playniteDir?: string;
sync: { pollMinutes: number; watch: boolean; debounceMs: number };
filter: {
installedOnly: boolean;
includeHidden: boolean;
sources: string[];
excludeSources: string[];
};
art: {
mode: "dataurl" | "off";
maxBytes: number;
includeBackground: boolean;
};
ui: {
standalone: boolean;
port: number;
bind: string;
passwordHash?: string;
};
devEntry: boolean;
}
export interface Preview {
location: Location;
report?: Report;
error?: string;
}
const api = async <T>(path: string, opts?: RequestInit): Promise<T> => {
const res = await fetch(path, {
headers: { "content-type": "application/json" },
...opts,
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return (await res.json()) as T;
};
export const getStatus = () => api<Status>("api/status");
export const getConfig = () => api<Config>("api/config");
export const putConfig = (config: Config) =>
api<Config>("api/config", { method: "PUT", body: JSON.stringify(config) });
export const getPreview = () => api<Preview>("api/preview");
export const runSync = () => api<unknown>("api/sync", { method: "POST" });
/** Live engine status via SSE (falls back to a one-shot fetch if EventSource fails). */
export const useStatusStream = (): Status | undefined => {
const [status, setStatus] = useState<Status>();
useEffect(() => {
getStatus()
.then(setStatus)
.catch(() => {});
try {
const es = new EventSource("api/events");
es.addEventListener("status", (e) => {
try {
setStatus(JSON.parse((e as MessageEvent).data));
} catch {
// ignore malformed frame
}
});
return () => es.close();
} catch {
return;
}
}, []);
return status;
};
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
-41
View File
@@ -1,41 +0,0 @@
@import "tailwindcss";
@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;
}
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;
}
-16
View File
@@ -1,16 +0,0 @@
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()],
build: {
outDir: "../dist/ui",
emptyOutDir: true,
},
server: { port: 5601 },
});