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,88 @@
|
||||
// Domain DTOs shared by the plugin server and the UI — Schemas are the source of truth,
|
||||
// the derived types keep the original domain names so the pure core reads unchanged.
|
||||
//
|
||||
// Trap, measured on this API (rom-manager's note only covers half of it): `optionalKey`
|
||||
// rejects a present-but-`undefined` value and 400s at the RESPONSE encoder — but
|
||||
// `Schema.optional` is not the fix, because the HttpApi encoder serialises `undefined` as
|
||||
// `null`, and `string | undefined` then refuses that null when the CLIENT decodes it. The
|
||||
// failure is silent in the SSE feed (invalid frames are skipped) and loud in the typed
|
||||
// client. So: every "may be absent" field here is `Schema.NullOr`, and the server always
|
||||
// sends an explicit value. JSON-native, symmetric, no undefined anywhere on the wire.
|
||||
import { Schema } from "effect";
|
||||
|
||||
/** Where the export was found — drives the console's "exporter connected?" banner. */
|
||||
export const Location = Schema.Struct({
|
||||
/** The resolved source dir (the ingest inbox, or a Playnite data dir), or null. */
|
||||
dir: Schema.NullOr(Schema.String),
|
||||
/** Every dir considered, in probe order (the UI's diagnostics view). */
|
||||
candidates: Schema.Array(Schema.String),
|
||||
/** The resolved export file, or null if the exporter hasn't written one yet. */
|
||||
file: Schema.NullOr(Schema.String),
|
||||
/** mtime (epoch ms) of the export file, or null. */
|
||||
mtime: Schema.NullOr(Schema.Number),
|
||||
/** True when the file came from `<config_dir>/ingest/playnite` (the reliable lane). */
|
||||
viaIngest: Schema.Boolean,
|
||||
});
|
||||
export type Location = typeof Location.Type;
|
||||
|
||||
/** A game the compute rejected, with a human reason (Library page, CLI). */
|
||||
export const SkippedGame = Schema.Struct({
|
||||
id: Schema.String,
|
||||
title: Schema.String,
|
||||
reason: Schema.String,
|
||||
});
|
||||
export type SkippedGame = typeof SkippedGame.Type;
|
||||
|
||||
/** A summary of one desired-state compute (Overview + Library pages). */
|
||||
export const SyncReport = Schema.Struct({
|
||||
/** ISO timestamp the exporter stamped on the source export. */
|
||||
generatedAt: Schema.String,
|
||||
/** Schema version of the export that produced this report. */
|
||||
schemaVersion: Schema.Number,
|
||||
/** Games in the export before filtering. */
|
||||
considered: Schema.Number,
|
||||
/** Entries in the reconcile payload. */
|
||||
included: Schema.Number,
|
||||
skipped: Schema.Array(SkippedGame),
|
||||
/** Dropped by an explicit per-game override (the Library page's toggle). */
|
||||
excluded: Schema.Array(
|
||||
Schema.Struct({ id: Schema.String, title: Schema.String }),
|
||||
),
|
||||
warnings: Schema.Array(Schema.String),
|
||||
/** Included entries per Playnite source ("Steam", "GOG", "(none)", …). */
|
||||
perSource: Schema.Record(Schema.String, Schema.Number),
|
||||
});
|
||||
export type SyncReport = typeof SyncReport.Type;
|
||||
|
||||
export const LastSync = Schema.Struct({
|
||||
fingerprint: Schema.String,
|
||||
count: Schema.Number,
|
||||
at: Schema.Number,
|
||||
});
|
||||
export type LastSync = typeof LastSync.Type;
|
||||
|
||||
/** The engine status the Overview page renders and the SSE feed streams. */
|
||||
export const EngineStatus = Schema.Struct({
|
||||
platform: Schema.String,
|
||||
location: Location,
|
||||
/** Last read/decode error (missing export, corruption, too-new schema), else null. */
|
||||
exportError: Schema.NullOr(Schema.String),
|
||||
/** Playnite's own version + mode, as stamped on the last readable export. */
|
||||
playnite: Schema.NullOr(
|
||||
Schema.Struct({
|
||||
version: Schema.NullOr(Schema.String),
|
||||
mode: Schema.NullOr(Schema.String),
|
||||
}),
|
||||
),
|
||||
artMode: Schema.String,
|
||||
syncing: Schema.Boolean,
|
||||
lastSync: Schema.NullOr(LastSync),
|
||||
lastReport: Schema.NullOr(SyncReport),
|
||||
paths: Schema.Struct({
|
||||
dir: Schema.String,
|
||||
config: Schema.String,
|
||||
cache: Schema.String,
|
||||
ingest: Schema.String,
|
||||
}),
|
||||
});
|
||||
export type EngineStatus = typeof EngineStatus.Type;
|
||||
Reference in New Issue
Block a user