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
+156
View File
@@ -0,0 +1,156 @@
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 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 => {
const p = path.join(dir, name);
fs.writeFileSync(p, bytes);
return p;
};
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",
installed: true,
hidden: false,
source: "Steam",
platforms: [],
cover: null,
background: null,
icon: null,
...over,
});
describe("fileToDataUrl", () => {
test("encodes a small png as a data URL", () => {
const p = write("cover.png", Buffer.from([1, 2, 3, 4]));
expect(fileToDataUrl(p, 1000)).toBe(
`data:image/png;base64,${Buffer.from([1, 2, 3, 4]).toString("base64")}`,
);
});
test("drops files over the size cap", () => {
const p = write("big.jpg", Buffer.alloc(2000, 7));
expect(fileToDataUrl(p, 1000)).toBeNull();
});
test("rejects unknown extensions and null", () => {
const p = write("weird.xyz", Buffer.from([1]));
expect(fileToDataUrl(p, 1000)).toBeNull();
expect(fileToDataUrl(null, 1000)).toBeNull();
});
test("mime follows the extension", () => {
const p = write("c.jpeg", Buffer.from([9]));
expect(fileToDataUrl(p, 1000)?.startsWith("data:image/jpeg;base64,")).toBe(
true,
);
});
test("a remote reference passes through untouched", () => {
expect(fileToDataUrl("https://cdn.example/cover.jpg", 10)).toBe(
"https://cdn.example/cover.jpg",
);
});
});
describe("buildArtwork", () => {
test("off mode yields no art", () => {
const cover = write("c1.png", Buffer.from([1]));
expect(
buildArtwork(game({ cover }), { ...DEFAULT_ART, mode: "off" }),
).toBeUndefined();
});
test("host mode (default) emits the raw cover path — no bytes", () => {
const cover = write("c2.png", Buffer.from([1, 2]));
const art = buildArtwork(game({ cover }), DEFAULT_ART);
expect(art?.portrait).toBe(cover);
expect(art?.hero).toBeUndefined();
});
test("host mode includeBackground adds the hero path", () => {
const cover = write("c2b.png", Buffer.from([1]));
const background = write("b2b.jpg", Buffer.from([2]));
const art = buildArtwork(game({ cover, background }), {
...DEFAULT_ART,
includeBackground: true,
});
expect(art?.portrait).toBe(cover);
expect(art?.hero).toBe(background);
});
test("host mode with no cover yields undefined", () => {
expect(buildArtwork(game({ cover: null }), DEFAULT_ART)).toBeUndefined();
});
test("dataurl mode embeds the cover as a portrait data URL", () => {
const cover = write("c3.png", Buffer.from([1, 2]));
const art = buildArtwork(game({ cover }), {
...DEFAULT_ART,
mode: "dataurl",
});
expect(art?.portrait?.startsWith("data:image/png;base64,")).toBe(true);
expect(art?.hero).toBeUndefined();
});
test("dataurl includeBackground adds a hero data URL", () => {
const cover = write("c4.png", Buffer.from([1]));
const background = write("b4.jpg", Buffer.from([2]));
const art = buildArtwork(game({ cover, background }), {
...DEFAULT_ART,
mode: "dataurl",
includeBackground: true,
});
expect(art?.portrait).toBeTruthy();
expect(art?.hero?.startsWith("data:image/jpeg;base64,")).toBe(true);
});
test("dataurl mode with no readable art yields undefined", () => {
expect(
buildArtwork(game({ cover: "/does/not/exist.png" }), {
...DEFAULT_ART,
mode: "dataurl",
}),
).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);
});
});