Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e832201669 |
@@ -12,10 +12,13 @@ using Playnite.SDK.Plugins;
|
|||||||
namespace PunktfunkSync
|
namespace PunktfunkSync
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes the Playnite library to <c>punktfunk-library.json</c> in this plugin's data directory
|
/// Writes the Playnite library to <c>punktfunk-library.json</c> whenever it changes, to two
|
||||||
/// (<c>…/Playnite/ExtensionsData/<this-id>/</c>) whenever it changes. The Punktfunk
|
/// places: this plugin's data directory (<c>…/Playnite/ExtensionsData/<this-id>/</c>) and —
|
||||||
/// <c>playnite</c> plugin globs for that file and reconciles it into the host game library — this
|
/// best-effort — the host's ingest inbox (<c>%ProgramData%\punktfunk\ingest\playnite\</c>). The
|
||||||
/// side stays a dumb, dependency-free exporter.
|
/// second matters because the host's plugin runner is de-privileged (<c>NT AUTHORITY\LocalService</c>)
|
||||||
|
/// and cannot read this user's <c>%APPDATA%</c>; <c>punktfunk-host plugins enable</c> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PunktfunkSyncPlugin : GenericPlugin
|
public class PunktfunkSyncPlugin : GenericPlugin
|
||||||
{
|
{
|
||||||
@@ -27,12 +30,19 @@ namespace PunktfunkSync
|
|||||||
public override Guid Id { get; } = Guid.Parse("d9f1b3a2-7c64-4e58-9a1b-6f2e3c4d5a6b");
|
public override Guid Id { get; } = Guid.Parse("d9f1b3a2-7c64-4e58-9a1b-6f2e3c4d5a6b");
|
||||||
|
|
||||||
private readonly string exportPath;
|
private readonly string exportPath;
|
||||||
|
private readonly string ingestPath;
|
||||||
private readonly Timer debounce;
|
private readonly Timer debounce;
|
||||||
|
|
||||||
public PunktfunkSyncPlugin(IPlayniteAPI api) : base(api)
|
public PunktfunkSyncPlugin(IPlayniteAPI api) : base(api)
|
||||||
{
|
{
|
||||||
Properties = new GenericPluginProperties { HasSettings = false };
|
Properties = new GenericPluginProperties { HasSettings = false };
|
||||||
exportPath = Path.Combine(GetPluginUserDataPath(), "punktfunk-library.json");
|
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()` (`<config_dir>/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.
|
// Coalesce bursts of per-item events (a metadata download touches many games) into one write.
|
||||||
debounce = new Timer(3000) { AutoReset = false };
|
debounce = new Timer(3000) { AutoReset = false };
|
||||||
@@ -123,14 +133,37 @@ namespace PunktfunkSync
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Write via a temp file + move so a reader never sees a half-written export.</summary>
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private void WriteAtomic(string json)
|
private void WriteAtomic(string json)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(exportPath));
|
WriteAtomicTo(exportPath, json);
|
||||||
var tmp = exportPath + ".tmp";
|
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})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Write via a temp file + move so a reader never sees a half-written export.</summary>
|
||||||
|
private static void WriteAtomicTo(string path, string json)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||||
|
var tmp = path + ".tmp";
|
||||||
File.WriteAllText(tmp, json);
|
File.WriteAllText(tmp, json);
|
||||||
if (File.Exists(exportPath)) File.Delete(exportPath);
|
if (File.Exists(path)) File.Delete(path);
|
||||||
File.Move(tmp, exportPath);
|
File.Move(tmp, path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@punktfunk/plugin-playnite",
|
"name": "@punktfunk/plugin-playnite",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"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.",
|
"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.",
|
||||||
|
|||||||
+23
-13
@@ -12,6 +12,7 @@ import type { Config } from "../config.js";
|
|||||||
import type { ExportedGame } from "../export-schema.js";
|
import type { ExportedGame } from "../export-schema.js";
|
||||||
import { deleteProvider, reconcileProvider } from "../host.js";
|
import { deleteProvider, reconcileProvider } from "../host.js";
|
||||||
import { type Logger, makeLogger } from "../log.js";
|
import { type Logger, makeLogger } from "../log.js";
|
||||||
|
import { ingestDir } from "../paths.js";
|
||||||
import {
|
import {
|
||||||
ExportError,
|
ExportError,
|
||||||
type Location,
|
type Location,
|
||||||
@@ -141,21 +142,30 @@ export class Engine {
|
|||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
if (!config.sync.watch) return;
|
if (!config.sync.watch) return;
|
||||||
const loc = locateExport(config);
|
const loc = locateExport(config);
|
||||||
if (!loc.dir) return;
|
|
||||||
// Prefer watching ExtensionsData (where the file lives); fall back to the Playnite dir if the
|
const targets = new Set<string>();
|
||||||
// exporter hasn't created it yet, so we notice it appearing.
|
// Always watch the ingest inbox when present — the de-privileged runner's source, so an
|
||||||
const extData = path.join(loc.dir, "ExtensionsData");
|
// exporter drop reconciles within seconds even when `loc.dir` resolved elsewhere (or nowhere).
|
||||||
const target = fs.existsSync(extData) ? extData : loc.dir;
|
const inbox = ingestDir();
|
||||||
try {
|
if (fs.existsSync(inbox)) targets.add(inbox);
|
||||||
const watcher = fs.watch(target, { recursive: true }, () =>
|
// Plus the located Playnite dir: prefer its ExtensionsData (where the file lives), else the
|
||||||
this.debouncedSync(),
|
// dir itself so we notice the export appearing (same-user/SYSTEM runner, or Linux).
|
||||||
);
|
if (loc.dir) {
|
||||||
this.watchers.push(watcher);
|
const extData = path.join(loc.dir, "ExtensionsData");
|
||||||
} catch {
|
targets.add(fs.existsSync(extData) ? extData : loc.dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
try {
|
try {
|
||||||
this.watchers.push(fs.watch(target, () => this.debouncedSync()));
|
this.watchers.push(
|
||||||
|
fs.watch(target, { recursive: true }, () => this.debouncedSync()),
|
||||||
|
);
|
||||||
} catch {
|
} 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 =>
|
export const pluginDir = (): string =>
|
||||||
path.join(hostConfigDir(), "plugin-state", "playnite");
|
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");
|
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");
|
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
|
* 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`
|
* 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.
|
// from the exporter's id.
|
||||||
//
|
//
|
||||||
// The plugin runs on the same box as Playnite (it's the streaming host), so these are plain local
|
// 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
|
// reads. The wrinkle is *which account* can see the file. The managed runner is de-privileged
|
||||||
// find it directly; if it runs as SYSTEM/another user we fall back to scanning `C:\Users\*`. An
|
// (`NT AUTHORITY\LocalService`) and cannot read the interactive user's `%APPDATA%`, so the reliable
|
||||||
// explicit `playniteDir` in the config always wins.
|
// 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 fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
@@ -18,6 +21,7 @@ import {
|
|||||||
type LibraryExport,
|
type LibraryExport,
|
||||||
SCHEMA,
|
SCHEMA,
|
||||||
} from "./export-schema.js";
|
} from "./export-schema.js";
|
||||||
|
import { ingestDir } from "./paths.js";
|
||||||
|
|
||||||
const exists = (p: string): boolean => {
|
const exists = (p: string): boolean => {
|
||||||
try {
|
try {
|
||||||
@@ -96,15 +100,38 @@ export const locateExport = (config: Config): Location => {
|
|||||||
? [override, ...candidatePlayniteDirs()]
|
? [override, ...candidatePlayniteDirs()]
|
||||||
: 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) {
|
for (const dir of candidates) {
|
||||||
const hit = findExportUnder(dir);
|
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
|
// Else, the first dir that at least exists (so the UI can say "Playnite found, exporter not
|
||||||
// installed yet" vs "Playnite not found").
|
// installed yet" vs "Playnite not found").
|
||||||
const dir = candidates.find(exists) ?? null;
|
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 {}
|
export class ExportError extends Error {}
|
||||||
|
|||||||
@@ -44,6 +44,26 @@ describe("locateExport", () => {
|
|||||||
expect(loc.dir).toBe(dir);
|
expect(loc.dir).toBe(dir);
|
||||||
expect(loc.file).toBeNull();
|
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", () => {
|
describe("readExport", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user