Files
punktfunk-plugin-playnite/plugin/src/services/engine.ts
T
enricobuehler b81e03685a refactor: rewrite on @punktfunk/plugin-kit — contract + plugin workspaces
The framework half of this plugin (engine lifecycle, config/state, host
reconcile, UI server, CLI) was ~80% identical to rom-manager's. That code is
now @punktfunk/plugin-kit; this replaces the local copy with it and adopts the
rom-manager blueprint layout.

  contract/  Effect Schemas: the cross-process export contract (the C#
             exporter's other half, with the schema-version guard as a decode
             check), the config schema (one schema, Encoded = authored file
             shape, defaults only via withDecodingDefaultKey), domain DTOs, the
             HttpApi contract, API errors.
  plugin/    src/domain = the pure core (locate/read, art, reconcile), ported
             tests green before any Effect wiring; src/services = the kit's
             ConfigService/CacheStore/SyncEngine/ProviderClient; definePluginKit
             entry, kit CLI, auth-free dev API.

Preserved, because they are what makes playnite work at all: the ingest inbox
is still read FIRST (the de-privileged LocalService runner cannot reach the
interactive user's %APPDATA%), the host/dataurl/off art modes, and the
playnite:// launch hand-back. The C# exporter is untouched.

New: per-game include/exclude overrides, and an allow-listed /api/art proxy so
the SPA can render local Playnite covers — it serves only paths the currently
ingested export references.

Dropped: the standalone password UI server (console surface + CLI only, as in
rom-manager 0.3.0) and the devEntry flag.

Trap found and fixed here: the HttpApi encoder serialises `undefined` as
`null`, so a `Schema.optional` field the server omits cannot be decoded by the
client — silently in the SSE feed, loudly in the typed client. Every optional
EngineStatus field is `Schema.NullOr` with an explicit value on the wire.
2026-07-20 20:56:12 +02:00

271 lines
9.2 KiB
TypeScript

// PlayniteSync — the plugin's engine service: wires the pure domain (locate → read →
// compute) into the kit's generic SyncEngine (poll/watch/debounce/coalesce/fingerprint)
// and the ProviderClient reconcile. Also owns the EngineStatus assembly the API and the
// SSE feed share, the side-effect-light preview the Library page uses, and the allow-listed
// cover-art reader behind `/api/art`.
import * as fs from "node:fs";
import * as path from "node:path";
import {
type Config,
type EngineStatus,
EXPORT_FILE,
ExportUnavailable,
type LibraryExport,
type Location,
type PlayniteInfo,
type PreviewResult,
resolveConfig,
type SyncReport,
} from "@playnite/contract";
import {
type LastSync,
makeSyncEngine,
ProviderClient,
pluginIngestDir,
type SyncEngine,
} from "@punktfunk/plugin-kit";
import type { ProviderEntry } from "@punktfunk/plugin-kit/wire";
import {
Context,
Duration,
Effect,
Layer,
Ref,
type Scope,
Stream,
} from "effect";
import { PLUGIN_NAME, PROVIDER_ID } from "../const.js";
import { artPaths, buildArtwork, mimeFor } from "../domain/art.js";
import { locateExport, readExport } from "../domain/locate.js";
import { computeEntries } from "../domain/reconcile.js";
import { PlayniteCache } from "./cache.js";
import { PlayniteConfig } from "./config.js";
/** One cover the `/api/art` proxy is willing to serve. */
export interface ArtFile {
readonly bytes: Uint8Array;
readonly contentType: string;
}
export interface PlayniteSyncService {
readonly engine: SyncEngine<SyncReport>;
/** Read + compute the desired state. NO art embedding, NO reconcile. */
readonly preview: Effect.Effect<PreviewResult, ExportUnavailable>;
readonly status: Effect.Effect<EngineStatus>;
/** The SSE feed: a full EngineStatus on every engine transition. */
readonly statusChanges: Stream.Stream<EngineStatus>;
/** Where the export was found — the CLI's `where`, without a full read. */
readonly locate: Effect.Effect<Location>;
/**
* Read one local cover, but ONLY if the currently-ingested export references it.
* `undefined` for anything else — the browser cannot ask this for an arbitrary file.
*/
readonly art: (file: string) => Effect.Effect<ArtFile | undefined>;
}
export class PlayniteSync extends Context.Service<
PlayniteSync,
PlayniteSyncService
>()("playnite/PlayniteSync") {
// Layer.effect runs `make` in the LAYER's scope (Scope is excluded from R) — the
// engine's watch/poll loops live exactly as long as the plugin runtime.
static readonly layer: Layer.Layer<
PlayniteSync,
never,
PlayniteConfig | PlayniteCache | ProviderClient
> = Layer.effect(PlayniteSync)(make());
}
const NO_LOCATION: Location = {
dir: null,
candidates: [],
file: null,
mtime: null,
viaIngest: false,
};
/** The art allow-list, pinned to the export document it was derived from. */
interface AllowList {
readonly file: string;
readonly mtime: number;
readonly paths: ReadonlySet<string>;
}
function make(): Effect.Effect<
PlayniteSyncService,
never,
PlayniteConfig | PlayniteCache | ProviderClient | Scope.Scope
> {
return Effect.gen(function* () {
const cfg = yield* PlayniteConfig;
const cache = yield* PlayniteCache;
const provider = yield* ProviderClient;
const platform = process.platform;
const ingest = pluginIngestDir(PLUGIN_NAME);
// Observed state the status view reports (the old Engine's private fields).
const lastLocation = yield* Ref.make<Location>(NO_LOCATION);
const lastError = yield* Ref.make<string | undefined>(undefined);
const lastPlaynite = yield* Ref.make<PlayniteInfo | undefined>(undefined);
const allowList = yield* Ref.make<AllowList | undefined>(undefined);
const loadOrDefault = cfg.load.pipe(
Effect.orElseSucceed(() => resolveConfig({})),
);
/** Locate + read + decode, refreshing everything the status view and the art proxy
* derive from the export. The one place an export failure is recorded. */
const readCurrent = (
config: Config,
): Effect.Effect<LibraryExport, ExportUnavailable> =>
Effect.gen(function* () {
const location = locateExport(config);
yield* Ref.set(lastLocation, location);
if (location.file === null) {
const message = `no exporter output under ${location.dir ?? "any Playnite data dir"} — is the Punktfunk Sync extension installed in Playnite?`;
yield* Ref.set(lastError, message);
return yield* Effect.fail(new ExportUnavailable({ message }));
}
const file = location.file;
const exp = yield* Effect.try({
try: () => readExport(file),
catch: (cause) =>
new ExportUnavailable({
message: cause instanceof Error ? cause.message : String(cause),
}),
}).pipe(Effect.tapError((e) => Ref.set(lastError, e.message)));
yield* Ref.set(lastError, undefined);
yield* Ref.set(lastPlaynite, exp.playnite);
yield* Ref.set(allowList, {
file,
mtime: location.mtime ?? 0,
paths: artPaths(exp),
});
return exp;
});
const computeWith = (
embedArt: boolean,
): Effect.Effect<
{ readonly entries: ProviderEntry[]; readonly report: SyncReport },
ExportUnavailable
> =>
Effect.gen(function* () {
const config = yield* loadOrDefault;
const exp = yield* readCurrent(config);
return computeEntries({
exp,
config,
platform,
// The preview skips the (heavy, in `dataurl` mode) art embed.
artFor: embedArt
? (g) => buildArtwork(g, config.art)
: () => undefined,
});
});
const engine = yield* makeSyncEngine<
SyncReport,
ReadonlyArray<ProviderEntry>,
never
>({
compute: () => computeWith(true),
apply: (entries) => provider.reconcile(PROVIDER_ID, entries),
lastSync: {
get: cache.get.pipe(Effect.map((c) => c.lastSync)),
set: (last: LastSync) =>
cache.update((c) => ({ ...c, lastSync: last })).pipe(Effect.ignore),
},
settings: loadOrDefault.pipe(
Effect.map((config) => ({
pollInterval: Duration.minutes(Math.max(1, config.sync.pollMinutes)),
watch: config.sync.watch,
debounce: Duration.millis(config.sync.debounceMs),
watchDirs: watchTargets(config),
})),
),
});
/**
* What to watch: the ingest inbox ALWAYS (the de-privileged runner's only source,
* so an exporter drop reconciles within seconds even when the Playnite dir resolved
* elsewhere), plus the located Playnite dir's `ExtensionsData` when there is one.
* The kit's watcher set is best-effort — an absent dir is silently skipped and the
* interval poll covers it.
*/
function watchTargets(config: Config): string[] {
const targets = new Set<string>([ingest]);
const located = locateExport(config);
if (located.dir !== null && !located.viaIngest) {
const extData = path.join(located.dir, "ExtensionsData");
targets.add(fs.existsSync(extData) ? extData : located.dir);
}
return [...targets];
}
const status: Effect.Effect<EngineStatus> = Effect.gen(function* () {
const config = yield* loadOrDefault;
const engineStatus = yield* engine.status;
const location = yield* Ref.get(lastLocation);
const exportError = yield* Ref.get(lastError);
const playnite = yield* Ref.get(lastPlaynite);
return {
platform,
// Before the first sync the status view would otherwise show nothing at all.
location: location.file === null ? locateExport(config) : location,
// Explicit nulls, never undefined — see the trap note in contract/domain.ts.
exportError: exportError ?? null,
playnite: playnite ?? null,
artMode: config.art.mode,
syncing: engineStatus.syncing,
lastSync: engineStatus.lastSync ?? null,
lastReport: engineStatus.lastReport ?? null,
paths: {
dir: path.dirname(cfg.path),
config: cfg.path,
cache: cache.path,
ingest: path.join(ingest, EXPORT_FILE),
},
} satisfies EngineStatus;
});
const art = (file: string): Effect.Effect<ArtFile | undefined> =>
Effect.gen(function* () {
const cached = yield* Ref.get(allowList);
const config = yield* loadOrDefault;
const located = locateExport(config);
// Refresh when we have never read an export, or the file moved under us —
// otherwise a cover added since the last sync would 404 until the next one.
const stale =
cached === undefined ||
located.file !== cached.file ||
(located.mtime ?? 0) !== cached.mtime;
if (stale) yield* readCurrent(config).pipe(Effect.ignore);
const allow = yield* Ref.get(allowList);
if (allow === undefined || !allow.paths.has(file)) return undefined;
const contentType = mimeFor(file);
if (contentType === null) return undefined;
return yield* Effect.try(
(): ArtFile => ({ bytes: fs.readFileSync(file), contentType }),
).pipe(Effect.orElseSucceed(() => undefined));
});
return {
engine,
preview: computeWith(false),
status,
// A fresh subscriber gets the CURRENT status immediately, then one frame per
// engine transition — so the console's live view is right the moment it
// connects, rather than blank until the next sync. Each frame is a full
// snapshot, so the sliver between the two stages costs nothing.
statusChanges: Stream.concat(
Stream.fromEffect(status),
engine.changes.pipe(Stream.mapEffect(() => status)),
),
locate: loadOrDefault.pipe(Effect.map(locateExport)),
art,
} satisfies PlayniteSyncService;
});
}