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
+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,
});
+8
View File
@@ -0,0 +1,8 @@
// 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";
/** A lucide icon name for the console nav entry. */
export const UI_ICON = "library-big";
+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}`,
);
}
};
+146
View File
@@ -0,0 +1,146 @@
// 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,
ExportedGame,
LibraryExport,
SyncReport,
} from "@playnite/contract";
import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
/** 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. */
export const launchCommand = (
id: string,
platform: NodeJS.Platform = process.platform,
): ProviderEntry["launch"] => {
const uri = `playnite://playnite/start/${id}`;
// 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 handler.
return { kind: "command", value: `xdg-open "${uri}"` };
};
const sourceKey = (g: ExportedGame): string => g.source ?? "(none)";
export interface ComputeInput {
readonly exp: LibraryExport;
readonly config: Config;
readonly platform?: NodeJS.Platform;
/** Art builder (IO lives in the engine; tests pass a stub). */
readonly artFor: (game: ExportedGame) => Artwork | undefined;
}
export interface ComputeResult {
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. `host`
* mode (the default) sends references, not bytes, so it has no such ceiling. */
const WARN_ENTRIES = 3000;
export const computeEntries = ({
exp,
config,
platform = process.platform,
artFor,
}: ComputeInput): ComputeResult => {
const { filter } = config;
const includeSources = new Set(filter.sources.map((s) => s.toLowerCase()));
const excludeSources = new Set(
filter.excludeSources.map((s) => s.toLowerCase()),
);
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 skip = (reason: string) =>
skipped.push({ id: g.id, title: title || g.id, reason });
if (!title) {
skip("no title");
continue;
}
if (!isGuid(g.id)) {
skip("invalid game id");
continue;
}
if (filter.installedOnly && !g.installed) {
skip("not installed");
continue;
}
if (!filter.includeHidden && g.hidden) {
skip("hidden in Playnite");
continue;
}
const src = (g.source ?? "").toLowerCase();
if (includeSources.size > 0 && !includeSources.has(src)) {
skip(`source "${g.source ?? "(none)"}" not in include list`);
continue;
}
if (excludeSources.has(src)) {
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 art = artFor(g);
entries.push({
external_id: g.id,
title,
launch: launchCommand(g.id, platform),
// `art` is an optionalKey — omit it, never set it to undefined.
...(art ? { art } : {}),
});
const key = sourceKey(g);
perSource[key] = (perSource[key] ?? 0) + 1;
}
// Stable order (by title) so the reconcile fingerprint is deterministic across reads.
entries.sort((a, b) => a.title.localeCompare(b.title));
if (config.art.mode === "dataurl" && entries.length > WARN_ENTRIES) {
warnings.push(
`${entries.length} entries with art.mode "dataurl" — inlined covers will exceed the host's reconcile body limit. Switch to art.mode "host" (the default), which serves covers by path.`,
);
}
return {
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;
});
}