e832201669
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>
163 lines
5.5 KiB
TypeScript
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;
|
|
};
|