Files
punktfunk-plugin-playnite/contract/src/domain.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

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;