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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user