Files
punktfunk-plugin-playnite/src/state.ts
T
enricobuehler 6694be9848
CI / exporter (push) Failing after 24s
CI / build (push) Failing after 25s
CI / publish (push) Has been skipped
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>
2026-07-18 23:35:35 +02:00

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()),
});