6694be9848
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>
91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
// 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()),
|
|
});
|