b81e03685a
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.
271 lines
9.2 KiB
TypeScript
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;
|
|
});
|
|
}
|