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.
89 lines
3.6 KiB
TypeScript
89 lines
3.6 KiB
TypeScript
// 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;
|