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", () => {