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>
This commit is contained in:
+23
-13
@@ -12,6 +12,7 @@ import type { Config } from "../config.js";
|
||||
import type { ExportedGame } from "../export-schema.js";
|
||||
import { deleteProvider, reconcileProvider } from "../host.js";
|
||||
import { type Logger, makeLogger } from "../log.js";
|
||||
import { ingestDir } from "../paths.js";
|
||||
import {
|
||||
ExportError,
|
||||
type Location,
|
||||
@@ -141,21 +142,30 @@ export class Engine {
|
||||
const config = loadConfig();
|
||||
if (!config.sync.watch) return;
|
||||
const loc = locateExport(config);
|
||||
if (!loc.dir) return;
|
||||
// Prefer watching ExtensionsData (where the file lives); fall back to the Playnite dir if the
|
||||
// exporter hasn't created it yet, so we notice it appearing.
|
||||
const extData = path.join(loc.dir, "ExtensionsData");
|
||||
const target = fs.existsSync(extData) ? extData : loc.dir;
|
||||
try {
|
||||
const watcher = fs.watch(target, { recursive: true }, () =>
|
||||
this.debouncedSync(),
|
||||
);
|
||||
this.watchers.push(watcher);
|
||||
} catch {
|
||||
|
||||
const targets = new Set<string>();
|
||||
// Always watch the ingest inbox when present — the de-privileged runner's source, so an
|
||||
// exporter drop reconciles within seconds even when `loc.dir` resolved elsewhere (or nowhere).
|
||||
const inbox = ingestDir();
|
||||
if (fs.existsSync(inbox)) targets.add(inbox);
|
||||
// Plus the located Playnite dir: prefer its ExtensionsData (where the file lives), else the
|
||||
// dir itself so we notice the export appearing (same-user/SYSTEM runner, or Linux).
|
||||
if (loc.dir) {
|
||||
const extData = path.join(loc.dir, "ExtensionsData");
|
||||
targets.add(fs.existsSync(extData) ? extData : loc.dir);
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
try {
|
||||
this.watchers.push(fs.watch(target, () => this.debouncedSync()));
|
||||
this.watchers.push(
|
||||
fs.watch(target, { recursive: true }, () => this.debouncedSync()),
|
||||
);
|
||||
} catch {
|
||||
this.log(`watch: cannot watch ${target} (poll only)`);
|
||||
try {
|
||||
this.watchers.push(fs.watch(target, () => this.debouncedSync()));
|
||||
} catch {
|
||||
this.log(`watch: cannot watch ${target} (poll only)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -32,12 +32,24 @@ export const hostConfigDir = (): string => {
|
||||
export const pluginDir = (): string =>
|
||||
path.join(hostConfigDir(), "plugin-state", "playnite");
|
||||
|
||||
/** `<config_dir>/playnite/config.json` — operator-editable, atomic-written. */
|
||||
/** `<config_dir>/plugin-state/playnite/config.json` — operator-editable, atomic-written. */
|
||||
export const configPath = (): string => path.join(pluginDir(), "config.json");
|
||||
|
||||
/** `<config_dir>/playnite/cache.json` — disposable derived state (last reconcile fingerprint). */
|
||||
/** `<config_dir>/plugin-state/playnite/cache.json` — disposable derived state (last fingerprint). */
|
||||
export const cachePath = (): string => path.join(pluginDir(), "cache.json");
|
||||
|
||||
/**
|
||||
* The cross-account **ingest inbox** the Playnite exporter drops the library export into:
|
||||
* `<config_dir>/ingest/playnite`. (Mirrors `@punktfunk/host`'s `pluginIngestDir`.)
|
||||
*
|
||||
* The exporter runs inside Playnite as the interactive user; the de-privileged LocalService runner
|
||||
* cannot read that user's `%APPDATA%\Playnite\ExtensionsData\…`. `punktfunk-host plugins enable`
|
||||
* makes `ingest` user-writable, so the exporter drops the file here for the runner to read. Read
|
||||
* this BEFORE the ExtensionsData scan (which only works for a same-user/SYSTEM runner or on Linux).
|
||||
*/
|
||||
export const ingestDir = (): string =>
|
||||
path.join(hostConfigDir(), "ingest", "playnite");
|
||||
|
||||
/**
|
||||
* Locate the built SPA directory (`dist/ui`) at runtime. The package ships as ONE self-contained
|
||||
* bundle so it installs from the Gitea registry with no external deps — which means `import.meta.url`
|
||||
|
||||
+33
-6
@@ -5,9 +5,12 @@
|
||||
// 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 one wrinkle is *which user's* `%APPDATA%`: if the runner runs as the interactive user we
|
||||
// find it directly; if it runs as SYSTEM/another user we fall back to scanning `C:\Users\*`. An
|
||||
// explicit `playniteDir` in the config always wins.
|
||||
// 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";
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
type LibraryExport,
|
||||
SCHEMA,
|
||||
} from "./export-schema.js";
|
||||
import { ingestDir } from "./paths.js";
|
||||
|
||||
const exists = (p: string): boolean => {
|
||||
try {
|
||||
@@ -96,15 +100,38 @@ export const locateExport = (config: Config): Location => {
|
||||
? [override, ...candidatePlayniteDirs()]
|
||||
: candidatePlayniteDirs();
|
||||
|
||||
// First, a dir that actually holds an export file.
|
||||
// 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, file: hit.file, mtime: hit.mtime };
|
||||
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, file: null, mtime: null };
|
||||
return { dir, candidates: [inbox, ...candidates], file: null, mtime: null };
|
||||
};
|
||||
|
||||
export class ExportError extends Error {}
|
||||
|
||||
Reference in New Issue
Block a user