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