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.
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user