diff --git a/exporter/PunktfunkSyncPlugin.cs b/exporter/PunktfunkSyncPlugin.cs
index 0b8d60e..42046ab 100644
--- a/exporter/PunktfunkSyncPlugin.cs
+++ b/exporter/PunktfunkSyncPlugin.cs
@@ -12,10 +12,13 @@ using Playnite.SDK.Plugins;
namespace PunktfunkSync
{
///
- /// Writes the Playnite library to punktfunk-library.json in this plugin's data directory
- /// (…/Playnite/ExtensionsData/<this-id>/) whenever it changes. The Punktfunk
- /// playnite plugin globs for that file and reconciles it into the host game library — this
- /// side stays a dumb, dependency-free exporter.
+ /// Writes the Playnite library to punktfunk-library.json whenever it changes, to two
+ /// places: this plugin's data directory (…/Playnite/ExtensionsData/<this-id>/) and —
+ /// best-effort — the host's ingest inbox (%ProgramData%\punktfunk\ingest\playnite\). The
+ /// second matters because the host's plugin runner is de-privileged (NT AUTHORITY\LocalService)
+ /// and cannot read this user's %APPDATA%; punktfunk-host plugins enable makes the
+ /// ingest inbox user-writable so we can drop the file where the runner can read it. This side
+ /// stays a dumb, dependency-free exporter.
///
public class PunktfunkSyncPlugin : GenericPlugin
{
@@ -27,12 +30,19 @@ namespace PunktfunkSync
public override Guid Id { get; } = Guid.Parse("d9f1b3a2-7c64-4e58-9a1b-6f2e3c4d5a6b");
private readonly string exportPath;
+ private readonly string ingestPath;
private readonly Timer debounce;
public PunktfunkSyncPlugin(IPlayniteAPI api) : base(api)
{
Properties = new GenericPluginProperties { HasSettings = false };
exportPath = Path.Combine(GetPluginUserDataPath(), "punktfunk-library.json");
+ // The host runner is de-privileged and can't read our %APPDATA%; drop a copy in the
+ // host's user-writable ingest inbox too (see WriteAtomic). Keep this in lockstep with the
+ // TS reader's `ingestDir()` (`/ingest/playnite`).
+ ingestPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "punktfunk", "ingest", "playnite", "punktfunk-library.json");
// Coalesce bursts of per-item events (a metadata download touches many games) into one write.
debounce = new Timer(3000) { AutoReset = false };
@@ -123,14 +133,37 @@ namespace PunktfunkSync
}
}
- /// Write via a temp file + move so a reader never sees a half-written export.
+ ///
+ /// Write the export to its ExtensionsData home (always) and to the host ingest inbox
+ /// (best-effort — only when a punktfunk host is present on this box, so we never create a
+ /// stray %ProgramData%\punktfunk on a machine that has no host).
+ ///
private void WriteAtomic(string json)
{
- Directory.CreateDirectory(Path.GetDirectoryName(exportPath));
- var tmp = exportPath + ".tmp";
+ WriteAtomicTo(exportPath, json);
+ try
+ {
+ var pfDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "punktfunk");
+ if (Directory.Exists(pfDir)) WriteAtomicTo(ingestPath, json);
+ }
+ catch (Exception ex)
+ {
+ // The inbox only exists (and is writable) where `plugins enable` has run; a failure
+ // here just means the de-privileged runner falls back to the ExtensionsData scan.
+ logger.Info($"Punktfunk Sync: ingest drop skipped ({ex.Message})");
+ }
+ }
+
+ /// Write via a temp file + move so a reader never sees a half-written export.
+ private static void WriteAtomicTo(string path, string json)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+ var tmp = path + ".tmp";
File.WriteAllText(tmp, json);
- if (File.Exists(exportPath)) File.Delete(exportPath);
- File.Move(tmp, exportPath);
+ if (File.Exists(path)) File.Delete(path);
+ File.Move(tmp, path);
}
}
}
diff --git a/package.json b/package.json
index 46c9995..aecf22d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-playnite",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": false,
"type": "module",
"description": "Punktfunk plugin: syncs your Playnite library into the host game library as a provider — every store and emulator Playnite manages, launched back through Playnite, with a console-hosted web UI. Pairs with the bundled Playnite exporter extension.",
diff --git a/src/engine/index.ts b/src/engine/index.ts
index 3af096e..6f1f7d4 100644
--- a/src/engine/index.ts
+++ b/src/engine/index.ts
@@ -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();
+ // 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)`);
+ }
}
}
}
diff --git a/src/paths.ts b/src/paths.ts
index cc2f65d..69365bb 100644
--- a/src/paths.ts
+++ b/src/paths.ts
@@ -32,12 +32,24 @@ export const hostConfigDir = (): string => {
export const pluginDir = (): string =>
path.join(hostConfigDir(), "plugin-state", "playnite");
-/** `/playnite/config.json` — operator-editable, atomic-written. */
+/** `/plugin-state/playnite/config.json` — operator-editable, atomic-written. */
export const configPath = (): string => path.join(pluginDir(), "config.json");
-/** `/playnite/cache.json` — disposable derived state (last reconcile fingerprint). */
+/** `/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:
+ * `/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`
diff --git a/src/playnite.ts b/src/playnite.ts
index ab8b4c4..d7a6ff1 100644
--- a/src/playnite.ts
+++ b/src/playnite.ts
@@ -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** (`/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/` 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 {}
diff --git a/test/playnite.test.ts b/test/playnite.test.ts
index b8f7f4f..6a96e2d 100644
--- a/test/playnite.test.ts
+++ b/test/playnite.test.ts
@@ -44,6 +44,26 @@ describe("locateExport", () => {
expect(loc.dir).toBe(dir);
expect(loc.file).toBeNull();
});
+
+ test("reads the ingest inbox first — the de-privileged runner's source", () => {
+ const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "pf-cfg-"));
+ const prev = process.env.PUNKTFUNK_CONFIG_DIR;
+ process.env.PUNKTFUNK_CONFIG_DIR = cfg;
+ try {
+ const inbox = path.join(cfg, "ingest", "playnite");
+ fs.mkdirSync(inbox, { recursive: true });
+ fs.writeFileSync(path.join(inbox, EXPORT_FILE), JSON.stringify(validDoc));
+ // Even with a Playnite ExtensionsData dir present, the ingest drop wins.
+ const pd = layout("with-ingest", validDoc);
+ const loc = locateExport(resolveConfig({ playniteDir: pd }));
+ expect(loc.dir).toBe(inbox);
+ expect(loc.file).toBe(path.join(inbox, EXPORT_FILE));
+ } finally {
+ if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
+ else process.env.PUNKTFUNK_CONFIG_DIR = prev;
+ fs.rmSync(cfg, { recursive: true, force: true });
+ }
+ });
});
describe("readExport", () => {