feat: Playnite library sync plugin + exporter
CI / exporter (push) Failing after 24s
CI / build (push) Failing after 25s
CI / publish (push) Has been skipped

A two-part Punktfunk plugin that syncs the Playnite library into the host
game library as the `playnite` provider:

- exporter/ — a minimal C# Playnite GenericPlugin ("Punktfunk Sync") that
  writes punktfunk-library.json on every library change. Reading Playnite's
  live-locked LiteDB from outside isn't robust, so this adapter runs inside
  Playnite. Builds net462 via reference assemblies; packaged as a .pext.
- src/ — the TS plugin (on @punktfunk/host, rom-manager shape): watches the
  export, embeds covers as data URLs, reconciles via
  PUT /library/provider/playnite, with a console-hosted web UI + standalone
  fallback + CLI. Launch = playnite://playnite/start/<id>, run by the host
  in the interactive session so Playnite performs the real launch.

Verified locally: backend tsc + 23 tests + biome clean, SPA build, C#
exporter build + .pext pack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 23:35:35 +02:00
commit 6694be9848
48 changed files with 3885 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
// Cover art. Playnite stores art as local files on the host; the host's library only proxies Steam
// art, and provider entries are fetched by clients directly — so for a Playnite title we inline the
// cover as a `data:` URL the clients render without any network fetch. A `data:` URL bloats the
// library payload, so anything over `maxBytes` is dropped rather than embedded (design default: cover
// only; backgrounds are large and opt-in).
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;
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
@@ -0,0 +1,126 @@
#!/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();
+113
View File
@@ -0,0 +1,113 @@
// 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, and the host only
* proxies Steam art — so for a provider entry we inline the cover as a size-capped `data:` URL that
* clients render directly. `off` syncs titles with no art. */
export type ArtMode = "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: "dataurl",
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({});
+8
View File
@@ -0,0 +1,8 @@
// 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`.
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";
+318
View File
@@ -0,0 +1,318 @@
// 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 {
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);
if (!loc.dir) return;
// Prefer watching ExtensionsData (where the file lives); fall back to the Playnite dir if the
// exporter hasn't created it yet, so we notice it appearing.
const extData = path.join(loc.dir, "ExtensionsData");
const target = fs.existsSync(extData) ? extData : loc.dir;
try {
const watcher = fs.watch(target, { recursive: true }, () =>
this.debouncedSync(),
);
this.watchers.push(watcher);
} 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,
};
}
}
+148
View File
@@ -0,0 +1,148 @@
// 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.
import type { Config } from "../config.js";
import type { ExportedGame, LibraryExport } from "../export-schema.js";
import type { Artwork, ProviderEntryInput } from "../wire.js";
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. */
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,
): ProviderEntryInput["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")
return { kind: "command", value: `start "" "${uri}"` };
// Best-effort elsewhere (Wine/portable): a desktop opener resolves the registered 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;
/** Art builder (IO lives in the engine; tests pass a stub). */
artFor: (game: ExportedGame) => Artwork | undefined;
}
export interface ComputeResult {
entries: ProviderEntryInput[];
report: SyncReport;
}
/** Warn above this many entries — every cover is an inlined `data:` URL, so a huge library makes a
* heavy library payload (see README "Box art"). */
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: ProviderEntryInput[] = [];
const skipped: SkippedGame[] = [];
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;
}
const entry: ProviderEntryInput = {
external_id: g.id,
title,
launch: launchCommand(g.id, platform),
};
const art = artFor(g);
if (art) entry.art = art;
entries.push(entry);
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 (entries.length > WARN_ENTRIES) {
warnings.push(
`${entries.length} entries — large library; inlined covers make a heavy library payload. Consider art.mode "off" or a tighter source filter.`,
);
}
return {
entries,
report: {
generatedAt: exp.generatedAt,
considered: exp.games.length,
included: entries.length,
skipped,
warnings,
perSource,
},
};
};
+55
View File
@@ -0,0 +1,55 @@
// 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
@@ -0,0 +1,20 @@
// 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
@@ -0,0 +1,63 @@
// 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
@@ -0,0 +1,10 @@
// 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();
+53
View File
@@ -0,0 +1,53 @@
// 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 `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>/playnite`. */
export const pluginDir = (): string => path.join(hostConfigDir(), "playnite");
/** `<config_dir>/playnite/config.json` — operator-editable, atomic-written. */
export const configPath = (): string => path.join(pluginDir(), "config.json");
/** `<config_dir>/playnite/cache.json` — disposable derived state (last reconcile fingerprint). */
export const cachePath = (): string => path.join(pluginDir(), "cache.json");
/**
* 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");
};
+135
View File
@@ -0,0 +1,135 @@
// 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 one wrinkle is *which user's* `%APPDATA%`: if the runner runs as the interactive user we
// find it directly; if it runs as SYSTEM/another user we fall back to scanning `C:\Users\*`. An
// explicit `playniteDir` in the config always wins.
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";
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();
// First, a dir that actually holds an export file.
for (const dir of candidates) {
const hit = findExportUnder(dir);
if (hit) return { dir, 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, 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
@@ -0,0 +1,30 @@
// 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
@@ -0,0 +1,30 @@
// 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
@@ -0,0 +1,91 @@
// 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
@@ -0,0 +1,150 @@
// 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
@@ -0,0 +1,90 @@
// 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
@@ -0,0 +1,16 @@
// 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
@@ -0,0 +1,34 @@
// 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[];
}