feat: Playnite library sync plugin + exporter
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:
+60
@@ -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
@@ -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
@@ -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({});
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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}`);
|
||||
@@ -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
@@ -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();
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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()),
|
||||
});
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user