Files
punktfunk-plugin-playnite/src/playnite.ts
T
enricobuehler e832201669
CI / publish (push) Successful in 17s
CI / exporter (push) Successful in 11s
CI / build (push) Successful in 20s
fix: read/write the library via the host ingest inbox (de-privileged runner)
The host plugin runner is de-privileged now (LocalService) and can't read the
interactive user's %APPDATA%\Playnite\ExtensionsData — so it never saw the
export. Bridge it through the host's user-writable ingest inbox:

- exporter (C#): also drop punktfunk-library.json into
  %ProgramData%\punktfunk\ingest\playnite (best-effort, only when a punktfunk
  host is present), alongside its ExtensionsData home.
- plugin (TS): locateExport() reads <config_dir>/ingest/playnite first, then
  falls back to the ExtensionsData scan (same-user/SYSTEM runner, Linux). The
  watcher also watches the inbox so a drop reconciles within seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 00:40:28 +02:00

163 lines
5.5 KiB
TypeScript

// 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 wrinkle is *which account* can see the file. The managed runner is de-privileged
// (`NT AUTHORITY\LocalService`) and cannot read the interactive user's `%APPDATA%`, so the reliable
// source is the **ingest inbox** (`<config_dir>/ingest/playnite/punktfunk-library.json`) the
// exporter drops the file into — checked FIRST. The `%APPDATA%`/`C:\Users\*` ExtensionsData scan
// still works for a same-user/SYSTEM runner or on Linux, and an explicit `playniteDir` override
// joins the candidate list.
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";
import { ingestDir } from "./paths.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();
// The ingest inbox first: the exporter drops the library here (a flat file, not an
// `ExtensionsData/<guid>` tree) so the de-privileged LocalService runner can read it — it can't
// reach the interactive user's `%APPDATA%`. This is the reliable path on a managed Windows host.
const inbox = ingestDir();
const inboxFile = path.join(inbox, EXPORT_FILE);
try {
const st = fs.statSync(inboxFile);
return {
dir: inbox,
candidates: [inbox, ...candidates],
file: inboxFile,
mtime: st.mtimeMs,
};
} catch {
// no ingest drop yet — fall back to scanning Playnite's own data dirs
}
// Then, a Playnite data dir that actually holds an export file (same-user/SYSTEM runner, Linux).
for (const dir of candidates) {
const hit = findExportUnder(dir);
if (hit)
return {
dir,
candidates: [inbox, ...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: [inbox, ...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;
};