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:
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
plugin/dist
|
||||||
ui/dist
|
ui/dist
|
||||||
ui/node_modules
|
ui/storybook-static
|
||||||
|
ui/screenshots
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
# Resolve the `@punktfunk` scope from the Gitea npm registry (like `@punktfunk/host` itself), so
|
# Resolve the @punktfunk and @unom scopes from the Gitea npm registry for the whole
|
||||||
# `bun add @punktfunk/plugin-playnite` pulls the shared `@punktfunk/host` + `effect` deps out of the
|
# workspace (plugin deps + UI design system).
|
||||||
# box. During local development the SDK is consumed via `bun link @punktfunk/host`.
|
|
||||||
[install.scopes]
|
[install.scopes]
|
||||||
"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/"
|
"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/"
|
||||||
|
"@unom" = "https://git.unom.io/api/packages/unom/npm/"
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"filter": {
|
|
||||||
"installedOnly": true,
|
|
||||||
"includeHidden": false,
|
|
||||||
"sources": [],
|
|
||||||
"excludeSources": ["Xbox"]
|
|
||||||
},
|
|
||||||
"art": {
|
|
||||||
"mode": "host",
|
|
||||||
"maxBytes": 1500000,
|
|
||||||
"includeBackground": false
|
|
||||||
},
|
|
||||||
"sync": {
|
|
||||||
"pollMinutes": 5,
|
|
||||||
"watch": true,
|
|
||||||
"debounceMs": 1500
|
|
||||||
},
|
|
||||||
"ui": {
|
|
||||||
"standalone": false,
|
|
||||||
"port": 47994,
|
|
||||||
"bind": "127.0.0.1"
|
|
||||||
},
|
|
||||||
"playniteDir": ""
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@playnite/contract",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "The single source of truth shared by the plugin server and the UI: the cross-process export schema (the C# exporter's other half), the config schema, domain DTOs, the HttpApi contract, and API errors. Never published — bundled into the plugin, aliased into the UI.",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"effect": "^4.0.0-beta.99",
|
||||||
|
"@punktfunk/plugin-kit": "^0.1.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"effect": "4.0.0-beta.99",
|
||||||
|
"@punktfunk/plugin-kit": "^0.1.4",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"@types/bun": "^1.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// The one HttpApi contract — HttpApiBuilder implements it server-side, AtomHttpApi
|
||||||
|
// derives the UI's typed client + atoms from it. No hand-mirrored types anywhere.
|
||||||
|
//
|
||||||
|
// Two routes sit OUTSIDE the HttpApi because neither is JSON: the SSE status feed
|
||||||
|
// (`effect/unstable/httpapi` has no event-stream media type at beta.99) and the cover-art
|
||||||
|
// proxy (image bytes). Both are plain HttpRouter routes; their paths live here so the UI
|
||||||
|
// and the server still share one definition.
|
||||||
|
|
||||||
|
import { ProviderEntry } from "@punktfunk/plugin-kit/wire";
|
||||||
|
import { Schema } from "effect";
|
||||||
|
import {
|
||||||
|
HttpApi,
|
||||||
|
HttpApiEndpoint,
|
||||||
|
HttpApiGroup,
|
||||||
|
HttpApiSchema,
|
||||||
|
} from "effect/unstable/httpapi";
|
||||||
|
import { EngineStatus, SyncReport } from "./domain.js";
|
||||||
|
import { ConfigInvalid, ExportUnavailable, SyncInProgress } from "./errors.js";
|
||||||
|
|
||||||
|
/** GET /api/config + PUT /api/config response: the authored raw shape, verbatim.
|
||||||
|
* The UI resolves it locally by decoding through the SHARED PlayniteConfigSchema. */
|
||||||
|
export const ConfigPayload = Schema.Struct({ raw: Schema.Unknown });
|
||||||
|
export type ConfigPayload = typeof ConfigPayload.Type;
|
||||||
|
|
||||||
|
/** GET /api/preview: the dry-run desired state (no art embedding, no host write). */
|
||||||
|
export const PreviewResult = Schema.Struct({
|
||||||
|
entries: Schema.Array(ProviderEntry),
|
||||||
|
report: SyncReport,
|
||||||
|
});
|
||||||
|
export type PreviewResult = typeof PreviewResult.Type;
|
||||||
|
|
||||||
|
export const PlayniteApi = HttpApi.make("playnite")
|
||||||
|
.add(
|
||||||
|
HttpApiGroup.make("status").add(
|
||||||
|
HttpApiEndpoint.get("get", "/api/status", { success: EngineStatus }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiGroup.make("config")
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.get("get", "/api/config", { success: ConfigPayload }),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.put("put", "/api/config", {
|
||||||
|
payload: Schema.Unknown,
|
||||||
|
success: ConfigPayload,
|
||||||
|
error: ConfigInvalid.pipe(HttpApiSchema.status(400)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiGroup.make("library").add(
|
||||||
|
HttpApiEndpoint.get("preview", "/api/preview", {
|
||||||
|
success: PreviewResult,
|
||||||
|
error: ExportUnavailable.pipe(HttpApiSchema.status(503)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiGroup.make("sync").add(
|
||||||
|
HttpApiEndpoint.post("run", "/api/sync", {
|
||||||
|
success: SyncReport,
|
||||||
|
error: Schema.Union([
|
||||||
|
SyncInProgress.pipe(HttpApiSchema.status(409)),
|
||||||
|
ExportUnavailable.pipe(HttpApiSchema.status(503)),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The SSE status feed. Frames: `event: status`, data = EngineStatus JSON. */
|
||||||
|
export const EVENTS_PATH = "/api/events";
|
||||||
|
export const EVENTS_EVENT = "status";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cover-art proxy. Playnite art is LOCAL files on the host, which a browser cannot
|
||||||
|
* load — so the SPA asks the plugin for them by path. The server only serves a path that
|
||||||
|
* appears in the currently-ingested export (an allow-list, not an arbitrary file read).
|
||||||
|
*/
|
||||||
|
export const ART_PATH = "/api/art";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one of the exporter's art references to something an `<img src>` can load.
|
||||||
|
* Remote art (Playnite passes `http(s)` references through verbatim) and `data:` URLs are
|
||||||
|
* already loadable; a local path goes through the allow-listed proxy above.
|
||||||
|
*/
|
||||||
|
export const artUrl = (
|
||||||
|
prefix: string,
|
||||||
|
reference: string | null | undefined,
|
||||||
|
): string | undefined => {
|
||||||
|
if (reference === null || reference === undefined || reference === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (/^(https?:|data:)/i.test(reference)) return reference;
|
||||||
|
return `${prefix}${ART_PATH}?path=${encodeURIComponent(reference)}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// The config model. ONE schema: the Encoded side IS the authored file shape (RawConfig),
|
||||||
|
// the Type side is the fully-resolved shape the engine consumes (Config). Defaults live
|
||||||
|
// ONLY here (`withDecodingDefaultKey` + encodingStrategy "omit") — the kit's ConfigService
|
||||||
|
// persists the raw shape verbatim, so a UI save never bakes defaults into the file.
|
||||||
|
//
|
||||||
|
// Dropped vs 0.1.x: `ui.*` (the standalone password server is gone — console surface + CLI
|
||||||
|
// only) and `devEntry` (a dev flag has no place in the prod shape; `preview` shows the
|
||||||
|
// round-trip instead). Added: `gameOverrides`, the Library page's per-game include toggle.
|
||||||
|
// Stale keys in existing files are tolerated on decode and dropped by the next save.
|
||||||
|
import { Effect, Schema } from "effect";
|
||||||
|
|
||||||
|
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
|
||||||
|
schema.pipe(
|
||||||
|
Schema.withDecodingDefaultKey(Effect.succeed(value), {
|
||||||
|
encodingStrategy: "omit",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** When and how often we re-read the export and reconcile. */
|
||||||
|
export const SyncOptions = Schema.Struct({
|
||||||
|
/** Interval poll in minutes — the safety net under the exporter-driven file watch. */
|
||||||
|
pollMinutes: withDefault(Schema.Number, 5),
|
||||||
|
/** Watch the ingest inbox and reconcile on change (the primary trigger). */
|
||||||
|
watch: withDefault(Schema.Boolean, true),
|
||||||
|
/** Debounce window for coalescing watch/poll-triggered re-reads (ms). */
|
||||||
|
debounceMs: withDefault(Schema.Number, 1500),
|
||||||
|
});
|
||||||
|
export type SyncOptions = typeof SyncOptions.Type;
|
||||||
|
|
||||||
|
/** Which Playnite games become library entries. */
|
||||||
|
export const FilterOptions = Schema.Struct({
|
||||||
|
/** Only sync games Playnite marks installed. Off = include not-yet-installed titles. */
|
||||||
|
installedOnly: withDefault(Schema.Boolean, true),
|
||||||
|
/** Include games flagged Hidden in Playnite. */
|
||||||
|
includeHidden: withDefault(Schema.Boolean, false),
|
||||||
|
/** If non-empty, keep ONLY games whose source is in this list ("Steam", "GOG", …). */
|
||||||
|
sources: withDefault(Schema.Array(Schema.String), []),
|
||||||
|
/** Drop games whose source is in this list (applied after `sources`). */
|
||||||
|
excludeSources: withDefault(Schema.Array(Schema.String), []),
|
||||||
|
});
|
||||||
|
export type FilterOptions = typeof FilterOptions.Type;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How cover art reaches the clients. Playnite art is local files on the host:
|
||||||
|
* - `host` (default): send the local file PATH; the host serves the bytes through its art
|
||||||
|
* proxy (`/library/art/…`, like Steam art). The reconcile payload carries no image data,
|
||||||
|
* so this scales to a library of any size — the recommended mode.
|
||||||
|
* - `dataurl`: inline each cover as a size-capped `data:` URL. Self-contained but does NOT
|
||||||
|
* scale — a large library blows past the host's reconcile body limit. Small libraries only.
|
||||||
|
* - `off`: sync titles with no art.
|
||||||
|
*/
|
||||||
|
export const ArtMode = Schema.Literals(["host", "dataurl", "off"]);
|
||||||
|
export type ArtMode = typeof ArtMode.Type;
|
||||||
|
|
||||||
|
export const ArtOptions = Schema.Struct({
|
||||||
|
mode: withDefault(ArtMode, "host"),
|
||||||
|
/** Skip an image whose file is larger than this (bytes) — `dataurl` mode only. */
|
||||||
|
maxBytes: withDefault(Schema.Number, 1_500_000),
|
||||||
|
/** Also send the Playnite background as `hero` art (usually large — off by default). */
|
||||||
|
includeBackground: withDefault(Schema.Boolean, false),
|
||||||
|
});
|
||||||
|
export type ArtOptions = typeof ArtOptions.Type;
|
||||||
|
|
||||||
|
/** A per-game override, keyed by the Playnite game Guid (the reconcile `external_id`). */
|
||||||
|
export const GameOverride = Schema.Struct({
|
||||||
|
exclude: Schema.optionalKey(Schema.Boolean),
|
||||||
|
});
|
||||||
|
export type GameOverride = typeof GameOverride.Type;
|
||||||
|
|
||||||
|
export const PlayniteConfigSchema = Schema.Struct({
|
||||||
|
/** Override the auto-detected Playnite data directory (`%APPDATA%\\Playnite`). Point it
|
||||||
|
* at a portable install, or at the interactive user's profile when the runner runs as
|
||||||
|
* a different user. The ingest inbox is still read FIRST. */
|
||||||
|
playniteDir: Schema.optionalKey(Schema.String),
|
||||||
|
sync: withDefault(SyncOptions, {}),
|
||||||
|
filter: withDefault(FilterOptions, {}),
|
||||||
|
art: withDefault(ArtOptions, {}),
|
||||||
|
gameOverrides: withDefault(Schema.Record(Schema.String, GameOverride), {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The fully-resolved config the engine consumes. */
|
||||||
|
export type Config = typeof PlayniteConfigSchema.Type;
|
||||||
|
/** The on-disk config as authored (all optional). */
|
||||||
|
export type RawConfig = typeof PlayniteConfigSchema.Encoded;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an authored raw config to the total shape (defaults filled). Throws on an
|
||||||
|
* invalid shape — the sync path for tests and the UI; the server side uses the kit's
|
||||||
|
* ConfigService (Effect + typed errors) instead.
|
||||||
|
*/
|
||||||
|
export const resolveConfig = (raw: unknown): Config =>
|
||||||
|
Schema.decodeUnknownSync(PlayniteConfigSchema)(raw);
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// API errors — Schema-backed so they cross the wire typed; status set per-endpoint via
|
||||||
|
// HttpApiSchema.status in api.ts.
|
||||||
|
import { Schema } from "effect";
|
||||||
|
|
||||||
|
/** PUT /api/config body failed schema validation. → 400 */
|
||||||
|
export class ConfigInvalid extends Schema.TaggedErrorClass<ConfigInvalid>()(
|
||||||
|
"ConfigInvalid",
|
||||||
|
{ issues: Schema.String },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** A sync pass is already running (the trigger was coalesced). → 409 */
|
||||||
|
export class SyncInProgress extends Schema.TaggedErrorClass<SyncInProgress>()(
|
||||||
|
"SyncInProgress",
|
||||||
|
{},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No exporter output was found, or the file is unreadable / not a valid export / stamped
|
||||||
|
* with a schema this plugin does not understand. → 503, because it is a "the other half
|
||||||
|
* isn't installed yet" state rather than a server fault: the UI turns it into onboarding.
|
||||||
|
*/
|
||||||
|
export class ExportUnavailable extends Schema.TaggedErrorClass<ExportUnavailable>()(
|
||||||
|
"ExportUnavailable",
|
||||||
|
{ message: Schema.String },
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// The CROSS-PROCESS contract: the other half of this file is C#.
|
||||||
|
//
|
||||||
|
// The Playnite exporter (`exporter/`, a GenericPlugin) writes `punktfunk-library.json` in
|
||||||
|
// exactly this shape on every library change; this plugin decodes it. Keep it in lockstep
|
||||||
|
// with `exporter/Model.cs` — the `[SerializationPropertyName]` attributes there pin the
|
||||||
|
// JSON keys these Schemas read. Bump `SCHEMA` on any breaking change; the exporter stamps
|
||||||
|
// the version it wrote and the `schema` check below turns a too-new file into a decode
|
||||||
|
// error rather than a silent mis-read.
|
||||||
|
//
|
||||||
|
// Leniency is deliberate and asymmetric: every field except `id`, `schema` and `games` has
|
||||||
|
// a decoding default, so an exporter that predates (or postdates) a field addition still
|
||||||
|
// decodes. Per-GAME validity — a malformed Guid, a blank title — is NOT a decode failure:
|
||||||
|
// those become skip reasons in the pure core, so one bad row can never cost you the whole
|
||||||
|
// library.
|
||||||
|
import { Effect, Schema } from "effect";
|
||||||
|
|
||||||
|
/** The file the exporter writes (ingest inbox, and its own `ExtensionsData/<guid>/`). */
|
||||||
|
export const EXPORT_FILE = "punktfunk-library.json";
|
||||||
|
|
||||||
|
/** Schema version this reader understands. A newer major is refused (see `LibraryExport`). */
|
||||||
|
export const SCHEMA = 1;
|
||||||
|
|
||||||
|
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
|
||||||
|
schema.pipe(
|
||||||
|
Schema.withDecodingDefaultKey(Effect.succeed(value), {
|
||||||
|
encodingStrategy: "omit",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** One game as the Playnite exporter sees it. Art fields are ABSOLUTE local paths on the
|
||||||
|
* host box (resolved via `IPlayniteAPI.Database.GetFullFilePath`) — except a remote
|
||||||
|
* `http(s)` art URL, which the exporter passes through verbatim. */
|
||||||
|
export const ExportedGame = Schema.Struct({
|
||||||
|
/** Playnite's stable game `Guid` ("d" format) — our reconcile `external_id`. */
|
||||||
|
id: Schema.String,
|
||||||
|
name: withDefault(Schema.String, ""),
|
||||||
|
/** Whether Playnite considers the game installed (drives the `installedOnly` filter). */
|
||||||
|
installed: withDefault(Schema.Boolean, false),
|
||||||
|
/** Playnite's per-game "Hidden" flag. */
|
||||||
|
hidden: withDefault(Schema.Boolean, false),
|
||||||
|
/** The library source name ("Steam", "GOG", "Epic", emulator name, …) or null. */
|
||||||
|
source: withDefault(Schema.NullOr(Schema.String), null),
|
||||||
|
/** Platform display names ("PC (Windows)", "Nintendo Switch", …). */
|
||||||
|
platforms: withDefault(Schema.Array(Schema.String), []),
|
||||||
|
cover: withDefault(Schema.NullOr(Schema.String), null),
|
||||||
|
background: withDefault(Schema.NullOr(Schema.String), null),
|
||||||
|
icon: withDefault(Schema.NullOr(Schema.String), null),
|
||||||
|
});
|
||||||
|
export type ExportedGame = typeof ExportedGame.Type;
|
||||||
|
|
||||||
|
export const PlayniteInfo = Schema.Struct({
|
||||||
|
version: withDefault(Schema.NullOr(Schema.String), null),
|
||||||
|
/** "Desktop" | "Fullscreen" — informational. */
|
||||||
|
mode: withDefault(Schema.NullOr(Schema.String), null),
|
||||||
|
});
|
||||||
|
export type PlayniteInfo = typeof PlayniteInfo.Type;
|
||||||
|
|
||||||
|
/** The whole export document. Decoding it IS the schema-version guard. */
|
||||||
|
export const LibraryExport = Schema.Struct({
|
||||||
|
schema: Schema.Number.pipe(
|
||||||
|
Schema.check(
|
||||||
|
Schema.isLessThanOrEqualTo(SCHEMA, {
|
||||||
|
description: `a punktfunk-library.json schema this plugin understands (≤ ${SCHEMA}) — a higher one means the Playnite exporter is newer than the plugin; update the plugin`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
/** ISO-8601 timestamp the exporter stamped on this document. */
|
||||||
|
generatedAt: withDefault(Schema.String, ""),
|
||||||
|
playnite: withDefault(PlayniteInfo, {}),
|
||||||
|
games: Schema.Array(ExportedGame),
|
||||||
|
});
|
||||||
|
export type LibraryExport = typeof LibraryExport.Type;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// @playnite/contract — the shared source of truth (the cross-process export schema, the
|
||||||
|
// config schema, domain DTOs, the HttpApi contract, API errors). Bundled into the plugin,
|
||||||
|
// aliased into the UI.
|
||||||
|
export * from "./api.js";
|
||||||
|
export * from "./config.js";
|
||||||
|
export * from "./domain.js";
|
||||||
|
export * from "./errors.js";
|
||||||
|
export * from "./export.js";
|
||||||
@@ -3,14 +3,13 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"types": ["bun"]
|
"types": ["bun"]
|
||||||
},
|
},
|
||||||
"include": ["src", "test"],
|
"include": ["src"]
|
||||||
"exclude": ["ui", "dist", "node_modules"]
|
|
||||||
}
|
}
|
||||||
+10
-46
@@ -1,55 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@punktfunk/plugin-playnite",
|
"name": "playnite-workspace",
|
||||||
"version": "0.1.1",
|
"private": true,
|
||||||
"private": false,
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Punktfunk plugin: syncs your Playnite library into the host game library as a provider — every store and emulator Playnite manages, launched back through Playnite, with a console-hosted web UI. Pairs with the bundled Playnite exporter extension.",
|
"workspaces": [
|
||||||
"license": "MIT OR Apache-2.0",
|
"contract",
|
||||||
"homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.unom.io/unom/punktfunk-plugin-playnite.git"
|
|
||||||
},
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://git.unom.io/unom/punktfunk-plugin-playnite/issues"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"punktfunk",
|
|
||||||
"plugin",
|
"plugin",
|
||||||
"playnite",
|
"ui"
|
||||||
"game-library",
|
|
||||||
"game-streaming"
|
|
||||||
],
|
],
|
||||||
"main": "./dist/index.js",
|
|
||||||
"module": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"bin": {
|
|
||||||
"punktfunk-plugin-playnite": "./dist/cli.js"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "cd contract && bun run typecheck && cd ../plugin && bun run typecheck && cd ../ui && bun run typecheck",
|
||||||
"test": "bun test",
|
"test": "cd plugin && bun test",
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "cd plugin && bun run build:all",
|
||||||
"build:ui": "cd ui && bun install --silent && bun run build",
|
"check": "bunx biome check ."
|
||||||
"build:all": "bun run build && bun run build:ui",
|
|
||||||
"sync": "bun src/cli.ts sync",
|
|
||||||
"where": "bun src/cli.ts where",
|
|
||||||
"preview": "bun src/cli.ts preview",
|
|
||||||
"prepublishOnly": "bun run build:all"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@punktfunk/host": "^0.1.1",
|
|
||||||
"effect": "^4.0.0-beta.98"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.2",
|
"@biomejs/biome": "^2.5.2"
|
||||||
"@types/bun": "^1.3.0",
|
|
||||||
"typescript": "^5.9.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "@punktfunk/plugin-playnite",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"private": false,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Punktfunk plugin: syncs your Playnite library into the host game library as a provider — every store and emulator Playnite manages, launched back through Playnite, with a console-hosted web UI. Pairs with the bundled Playnite exporter extension. Built on @punktfunk/plugin-kit.",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.unom.io/unom/punktfunk-plugin-playnite.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://git.unom.io/unom/punktfunk-plugin-playnite/issues"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"punktfunk",
|
||||||
|
"plugin",
|
||||||
|
"playnite",
|
||||||
|
"game-library",
|
||||||
|
"game-streaming"
|
||||||
|
],
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./types/index.d.ts",
|
||||||
|
"bin": {
|
||||||
|
"punktfunk-plugin-playnite": "./dist/cli.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"types"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "bun test",
|
||||||
|
"build": "bun build src/index.ts src/cli.ts --target=bun --outdir dist --external effect --external '@punktfunk/*'",
|
||||||
|
"build:ui": "cd ../ui && bun run build",
|
||||||
|
"build:all": "bun run build && bun run build:ui",
|
||||||
|
"dev": "bun src/dev.ts",
|
||||||
|
"prepublishOnly": "bun run build:all"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@punktfunk/host": "^0.1.2",
|
||||||
|
"@punktfunk/plugin-kit": "^0.1.4",
|
||||||
|
"effect": "^4.0.0-beta.99"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playnite/contract": "workspace:*",
|
||||||
|
"@types/bun": "^1.3.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
// Ops CLI on the kit's dispatcher — the same layer graph as the plugin entry, so every
|
||||||
|
// command sees the exact services the runner runs. where/preview work offline;
|
||||||
|
// sync/uninstall talk to the host.
|
||||||
|
import {
|
||||||
|
type CliCommand,
|
||||||
|
ProviderClient,
|
||||||
|
runPluginCli,
|
||||||
|
} from "@punktfunk/plugin-kit";
|
||||||
|
import { Effect } from "effect";
|
||||||
|
import pkg from "../package.json" with { type: "json" };
|
||||||
|
import { PLUGIN_NAME, PROVIDER_ID } from "./const.js";
|
||||||
|
import { type PlayniteServices, playniteLayer } from "./layer.js";
|
||||||
|
import { PlayniteSync } from "./services/engine.js";
|
||||||
|
|
||||||
|
const print = (line: string) => Effect.sync(() => console.log(line));
|
||||||
|
|
||||||
|
const commands: Record<string, CliCommand<PlayniteServices>> = {
|
||||||
|
where: {
|
||||||
|
summary: "Show where the Playnite exporter's output was found",
|
||||||
|
offline: true,
|
||||||
|
run: () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sync = yield* PlayniteSync;
|
||||||
|
const loc = yield* sync.locate;
|
||||||
|
yield* print(`Source dir: ${loc.dir ?? "(not found)"}`);
|
||||||
|
yield* print(`Export file: ${loc.file ?? "(not found)"}`);
|
||||||
|
yield* print(`Via ingest: ${loc.viaIngest ? "yes" : "no"}`);
|
||||||
|
if (loc.mtime !== null) {
|
||||||
|
yield* print(`Last written: ${new Date(loc.mtime).toISOString()}`);
|
||||||
|
}
|
||||||
|
yield* print("Candidates probed:");
|
||||||
|
for (const c of loc.candidates) yield* print(` ${c}`);
|
||||||
|
if (loc.file === null) {
|
||||||
|
yield* print(
|
||||||
|
"\nNo export found. Install the Punktfunk Sync extension in Playnite (see exporter/README.md),",
|
||||||
|
);
|
||||||
|
yield* print("or set `playniteDir` in the plugin's config.json.");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
summary: "Dry-run: show the entries a sync would reconcile",
|
||||||
|
offline: true,
|
||||||
|
run: () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sync = yield* PlayniteSync;
|
||||||
|
const { entries, report } = yield* sync.preview;
|
||||||
|
yield* print(
|
||||||
|
`${report.included}/${report.considered} game(s) would sync (${report.skipped.length} skipped, ${report.excluded.length} excluded by you).`,
|
||||||
|
);
|
||||||
|
for (const [source, n] of Object.entries(report.perSource).sort(
|
||||||
|
(a, b) => b[1] - a[1],
|
||||||
|
)) {
|
||||||
|
yield* print(` ${source}: ${n}`);
|
||||||
|
}
|
||||||
|
for (const e of entries.slice(0, 40)) {
|
||||||
|
yield* print(` ${e.title} → ${e.launch?.value ?? "(no launch)"}`);
|
||||||
|
}
|
||||||
|
if (entries.length > 40) {
|
||||||
|
yield* print(` … and ${entries.length - 40} more`);
|
||||||
|
}
|
||||||
|
for (const s of report.skipped.slice(0, 20)) {
|
||||||
|
yield* print(`SKIP ${s.title}: ${s.reason}`);
|
||||||
|
}
|
||||||
|
for (const w of report.warnings) yield* print(`! ${w}`);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
sync: {
|
||||||
|
summary: "Reconcile the library provider now",
|
||||||
|
run: () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sync = yield* PlayniteSync;
|
||||||
|
const outcome = yield* sync.engine.sync("manual");
|
||||||
|
yield* print(
|
||||||
|
outcome._tag === "AlreadyRunning"
|
||||||
|
? "a sync is already running"
|
||||||
|
: `${outcome._tag}: ${outcome.report.included} entries`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
uninstall: {
|
||||||
|
summary: "Remove every playnite entry from the host library",
|
||||||
|
run: () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const provider = yield* ProviderClient;
|
||||||
|
yield* provider.remove(PROVIDER_ID);
|
||||||
|
yield* print("provider entries removed");
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await runPluginCli({
|
||||||
|
def: {
|
||||||
|
name: PLUGIN_NAME,
|
||||||
|
version: pkg.version,
|
||||||
|
layer: playniteLayer,
|
||||||
|
main: Effect.void,
|
||||||
|
},
|
||||||
|
commands,
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Plugin identity. The package name is `@punktfunk/plugin-playnite` (the runner's scoped glob), but
|
// Plugin identity. The package name is `@punktfunk/plugin-playnite` (the runner's scoped
|
||||||
// the `definePlugin` name, the library provider id, and the console-nav id are all the short
|
// glob), but the `definePluginKit` name, the library provider id, the state/ingest dir
|
||||||
// `playnite`.
|
// names, and the console-nav id are all the short `playnite`.
|
||||||
export const PLUGIN_NAME = "playnite";
|
export const PLUGIN_NAME = "playnite";
|
||||||
export const PROVIDER_ID = "playnite";
|
export const PROVIDER_ID = "playnite";
|
||||||
export const UI_TITLE = "Playnite";
|
export const UI_TITLE = "Playnite";
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
// Dev-only API server: the plugin's HttpApi on a plain loopback port (:5886), no auth, no
|
||||||
|
// static files — the `bun run dev:live` proxy target for the Vite UI. Connects to a running
|
||||||
|
// host when one is up (full sync path); otherwise runs host-less (locate, preview, config
|
||||||
|
// and the art proxy all work; reconcile fails softly in the engine's safeSync).
|
||||||
|
// This is NOT part of the shipped plugin (never bundled into dist/index.js or cli.js).
|
||||||
|
import { connect, type Punktfunk } from "@punktfunk/host";
|
||||||
|
import {
|
||||||
|
hostClientFromFacade,
|
||||||
|
loggingLayer,
|
||||||
|
pluginInfoLayer,
|
||||||
|
} from "@punktfunk/plugin-kit";
|
||||||
|
import { Effect, Layer, ManagedRuntime } from "effect";
|
||||||
|
import { HttpRouter } from "effect/unstable/http";
|
||||||
|
import pkg from "../package.json" with { type: "json" };
|
||||||
|
import { PLUGIN_NAME } from "./const.js";
|
||||||
|
import { playniteLayer } from "./layer.js";
|
||||||
|
import { makeApi } from "./services/api.js";
|
||||||
|
import { PlayniteConfig } from "./services/config.js";
|
||||||
|
import { PlayniteSync } from "./services/engine.js";
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PLAYNITE_DEV_PORT ?? 5886);
|
||||||
|
|
||||||
|
const offlineFacade = (): Punktfunk =>
|
||||||
|
({
|
||||||
|
request: async (method: string, path: string) => {
|
||||||
|
throw new Error(`dev: no host running (tried ${method} ${path})`);
|
||||||
|
},
|
||||||
|
close: () => {},
|
||||||
|
}) as unknown as Punktfunk;
|
||||||
|
|
||||||
|
const pf = await connect().catch(() => {
|
||||||
|
console.log("dev: no punktfunk host reachable — running host-less");
|
||||||
|
return offlineFacade();
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = Layer.mergeAll(
|
||||||
|
hostClientFromFacade(pf),
|
||||||
|
pluginInfoLayer({ name: PLUGIN_NAME, version: pkg.version }),
|
||||||
|
loggingLayer(PLUGIN_NAME),
|
||||||
|
);
|
||||||
|
const rt = ManagedRuntime.make(Layer.provideMerge(playniteLayer, base));
|
||||||
|
|
||||||
|
// No Effect.scoped here: the engine's loops belong to the runtime's layer scope and must
|
||||||
|
// outlive this acquisition effect (they close on rt.dispose()).
|
||||||
|
const { handler, dispose } = await rt.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sync = yield* PlayniteSync;
|
||||||
|
const config = yield* PlayniteConfig;
|
||||||
|
yield* sync.engine.start;
|
||||||
|
return HttpRouter.toWebHandler(makeApi({ sync, config }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: PORT,
|
||||||
|
idleTimeout: 0, // SSE
|
||||||
|
fetch: (req) => handler(req),
|
||||||
|
});
|
||||||
|
console.log(`playnite dev API on http://127.0.0.1:${server.port}/api/status`);
|
||||||
|
|
||||||
|
process.once("SIGINT", () => {
|
||||||
|
void dispose()
|
||||||
|
.then(() => rt.dispose())
|
||||||
|
.finally(() => process.exit(0));
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Cover art. Playnite stores art as local files on the host (it passes a remote `http`
|
||||||
|
// reference through verbatim). Three delivery modes — see `ArtOptions` in the contract:
|
||||||
|
// - `host` (default): emit the reference AS-IS; the host serves local paths through its
|
||||||
|
// art proxy (`/library/art/…`), so the reconcile payload carries no image bytes and
|
||||||
|
// scales to any library size.
|
||||||
|
// - `dataurl`: inline the cover as a size-capped `data:` URL (self-contained, but only fit
|
||||||
|
// for small libraries — a big one blows past the host's reconcile body limit).
|
||||||
|
// - `off`: no art at all.
|
||||||
|
//
|
||||||
|
// This module also owns the ALLOW-LIST for the plugin's own `/api/art` proxy: the SPA can't
|
||||||
|
// load `C:\…\cover.jpg`, so it asks the plugin for those bytes — and the plugin only ever
|
||||||
|
// serves a path that the current export actually references (`artPaths`).
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import type {
|
||||||
|
ArtOptions,
|
||||||
|
ExportedGame,
|
||||||
|
LibraryExport,
|
||||||
|
} from "@playnite/contract";
|
||||||
|
import type { Artwork } from "@punktfunk/plugin-kit/wire";
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".tga": "image/x-tga",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The content type for a local art file, or null for an extension we don't serve. */
|
||||||
|
export const mimeFor = (file: string): string | null =>
|
||||||
|
MIME[path.extname(file).toLowerCase()] ?? null;
|
||||||
|
|
||||||
|
/** True for a reference the browser/host can already fetch without our help. */
|
||||||
|
export const isRemote = (reference: string): boolean =>
|
||||||
|
/^(https?:|data:)/i.test(reference);
|
||||||
|
|
||||||
|
/** Read a local image file into a `data:` URL, or null if missing, oversized, or an
|
||||||
|
* unknown type. */
|
||||||
|
export const fileToDataUrl = (
|
||||||
|
file: string | null,
|
||||||
|
maxBytes: number,
|
||||||
|
): string | null => {
|
||||||
|
if (!file) return null;
|
||||||
|
if (isRemote(file)) return file; // already inline-able
|
||||||
|
const mime = mimeFor(file);
|
||||||
|
if (!mime) return null;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(file);
|
||||||
|
if (!st.isFile() || st.size === 0 || st.size > maxBytes) return null;
|
||||||
|
return `data:${mime};base64,${fs.readFileSync(file).toString("base64")}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build the artwork block for a game per the art config. `undefined` when there is none. */
|
||||||
|
export const buildArtwork = (
|
||||||
|
game: ExportedGame,
|
||||||
|
art: ArtOptions,
|
||||||
|
): Artwork | undefined => {
|
||||||
|
if (art.mode === "off") return undefined;
|
||||||
|
// `host` mode: emit the references verbatim (the exporter only sets one when the file
|
||||||
|
// exists). The host serves the bytes, so the payload stays tiny at any library size.
|
||||||
|
// `dataurl` mode: inline (size-capped).
|
||||||
|
const [portrait, background] =
|
||||||
|
art.mode === "host"
|
||||||
|
? [game.cover, art.includeBackground ? game.background : null]
|
||||||
|
: [
|
||||||
|
fileToDataUrl(game.cover, art.maxBytes),
|
||||||
|
art.includeBackground
|
||||||
|
? fileToDataUrl(game.background, art.maxBytes)
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
if (!portrait && !background) return undefined;
|
||||||
|
// `Artwork` keys are optionalKey — omit them, never set them to undefined.
|
||||||
|
return {
|
||||||
|
...(portrait ? { portrait } : {}),
|
||||||
|
...(background ? { hero: background } : {}),
|
||||||
|
} satisfies Artwork;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every LOCAL art path the export references — the allow-list the `/api/art` proxy serves
|
||||||
|
* from. Remote references are excluded (the browser loads those itself), so the proxy can
|
||||||
|
* never be talked into reading a file Playnite didn't hand us.
|
||||||
|
*/
|
||||||
|
export const artPaths = (exp: LibraryExport): ReadonlySet<string> => {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const game of exp.games) {
|
||||||
|
for (const ref of [game.cover, game.background, game.icon]) {
|
||||||
|
if (ref && !isRemote(ref) && mimeFor(ref) !== null) out.add(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Locating and reading the exporter's output — the half of the cross-process contract
|
||||||
|
// that runs on the host. Pure of Effect (plain sync fs) so it unit-tests against tmp dirs;
|
||||||
|
// the engine service wraps it.
|
||||||
|
//
|
||||||
|
// WHICH ACCOUNT can see the file is the whole problem. The managed runner is de-privileged
|
||||||
|
// (`NT AUTHORITY\LocalService`) and cannot read the interactive user's `%APPDATA%`, so the
|
||||||
|
// reliable source is the **ingest inbox** (`<config_dir>/ingest/playnite/`) that the
|
||||||
|
// exporter also drops the file into — checked FIRST, always. The `%APPDATA%` /
|
||||||
|
// `C:\Users\*` `ExtensionsData` scan still works for a same-user/SYSTEM runner or on Linux
|
||||||
|
// (Wine/portable), and an explicit `playniteDir` override joins the candidate list.
|
||||||
|
//
|
||||||
|
// The `ExtensionsData/<guid>/` folder name is NOT hardcoded: we glob
|
||||||
|
// `ExtensionsData/*/punktfunk-library.json` and take the newest, so the reader stays
|
||||||
|
// decoupled from the exporter's extension id.
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import {
|
||||||
|
type Config,
|
||||||
|
EXPORT_FILE,
|
||||||
|
LibraryExport,
|
||||||
|
type Location,
|
||||||
|
} from "@playnite/contract";
|
||||||
|
import { pluginIngestDir } from "@punktfunk/plugin-kit";
|
||||||
|
import { Schema } from "effect";
|
||||||
|
import { PLUGIN_NAME } from "../const.js";
|
||||||
|
|
||||||
|
const exists = (p: string): boolean => {
|
||||||
|
try {
|
||||||
|
fs.accessSync(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Candidate Playnite data directories, most-specific first (the config override is
|
||||||
|
* prepended by the caller). */
|
||||||
|
export const candidatePlayniteDirs = (): string[] => {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const appdata = process.env.APPDATA;
|
||||||
|
if (appdata) out.push(path.join(appdata, "Playnite"));
|
||||||
|
// SYSTEM/other-user runner: scan every local profile's roaming AppData.
|
||||||
|
const usersRoot = path.join(process.env.SystemDrive ?? "C:", "\\", "Users");
|
||||||
|
try {
|
||||||
|
for (const user of fs.readdirSync(usersRoot)) {
|
||||||
|
out.push(path.join(usersRoot, user, "AppData", "Roaming", "Playnite"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no C:\Users (or not Windows-like) — fine
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Playnite is Windows-only, but a Wine/portable layout may set these — best effort.
|
||||||
|
const home = process.env.HOME;
|
||||||
|
if (home) out.push(path.join(home, ".local", "share", "Playnite"));
|
||||||
|
}
|
||||||
|
return [...new Set(out)];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Find the newest `punktfunk-library.json` under a Playnite dir's `ExtensionsData`. */
|
||||||
|
const findExportUnder = (
|
||||||
|
dir: string,
|
||||||
|
): { file: string; mtime: number } | null => {
|
||||||
|
const base = path.join(dir, "ExtensionsData");
|
||||||
|
let best: { file: string; mtime: number } | null = null;
|
||||||
|
let subdirs: string[];
|
||||||
|
try {
|
||||||
|
subdirs = fs.readdirSync(base);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const sub of subdirs) {
|
||||||
|
const file = path.join(base, sub, EXPORT_FILE);
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(file);
|
||||||
|
if (!best || st.mtimeMs > best.mtime) best = { file, mtime: st.mtimeMs };
|
||||||
|
} catch {
|
||||||
|
// no export in this extension's dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve where the export lives. The ingest inbox always wins; then a Playnite data dir
|
||||||
|
* that actually holds an export; else the first candidate that at least exists, so the UI
|
||||||
|
* can distinguish "Playnite found, exporter not installed" from "Playnite not found". */
|
||||||
|
export const locateExport = (config: Config): Location => {
|
||||||
|
const override = config.playniteDir?.trim();
|
||||||
|
const playniteDirs = override
|
||||||
|
? [override, ...candidatePlayniteDirs()]
|
||||||
|
: candidatePlayniteDirs();
|
||||||
|
const inbox = pluginIngestDir(PLUGIN_NAME);
|
||||||
|
const candidates = [inbox, ...playniteDirs];
|
||||||
|
|
||||||
|
const inboxFile = path.join(inbox, EXPORT_FILE);
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(inboxFile);
|
||||||
|
return {
|
||||||
|
dir: inbox,
|
||||||
|
candidates,
|
||||||
|
file: inboxFile,
|
||||||
|
mtime: st.mtimeMs,
|
||||||
|
viaIngest: true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// no ingest drop yet — fall back to scanning Playnite's own data dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of playniteDirs) {
|
||||||
|
const hit = findExportUnder(dir);
|
||||||
|
if (hit) {
|
||||||
|
return {
|
||||||
|
dir,
|
||||||
|
candidates,
|
||||||
|
file: hit.file,
|
||||||
|
mtime: hit.mtime,
|
||||||
|
viaIngest: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dir: playniteDirs.find(exists) ?? null,
|
||||||
|
candidates,
|
||||||
|
file: null,
|
||||||
|
mtime: null,
|
||||||
|
viaIngest: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A missing, corrupt, foreign, or too-new export. Carries a sentence fit for the UI. */
|
||||||
|
export class ExportError extends Error {}
|
||||||
|
|
||||||
|
const decodeExport = Schema.decodeUnknownSync(LibraryExport);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read + decode the export file. The schema-version guard lives in the contract Schema
|
||||||
|
* (`schema` is checked `<= SCHEMA`), so a too-new file surfaces here as a decode failure —
|
||||||
|
* one place, shared with the UI's type, no second hand-written check to drift.
|
||||||
|
*/
|
||||||
|
export const readExport = (file: string): LibraryExport => {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(file, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
throw new ExportError(`cannot read ${file}: ${e}`);
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
throw new ExportError(`${file} is not valid JSON: ${e}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeExport(parsed);
|
||||||
|
} catch (e) {
|
||||||
|
throw new ExportError(
|
||||||
|
`${file} is not a usable Punktfunk library export: ${e}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,69 +1,57 @@
|
|||||||
// The pure core: turn a validated library export + the config into the declarative entry list the host
|
// The pure core: turn a decoded library export + the config into the declarative entry
|
||||||
// reconcile expects, plus a report of what was included and why anything was skipped. No IO here (art
|
// list the host reconcile expects, plus a report of what was included and why anything was
|
||||||
// is injected via `artFor`), so it's fully unit-testable.
|
// skipped. No IO here (art is injected via `artFor`), so it is fully unit-testable.
|
||||||
|
|
||||||
import type { Config } from "../config.js";
|
import type {
|
||||||
import type { ExportedGame, LibraryExport } from "../export-schema.js";
|
Config,
|
||||||
import type { Artwork, ProviderEntryInput } from "../wire.js";
|
ExportedGame,
|
||||||
|
LibraryExport,
|
||||||
|
SyncReport,
|
||||||
|
} from "@playnite/contract";
|
||||||
|
import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
|
||||||
|
|
||||||
export interface SkippedGame {
|
/** Playnite ids are .NET Guids. Validate the shape before interpolating into a launch
|
||||||
id: string;
|
* command — the id comes from our own exporter, but the guard is cheap and keeps the
|
||||||
title: string;
|
* command line unambiguous. */
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SyncReport {
|
|
||||||
/** ISO timestamp the exporter stamped on the source export. */
|
|
||||||
generatedAt: string;
|
|
||||||
/** Games in the export before filtering. */
|
|
||||||
considered: number;
|
|
||||||
/** Entries in the reconcile payload. */
|
|
||||||
included: number;
|
|
||||||
skipped: SkippedGame[];
|
|
||||||
warnings: string[];
|
|
||||||
/** Count of included entries per Playnite source ("Steam", "GOG", "(none)"…). */
|
|
||||||
perSource: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Playnite ids are .NET Guids. Validate the shape before interpolating into a launch command — the
|
|
||||||
* id comes from our own exporter, but the guard is cheap and keeps the command line unambiguous. */
|
|
||||||
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
export const isGuid = (id: string): boolean => GUID.test(id);
|
export const isGuid = (id: string): boolean => GUID.test(id);
|
||||||
|
|
||||||
/** The launch command for a Playnite game: hand the `playnite://` URI to Playnite so IT performs the
|
/** The launch command for a Playnite game: hand the `playnite://` URI to Playnite so IT
|
||||||
* real launch (any store or emulator, uniformly). The host runs `command` values via
|
* performs the real launch (any store or emulator, uniformly). The host runs `command`
|
||||||
* `cmd.exe /c <value>` in the interactive session, so `start "" "<uri>"` invokes the URI handler. */
|
* values via `cmd.exe /c <value>` in the interactive session, so `start "" "<uri>"`
|
||||||
|
* invokes the URI handler. */
|
||||||
export const launchCommand = (
|
export const launchCommand = (
|
||||||
id: string,
|
id: string,
|
||||||
platform: NodeJS.Platform = process.platform,
|
platform: NodeJS.Platform = process.platform,
|
||||||
): ProviderEntryInput["launch"] => {
|
): ProviderEntry["launch"] => {
|
||||||
const uri = `playnite://playnite/start/${id}`;
|
const uri = `playnite://playnite/start/${id}`;
|
||||||
// Playnite is Windows-only; the Windows host is the supported target. `start ""` gives cmd a
|
// Playnite is Windows-only; the Windows host is the supported target. `start ""` gives
|
||||||
// (empty) window title so a quoted URI isn't mistaken for one.
|
// cmd an (empty) window title so a quoted URI isn't mistaken for one.
|
||||||
if (platform === "win32")
|
if (platform === "win32") {
|
||||||
return { kind: "command", value: `start "" "${uri}"` };
|
return { kind: "command", value: `start "" "${uri}"` };
|
||||||
// Best-effort elsewhere (Wine/portable): a desktop opener resolves the registered handler.
|
}
|
||||||
|
// Best-effort elsewhere (Wine/portable): a desktop opener resolves the handler.
|
||||||
return { kind: "command", value: `xdg-open "${uri}"` };
|
return { kind: "command", value: `xdg-open "${uri}"` };
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourceKey = (g: ExportedGame): string => g.source ?? "(none)";
|
const sourceKey = (g: ExportedGame): string => g.source ?? "(none)";
|
||||||
|
|
||||||
export interface ComputeInput {
|
export interface ComputeInput {
|
||||||
exp: LibraryExport;
|
readonly exp: LibraryExport;
|
||||||
config: Config;
|
readonly config: Config;
|
||||||
platform?: NodeJS.Platform;
|
readonly platform?: NodeJS.Platform;
|
||||||
/** Art builder (IO lives in the engine; tests pass a stub). */
|
/** Art builder (IO lives in the engine; tests pass a stub). */
|
||||||
artFor: (game: ExportedGame) => Artwork | undefined;
|
readonly artFor: (game: ExportedGame) => Artwork | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComputeResult {
|
export interface ComputeResult {
|
||||||
entries: ProviderEntryInput[];
|
readonly entries: ProviderEntry[];
|
||||||
report: SyncReport;
|
readonly report: SyncReport;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** In `dataurl` art mode only: warn above this many entries, since each inlined cover adds to the
|
/** In `dataurl` art mode only: warn above this many entries, since each inlined cover adds
|
||||||
* reconcile payload and a large library blows past the host's body limit (see README "Box art").
|
* to the reconcile payload and a large library blows past the host's body limit. `host`
|
||||||
* `host` mode (the default) sends paths, not bytes, so it has no such ceiling. */
|
* mode (the default) sends references, not bytes, so it has no such ceiling. */
|
||||||
const WARN_ENTRIES = 3000;
|
const WARN_ENTRIES = 3000;
|
||||||
|
|
||||||
export const computeEntries = ({
|
export const computeEntries = ({
|
||||||
@@ -78,13 +66,14 @@ export const computeEntries = ({
|
|||||||
filter.excludeSources.map((s) => s.toLowerCase()),
|
filter.excludeSources.map((s) => s.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
const entries: ProviderEntryInput[] = [];
|
const entries: ProviderEntry[] = [];
|
||||||
const skipped: SkippedGame[] = [];
|
const skipped: SyncReport["skipped"][number][] = [];
|
||||||
|
const excluded: SyncReport["excluded"][number][] = [];
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const perSource: Record<string, number> = {};
|
const perSource: Record<string, number> = {};
|
||||||
|
|
||||||
for (const g of exp.games) {
|
for (const g of exp.games) {
|
||||||
const title = (g.name ?? "").trim();
|
const title = g.name.trim();
|
||||||
const skip = (reason: string) =>
|
const skip = (reason: string) =>
|
||||||
skipped.push({ id: g.id, title: title || g.id, reason });
|
skipped.push({ id: g.id, title: title || g.id, reason });
|
||||||
|
|
||||||
@@ -113,15 +102,21 @@ export const computeEntries = ({
|
|||||||
skip(`source "${g.source ?? "(none)"}" excluded`);
|
skip(`source "${g.source ?? "(none)"}" excluded`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Last, so `excluded` only ever holds games that would OTHERWISE be included —
|
||||||
|
// what the Library page offers a "re-include" button for.
|
||||||
|
if (config.gameOverrides[g.id]?.exclude === true) {
|
||||||
|
excluded.push({ id: g.id, title });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const entry: ProviderEntryInput = {
|
const art = artFor(g);
|
||||||
|
entries.push({
|
||||||
external_id: g.id,
|
external_id: g.id,
|
||||||
title,
|
title,
|
||||||
launch: launchCommand(g.id, platform),
|
launch: launchCommand(g.id, platform),
|
||||||
};
|
// `art` is an optionalKey — omit it, never set it to undefined.
|
||||||
const art = artFor(g);
|
...(art ? { art } : {}),
|
||||||
if (art) entry.art = art;
|
});
|
||||||
entries.push(entry);
|
|
||||||
const key = sourceKey(g);
|
const key = sourceKey(g);
|
||||||
perSource[key] = (perSource[key] ?? 0) + 1;
|
perSource[key] = (perSource[key] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
@@ -139,9 +134,11 @@ export const computeEntries = ({
|
|||||||
entries,
|
entries,
|
||||||
report: {
|
report: {
|
||||||
generatedAt: exp.generatedAt,
|
generatedAt: exp.generatedAt,
|
||||||
|
schemaVersion: exp.schema,
|
||||||
considered: exp.games.length,
|
considered: exp.games.length,
|
||||||
included: entries.length,
|
included: entries.length,
|
||||||
skipped,
|
skipped,
|
||||||
|
excluded,
|
||||||
warnings,
|
warnings,
|
||||||
perSource,
|
perSource,
|
||||||
},
|
},
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// The plugin entry — the default export the runner discovers. Everything is Effect inside
|
||||||
|
// definePluginKit's async-main boundary (see @punktfunk/plugin-kit): start the sync engine,
|
||||||
|
// serve the console UI, park until interruption.
|
||||||
|
//
|
||||||
|
// Provider entries are deliberately LEFT in the host library on shutdown, so the games
|
||||||
|
// survive plugin restarts and host reboots; a clean uninstall is the explicit CLI command.
|
||||||
|
import { definePluginKit, serveUi } from "@punktfunk/plugin-kit";
|
||||||
|
import { Effect } from "effect";
|
||||||
|
import pkg from "../package.json" with { type: "json" };
|
||||||
|
import { PLUGIN_NAME, UI_ICON, UI_TITLE } from "./const.js";
|
||||||
|
import { playniteLayer } from "./layer.js";
|
||||||
|
import { makeApi } from "./services/api.js";
|
||||||
|
import { PlayniteConfig } from "./services/config.js";
|
||||||
|
import { PlayniteSync } from "./services/engine.js";
|
||||||
|
|
||||||
|
export { PLUGIN_NAME, PROVIDER_ID, UI_ICON, UI_TITLE } from "./const.js";
|
||||||
|
|
||||||
|
const plugin = definePluginKit({
|
||||||
|
name: PLUGIN_NAME,
|
||||||
|
version: pkg.version,
|
||||||
|
layer: playniteLayer,
|
||||||
|
main: Effect.gen(function* () {
|
||||||
|
const sync = yield* PlayniteSync;
|
||||||
|
const config = yield* PlayniteConfig;
|
||||||
|
|
||||||
|
// Headless sync first — the library reconciles even if the UI can't come up.
|
||||||
|
yield* sync.engine.start;
|
||||||
|
|
||||||
|
yield* serveUi({
|
||||||
|
title: UI_TITLE,
|
||||||
|
icon: UI_ICON,
|
||||||
|
staticDir: new URL("./ui/", import.meta.url),
|
||||||
|
api: makeApi({ sync, config }),
|
||||||
|
}).pipe(
|
||||||
|
Effect.catch((e) =>
|
||||||
|
Effect.logWarning(
|
||||||
|
`ui: could not serve the console surface (${e.cause}) — engine continues headless`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
yield* Effect.never;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// The plugin's service graph over the kit base (HostClient | PluginInfo) — shared by the
|
||||||
|
// runner entry (index.ts), the CLI, and the dev server so they all run the same engine.
|
||||||
|
|
||||||
|
import type { HostClient } from "@punktfunk/plugin-kit";
|
||||||
|
import { type PluginInfo, ProviderClient } from "@punktfunk/plugin-kit";
|
||||||
|
import { Layer } from "effect";
|
||||||
|
import { PlayniteCache } from "./services/cache.js";
|
||||||
|
import { PlayniteConfig } from "./services/config.js";
|
||||||
|
import { PlayniteSync } from "./services/engine.js";
|
||||||
|
|
||||||
|
export type PlayniteServices =
|
||||||
|
| PlayniteConfig
|
||||||
|
| PlayniteCache
|
||||||
|
| PlayniteSync
|
||||||
|
| ProviderClient;
|
||||||
|
|
||||||
|
export const playniteLayer: Layer.Layer<
|
||||||
|
PlayniteServices,
|
||||||
|
never,
|
||||||
|
HostClient | PluginInfo
|
||||||
|
> = PlayniteSync.layer.pipe(
|
||||||
|
Layer.provideMerge(
|
||||||
|
Layer.mergeAll(
|
||||||
|
PlayniteConfig.layer,
|
||||||
|
PlayniteCache.layer,
|
||||||
|
ProviderClient.layer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// The HttpApi implementation + the two non-JSON routes (SSE status feed, cover-art
|
||||||
|
// proxy), composed as the Layer `serveUi` mounts. Built from live service VALUES (not
|
||||||
|
// layers) so the handler runtime shares the plugin runtime's singletons — one engine, one
|
||||||
|
// config service.
|
||||||
|
import {
|
||||||
|
ART_PATH,
|
||||||
|
ConfigInvalid,
|
||||||
|
EVENTS_EVENT,
|
||||||
|
EVENTS_PATH,
|
||||||
|
ExportUnavailable,
|
||||||
|
PlayniteApi,
|
||||||
|
type PlayniteConfigSchema,
|
||||||
|
SyncInProgress,
|
||||||
|
} from "@playnite/contract";
|
||||||
|
import {
|
||||||
|
type ConfigService,
|
||||||
|
httpApiEnv,
|
||||||
|
sseRoute,
|
||||||
|
} from "@punktfunk/plugin-kit";
|
||||||
|
import { Effect, Layer } from "effect";
|
||||||
|
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
||||||
|
import { HttpApiBuilder } from "effect/unstable/httpapi";
|
||||||
|
import type { PlayniteSyncService } from "./engine.js";
|
||||||
|
|
||||||
|
export interface ApiDeps {
|
||||||
|
readonly sync: PlayniteSyncService;
|
||||||
|
readonly config: ConfigService<typeof PlayniteConfigSchema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/art?path=<abs>` — the cover-art proxy. Playnite art is local files on the
|
||||||
|
* host, which the SPA cannot load, so it asks us for the bytes. The engine only answers
|
||||||
|
* for a path the CURRENT export references (see `artPaths`), so this is an allow-list
|
||||||
|
* lookup, not an arbitrary file read; anything else is a flat 404.
|
||||||
|
*/
|
||||||
|
const artRoute = (
|
||||||
|
deps: ApiDeps,
|
||||||
|
): Layer.Layer<never, never, HttpRouter.HttpRouter> =>
|
||||||
|
HttpRouter.add("GET", ART_PATH, (request) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = new URL(request.url, "http://localhost").searchParams.get(
|
||||||
|
"path",
|
||||||
|
);
|
||||||
|
if (file === null || file === "") {
|
||||||
|
return HttpServerResponse.empty({ status: 400 });
|
||||||
|
}
|
||||||
|
const art = yield* deps.sync.art(file);
|
||||||
|
if (art === undefined) return HttpServerResponse.empty({ status: 404 });
|
||||||
|
return HttpServerResponse.uint8Array(art.bytes, {
|
||||||
|
contentType: art.contentType,
|
||||||
|
// Playnite art files are content-addressed by Playnite; the path changes
|
||||||
|
// when the picture does, so a long private cache is safe.
|
||||||
|
headers: { "cache-control": "private, max-age=3600" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const makeApi = (
|
||||||
|
deps: ApiDeps,
|
||||||
|
): Layer.Layer<never, never, HttpRouter.HttpRouter> => {
|
||||||
|
const statusGroup = HttpApiBuilder.group(PlayniteApi, "status", (h) =>
|
||||||
|
h.handle("get", () => deps.sync.status),
|
||||||
|
);
|
||||||
|
|
||||||
|
const configGroup = HttpApiBuilder.group(PlayniteApi, "config", (h) =>
|
||||||
|
h
|
||||||
|
.handle("get", () =>
|
||||||
|
deps.config.loadRaw.pipe(
|
||||||
|
Effect.map((raw) => ({ raw: raw as unknown })),
|
||||||
|
Effect.orDie,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.handle("put", ({ payload }) =>
|
||||||
|
deps.config.saveRaw(payload).pipe(
|
||||||
|
Effect.mapError((e) => new ConfigInvalid({ issues: String(e) })),
|
||||||
|
// Loops restart + resync in the background; the PUT answers immediately.
|
||||||
|
Effect.tap(() => Effect.forkDetach(deps.sync.engine.reconfigure)),
|
||||||
|
Effect.map(() => ({ raw: payload as unknown })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const libraryGroup = HttpApiBuilder.group(PlayniteApi, "library", (h) =>
|
||||||
|
h.handle("preview", () => deps.sync.preview),
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncGroup = HttpApiBuilder.group(PlayniteApi, "sync", (h) =>
|
||||||
|
h.handle("run", () =>
|
||||||
|
deps.sync.engine.sync("manual").pipe(
|
||||||
|
// A SyncError wraps whatever compute/apply failed with; an unreadable export
|
||||||
|
// is the one case the UI can act on, so it stays a typed 503.
|
||||||
|
Effect.mapError((e) =>
|
||||||
|
e.cause instanceof ExportUnavailable
|
||||||
|
? e.cause
|
||||||
|
: new ExportUnavailable({ message: String(e.cause) }),
|
||||||
|
),
|
||||||
|
Effect.flatMap((outcome) =>
|
||||||
|
outcome._tag === "AlreadyRunning"
|
||||||
|
? Effect.fail(new SyncInProgress())
|
||||||
|
: Effect.succeed(outcome.report),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Layer.mergeAll(
|
||||||
|
HttpApiBuilder.layer(PlayniteApi).pipe(
|
||||||
|
Layer.provide(
|
||||||
|
Layer.mergeAll(statusGroup, configGroup, libraryGroup, syncGroup),
|
||||||
|
),
|
||||||
|
Layer.provide(httpApiEnv),
|
||||||
|
),
|
||||||
|
// EngineStatus is an identity codec (plain JSON shape) — default JSON encode.
|
||||||
|
sseRoute(EVENTS_PATH, deps.sync.statusChanges, { event: EVENTS_EVENT }),
|
||||||
|
artRoute(deps),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// The plugin's disposable derived state (cache.json), on the kit's CacheStore. Playnite's
|
||||||
|
// library IS the source of truth and lives one process away, so there is nothing to cache
|
||||||
|
// but the last reconcile fingerprint — an unchanged export then costs one read and no PUT.
|
||||||
|
// Corrupt/absent → empty; every mutation is write-through.
|
||||||
|
import { LastSync } from "@playnite/contract";
|
||||||
|
import {
|
||||||
|
type CacheStore,
|
||||||
|
makeCacheStore,
|
||||||
|
type PluginInfo,
|
||||||
|
} from "@punktfunk/plugin-kit";
|
||||||
|
import { Context, Layer, Schema } from "effect";
|
||||||
|
|
||||||
|
export const CacheSchema = Schema.Struct({
|
||||||
|
lastSync: Schema.optionalKey(LastSync),
|
||||||
|
});
|
||||||
|
export type Cache = typeof CacheSchema.Type;
|
||||||
|
|
||||||
|
export const emptyCache: Cache = {};
|
||||||
|
|
||||||
|
export class PlayniteCache extends Context.Service<
|
||||||
|
PlayniteCache,
|
||||||
|
CacheStore<typeof CacheSchema>
|
||||||
|
>()("playnite/PlayniteCache") {
|
||||||
|
static readonly layer: Layer.Layer<PlayniteCache, never, PluginInfo> =
|
||||||
|
Layer.effect(PlayniteCache)(
|
||||||
|
makeCacheStore({ schema: CacheSchema, empty: emptyCache }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// The plugin's config service — the kit's ConfigService over the contract schema.
|
||||||
|
// Raw round-trip by construction: the file on disk is always the authored shape.
|
||||||
|
import { PlayniteConfigSchema } from "@playnite/contract";
|
||||||
|
import {
|
||||||
|
type ConfigService,
|
||||||
|
makeConfigService,
|
||||||
|
type PluginInfo,
|
||||||
|
} from "@punktfunk/plugin-kit";
|
||||||
|
import { Context, Layer } from "effect";
|
||||||
|
|
||||||
|
export class PlayniteConfig extends Context.Service<
|
||||||
|
PlayniteConfig,
|
||||||
|
ConfigService<typeof PlayniteConfigSchema>
|
||||||
|
>()("playnite/PlayniteConfig") {
|
||||||
|
static readonly layer: Layer.Layer<PlayniteConfig, never, PluginInfo> =
|
||||||
|
Layer.effect(PlayniteConfig)(
|
||||||
|
makeConfigService({ schema: PlayniteConfigSchema }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,9 +2,8 @@ import { afterAll, describe, expect, test } from "bun:test";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { buildArtwork, fileToDataUrl } from "../src/art.js";
|
import type { ArtOptions, ExportedGame } from "@playnite/contract";
|
||||||
import { DEFAULT_ART } from "../src/config.js";
|
import { artPaths, buildArtwork, fileToDataUrl } from "../src/domain/art.js";
|
||||||
import type { ExportedGame } from "../src/export-schema.js";
|
|
||||||
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-art-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-art-"));
|
||||||
const write = (name: string, bytes: Buffer): string => {
|
const write = (name: string, bytes: Buffer): string => {
|
||||||
@@ -14,6 +13,12 @@ const write = (name: string, bytes: Buffer): string => {
|
|||||||
};
|
};
|
||||||
afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));
|
afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const DEFAULT_ART: ArtOptions = {
|
||||||
|
mode: "host",
|
||||||
|
maxBytes: 1_500_000,
|
||||||
|
includeBackground: false,
|
||||||
|
};
|
||||||
|
|
||||||
const game = (over: Partial<ExportedGame>): ExportedGame => ({
|
const game = (over: Partial<ExportedGame>): ExportedGame => ({
|
||||||
id: "11111111-1111-1111-1111-111111111111",
|
id: "11111111-1111-1111-1111-111111111111",
|
||||||
name: "Game",
|
name: "Game",
|
||||||
@@ -49,6 +54,11 @@ describe("fileToDataUrl", () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
test("a remote reference passes through untouched", () => {
|
||||||
|
expect(fileToDataUrl("https://cdn.example/cover.jpg", 10)).toBe(
|
||||||
|
"https://cdn.example/cover.jpg",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildArtwork", () => {
|
describe("buildArtwork", () => {
|
||||||
@@ -106,3 +116,41 @@ describe("buildArtwork", () => {
|
|||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("artPaths (the /api/art allow-list)", () => {
|
||||||
|
const exp = (games: ExportedGame[]) => ({
|
||||||
|
schema: 1,
|
||||||
|
generatedAt: "2026-01-01T00:00:00Z",
|
||||||
|
playnite: { version: "10", mode: "Desktop" },
|
||||||
|
games,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collects every local cover/background/icon the export references", () => {
|
||||||
|
const paths = artPaths(
|
||||||
|
exp([
|
||||||
|
game({ cover: "/a/cover.png", background: "/a/bg.jpg" }),
|
||||||
|
game({ icon: "/a/icon.ico" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect([...paths].sort()).toEqual([
|
||||||
|
"/a/bg.jpg",
|
||||||
|
"/a/cover.png",
|
||||||
|
"/a/icon.ico",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes remote references and unservable extensions", () => {
|
||||||
|
const paths = artPaths(
|
||||||
|
exp([
|
||||||
|
game({ cover: "https://cdn.example/cover.jpg" }),
|
||||||
|
game({ cover: "/a/notes.txt" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(paths.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a path the export never mentions is not allow-listed", () => {
|
||||||
|
const paths = artPaths(exp([game({ cover: "/a/cover.png" })]));
|
||||||
|
expect(paths.has("/etc/shadow")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// The config contract: defaults live ONLY in the schema, and the Encoded side is the
|
||||||
|
// authored file shape. A regression here would let a UI save bake defaults into the
|
||||||
|
// operator's config.json (the raw round-trip invariant the kit's ConfigService relies on).
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { RawConfig } from "@playnite/contract";
|
||||||
|
import { PlayniteConfigSchema, resolveConfig } from "@playnite/contract";
|
||||||
|
import { Schema } from "effect";
|
||||||
|
|
||||||
|
const encode = Schema.encodeUnknownSync(PlayniteConfigSchema);
|
||||||
|
|
||||||
|
describe("resolveConfig", () => {
|
||||||
|
test("an empty file resolves to the documented defaults", () => {
|
||||||
|
expect(resolveConfig({})).toEqual({
|
||||||
|
sync: { pollMinutes: 5, watch: true, debounceMs: 1500 },
|
||||||
|
filter: {
|
||||||
|
installedOnly: true,
|
||||||
|
includeHidden: false,
|
||||||
|
sources: [],
|
||||||
|
excludeSources: [],
|
||||||
|
},
|
||||||
|
art: { mode: "host", maxBytes: 1_500_000, includeBackground: false },
|
||||||
|
gameOverrides: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a partial group keeps the untouched keys at their defaults", () => {
|
||||||
|
const config = resolveConfig({ art: { mode: "dataurl" } });
|
||||||
|
expect(config.art.mode).toBe("dataurl");
|
||||||
|
expect(config.art.maxBytes).toBe(1_500_000);
|
||||||
|
expect(config.sync.pollMinutes).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stale keys from an older config are tolerated and dropped", () => {
|
||||||
|
// 0.1.x wrote `ui` and `devEntry`; both are gone in 0.2.0.
|
||||||
|
const config = resolveConfig({
|
||||||
|
ui: { standalone: true, port: 47994 },
|
||||||
|
devEntry: true,
|
||||||
|
filter: { installedOnly: false },
|
||||||
|
});
|
||||||
|
expect(config.filter.installedOnly).toBe(false);
|
||||||
|
expect("ui" in config).toBe(false);
|
||||||
|
expect("devEntry" in config).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an invalid art mode is refused", () => {
|
||||||
|
expect(() => resolveConfig({ art: { mode: "inline" } })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("raw round-trip", () => {
|
||||||
|
test("encoding a resolved config omits every defaulted key", () => {
|
||||||
|
// This is what makes a UI save non-destructive: defaults never reach the file.
|
||||||
|
expect(encode(resolveConfig({}))).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("encode is LOSSY for defaulted groups — which is why we never re-encode", () => {
|
||||||
|
// `encodingStrategy: "omit"` drops a defaulted key unconditionally, so a decode →
|
||||||
|
// encode round-trip would silently throw away authored values. The kit's
|
||||||
|
// ConfigService therefore persists the RAW payload verbatim and only *validates*
|
||||||
|
// by decoding; nothing in the plugin may write `encode(config)` back to disk.
|
||||||
|
const raw: RawConfig = {
|
||||||
|
playniteDir: "D:\\Playnite",
|
||||||
|
filter: { excludeSources: ["Xbox"] },
|
||||||
|
gameOverrides: {
|
||||||
|
"11111111-1111-1111-1111-111111111111": { exclude: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Undefaulted keys survive…
|
||||||
|
expect(encode(resolveConfig(raw))).toEqual({ playniteDir: "D:\\Playnite" });
|
||||||
|
// …but the authored values themselves are only safe in the raw shape.
|
||||||
|
expect(resolveConfig(raw).filter.excludeSources).toEqual(["Xbox"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,8 @@ import { afterAll, describe, expect, test } from "bun:test";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { resolveConfig } from "../src/config.js";
|
import { EXPORT_FILE, resolveConfig } from "@playnite/contract";
|
||||||
import { EXPORT_FILE } from "../src/export-schema.js";
|
import { ExportError, locateExport, readExport } from "../src/domain/locate.js";
|
||||||
import { ExportError, locateExport, readExport } from "../src/playnite.js";
|
|
||||||
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-loc-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-loc-"));
|
||||||
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
|
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
@@ -14,8 +13,9 @@ const layout = (name: string, doc: unknown): string => {
|
|||||||
const dir = path.join(root, name);
|
const dir = path.join(root, name);
|
||||||
const ext = path.join(dir, "ExtensionsData", "abc-guid");
|
const ext = path.join(dir, "ExtensionsData", "abc-guid");
|
||||||
fs.mkdirSync(ext, { recursive: true });
|
fs.mkdirSync(ext, { recursive: true });
|
||||||
if (doc !== undefined)
|
if (doc !== undefined) {
|
||||||
fs.writeFileSync(path.join(ext, EXPORT_FILE), JSON.stringify(doc));
|
fs.writeFileSync(path.join(ext, EXPORT_FILE), JSON.stringify(doc));
|
||||||
|
}
|
||||||
return dir;
|
return dir;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ describe("locateExport", () => {
|
|||||||
path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE),
|
path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE),
|
||||||
);
|
);
|
||||||
expect(loc.mtime).toBeGreaterThan(0);
|
expect(loc.mtime).toBeGreaterThan(0);
|
||||||
|
expect(loc.viaIngest).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reports the dir but no file when the exporter hasn't written yet", () => {
|
test("reports the dir but no file when the exporter hasn't written yet", () => {
|
||||||
@@ -58,6 +59,9 @@ describe("locateExport", () => {
|
|||||||
const loc = locateExport(resolveConfig({ playniteDir: pd }));
|
const loc = locateExport(resolveConfig({ playniteDir: pd }));
|
||||||
expect(loc.dir).toBe(inbox);
|
expect(loc.dir).toBe(inbox);
|
||||||
expect(loc.file).toBe(path.join(inbox, EXPORT_FILE));
|
expect(loc.file).toBe(path.join(inbox, EXPORT_FILE));
|
||||||
|
expect(loc.viaIngest).toBe(true);
|
||||||
|
// The inbox always leads the probe list, so the UI can show it as the target.
|
||||||
|
expect(loc.candidates[0]).toBe(inbox);
|
||||||
} finally {
|
} finally {
|
||||||
if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
|
if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
|
||||||
else process.env.PUNKTFUNK_CONFIG_DIR = prev;
|
else process.env.PUNKTFUNK_CONFIG_DIR = prev;
|
||||||
@@ -73,6 +77,25 @@ describe("readExport", () => {
|
|||||||
expect(readExport(file).schema).toBe(1);
|
expect(readExport(file).schema).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fills defaults for fields an older exporter omits", () => {
|
||||||
|
const dir = layout("sparse", { schema: 1, games: [{ id: "abc" }] });
|
||||||
|
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
|
||||||
|
const exp = readExport(file);
|
||||||
|
expect(exp.generatedAt).toBe("");
|
||||||
|
expect(exp.playnite).toEqual({ version: null, mode: null });
|
||||||
|
expect(exp.games[0]).toEqual({
|
||||||
|
id: "abc",
|
||||||
|
name: "",
|
||||||
|
installed: false,
|
||||||
|
hidden: false,
|
||||||
|
source: null,
|
||||||
|
platforms: [],
|
||||||
|
cover: null,
|
||||||
|
background: null,
|
||||||
|
icon: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects a future schema", () => {
|
test("rejects a future schema", () => {
|
||||||
const dir = layout("future", { ...validDoc, schema: 99 });
|
const dir = layout("future", { ...validDoc, schema: 99 });
|
||||||
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
|
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
|
||||||
@@ -88,4 +111,8 @@ describe("readExport", () => {
|
|||||||
fs.writeFileSync(foreign, JSON.stringify({ hello: "world" }));
|
fs.writeFileSync(foreign, JSON.stringify({ hello: "world" }));
|
||||||
expect(() => readExport(foreign)).toThrow(ExportError);
|
expect(() => readExport(foreign)).toThrow(ExportError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects a missing file", () => {
|
||||||
|
expect(() => readExport(path.join(root, "nope.json"))).toThrow(ExportError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { resolveConfig } from "../src/config.js";
|
import type { ExportedGame, LibraryExport } from "@playnite/contract";
|
||||||
|
import { resolveConfig } from "@playnite/contract";
|
||||||
import {
|
import {
|
||||||
computeEntries,
|
computeEntries,
|
||||||
isGuid,
|
isGuid,
|
||||||
launchCommand,
|
launchCommand,
|
||||||
} from "../src/engine/reconcile.js";
|
} from "../src/domain/reconcile.js";
|
||||||
import type { ExportedGame, LibraryExport } from "../src/export-schema.js";
|
|
||||||
|
|
||||||
const G1 = "11111111-1111-1111-1111-111111111111";
|
const G1 = "11111111-1111-1111-1111-111111111111";
|
||||||
const G2 = "22222222-2222-2222-2222-222222222222";
|
const G2 = "22222222-2222-2222-2222-222222222222";
|
||||||
@@ -56,10 +56,9 @@ describe("launchCommand", () => {
|
|||||||
|
|
||||||
describe("computeEntries filtering", () => {
|
describe("computeEntries filtering", () => {
|
||||||
test("installedOnly drops uninstalled games", () => {
|
test("installedOnly drops uninstalled games", () => {
|
||||||
const config = resolveConfig({});
|
|
||||||
const { entries, report } = computeEntries({
|
const { entries, report } = computeEntries({
|
||||||
exp: exp([game({ id: G1 }), game({ id: G2, installed: false })]),
|
exp: exp([game({ id: G1 }), game({ id: G2, installed: false })]),
|
||||||
config,
|
config: resolveConfig({}),
|
||||||
platform: "win32",
|
platform: "win32",
|
||||||
artFor: noArt,
|
artFor: noArt,
|
||||||
});
|
});
|
||||||
@@ -146,4 +145,82 @@ describe("computeEntries filtering", () => {
|
|||||||
});
|
});
|
||||||
expect(entries[0]?.art?.portrait).toBe("data:image/png;base64,AAAA");
|
expect(entries[0]?.art?.portrait).toBe("data:image/png;base64,AAAA");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the report carries the export's stamp", () => {
|
||||||
|
const { report } = computeEntries({
|
||||||
|
exp: exp([game({ id: G1 })]),
|
||||||
|
config: resolveConfig({}),
|
||||||
|
platform: "win32",
|
||||||
|
artFor: noArt,
|
||||||
|
});
|
||||||
|
expect(report.generatedAt).toBe("2026-01-01T00:00:00Z");
|
||||||
|
expect(report.schemaVersion).toBe(1);
|
||||||
|
expect(report.considered).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("per-game overrides", () => {
|
||||||
|
test("an excluded game lands in `excluded`, not `skipped`", () => {
|
||||||
|
const { entries, report } = computeEntries({
|
||||||
|
exp: exp([
|
||||||
|
game({ id: G1, name: "Kept" }),
|
||||||
|
game({ id: G2, name: "Dropped" }),
|
||||||
|
]),
|
||||||
|
config: resolveConfig({ gameOverrides: { [G2]: { exclude: true } } }),
|
||||||
|
platform: "win32",
|
||||||
|
artFor: noArt,
|
||||||
|
});
|
||||||
|
expect(entries.map((e) => e.title)).toEqual(["Kept"]);
|
||||||
|
expect(report.excluded).toEqual([{ id: G2, title: "Dropped" }]);
|
||||||
|
expect(report.skipped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("`exclude: false` is a no-op (the UI omits the key instead)", () => {
|
||||||
|
const { entries } = computeEntries({
|
||||||
|
exp: exp([game({ id: G1 })]),
|
||||||
|
config: resolveConfig({ gameOverrides: { [G1]: { exclude: false } } }),
|
||||||
|
platform: "win32",
|
||||||
|
artFor: noArt,
|
||||||
|
});
|
||||||
|
expect(entries.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a game already filtered out is not double-reported as excluded", () => {
|
||||||
|
const { report } = computeEntries({
|
||||||
|
exp: exp([game({ id: G1, installed: false })]),
|
||||||
|
config: resolveConfig({ gameOverrides: { [G1]: { exclude: true } } }),
|
||||||
|
platform: "win32",
|
||||||
|
artFor: noArt,
|
||||||
|
});
|
||||||
|
expect(report.excluded).toEqual([]);
|
||||||
|
expect(report.skipped[0]?.reason).toBe("not installed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dataurl guard rail", () => {
|
||||||
|
test("warns once the inlined-cover payload would be too large", () => {
|
||||||
|
const many = Array.from({ length: 3001 }, (_, i) =>
|
||||||
|
game({
|
||||||
|
id: `${i.toString(16).padStart(8, "0")}-1111-1111-1111-111111111111`,
|
||||||
|
name: `Game ${i}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { report } = computeEntries({
|
||||||
|
exp: exp(many),
|
||||||
|
config: resolveConfig({ art: { mode: "dataurl" } }),
|
||||||
|
platform: "win32",
|
||||||
|
artFor: noArt,
|
||||||
|
});
|
||||||
|
expect(report.warnings[0]).toContain('art.mode "dataurl"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("host mode never warns", () => {
|
||||||
|
const { report } = computeEntries({
|
||||||
|
exp: exp([game({ id: G1 })]),
|
||||||
|
config: resolveConfig({ art: { mode: "host" } }),
|
||||||
|
platform: "win32",
|
||||||
|
artFor: noArt,
|
||||||
|
});
|
||||||
|
expect(report.warnings).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"jsx": "react-jsx",
|
"lib": ["ES2022"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": []
|
"resolveJsonModule": true,
|
||||||
|
"types": ["bun"]
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts"]
|
"include": ["src", "test"]
|
||||||
}
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// The published type surface: a plugin is a runtime artifact — the only thing anyone
|
||||||
|
// imports from the package is the default PluginDef (the runner's discovery contract).
|
||||||
|
import type { PluginDef } from "@punktfunk/host";
|
||||||
|
|
||||||
|
declare const plugin: PluginDef;
|
||||||
|
export default plugin;
|
||||||
-70
@@ -1,70 +0,0 @@
|
|||||||
// Cover art. Playnite stores art as local files on the host. Two delivery modes (see ArtMode):
|
|
||||||
// - `host` (default): emit the local file PATH; the updated host serves the bytes via its art proxy
|
|
||||||
// (`/library/art/…`), so the reconcile payload carries no image data and scales to any library size.
|
|
||||||
// - `dataurl`: inline the cover as a size-capped `data:` URL (self-contained, but only fit for small
|
|
||||||
// libraries — a big one blows past the host's reconcile body limit). `maxBytes` caps a single image.
|
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import type { ArtOptions } from "./config.js";
|
|
||||||
import type { ExportedGame } from "./export-schema.js";
|
|
||||||
import type { Artwork } from "./wire.js";
|
|
||||||
|
|
||||||
const MIME: Record<string, string> = {
|
|
||||||
".jpg": "image/jpeg",
|
|
||||||
".jpeg": "image/jpeg",
|
|
||||||
".png": "image/png",
|
|
||||||
".webp": "image/webp",
|
|
||||||
".gif": "image/gif",
|
|
||||||
".bmp": "image/bmp",
|
|
||||||
".ico": "image/x-icon",
|
|
||||||
".tga": "image/x-tga",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mimeFor = (file: string): string | null =>
|
|
||||||
MIME[path.extname(file).toLowerCase()] ?? null;
|
|
||||||
|
|
||||||
/** Read a local image file into a `data:` URL, or null if missing, oversized, or an unknown type. */
|
|
||||||
export const fileToDataUrl = (
|
|
||||||
file: string | null,
|
|
||||||
maxBytes: number,
|
|
||||||
): string | null => {
|
|
||||||
if (!file) return null;
|
|
||||||
const mime = mimeFor(file);
|
|
||||||
if (!mime) return null;
|
|
||||||
try {
|
|
||||||
const st = fs.statSync(file);
|
|
||||||
if (!st.isFile() || st.size === 0 || st.size > maxBytes) return null;
|
|
||||||
const b64 = fs.readFileSync(file).toString("base64");
|
|
||||||
return `data:${mime};base64,${b64}`;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Build the artwork block for a game per the art config. Returns undefined when nothing was embedded. */
|
|
||||||
export const buildArtwork = (
|
|
||||||
game: ExportedGame,
|
|
||||||
art: ArtOptions,
|
|
||||||
): Artwork | undefined => {
|
|
||||||
if (art.mode === "off") return undefined;
|
|
||||||
// `host` mode: emit the local file paths verbatim (the exporter only sets a path when the file
|
|
||||||
// exists). The host serves the bytes via its art proxy, so the reconcile payload carries no image
|
|
||||||
// data and scales to any library size. The default.
|
|
||||||
if (art.mode === "host") {
|
|
||||||
const out: Artwork = {};
|
|
||||||
if (game.cover) out.portrait = game.cover;
|
|
||||||
if (art.includeBackground && game.background) out.hero = game.background;
|
|
||||||
return out.portrait || out.hero ? out : undefined;
|
|
||||||
}
|
|
||||||
// `dataurl` mode: inline (size-capped) — self-contained but only fit for small libraries.
|
|
||||||
const portrait = fileToDataUrl(game.cover, art.maxBytes);
|
|
||||||
const hero = art.includeBackground
|
|
||||||
? fileToDataUrl(game.background, art.maxBytes)
|
|
||||||
: null;
|
|
||||||
if (!portrait && !hero) return undefined;
|
|
||||||
const out: Artwork = {};
|
|
||||||
if (portrait) out.portrait = portrait;
|
|
||||||
if (hero) out.hero = hero;
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
-126
@@ -1,126 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
// Dev/debug CLI — reuses the pure core and the engine. `where`/`preview` are offline (no host needed);
|
|
||||||
// `sync`/`uninstall` connect to the host and reconcile. Handy for configuring a headless box or
|
|
||||||
// checking what a `config.json` will produce before pushing it to the library.
|
|
||||||
//
|
|
||||||
// bunx punktfunk-plugin-playnite where # where the exporter output was found
|
|
||||||
// bunx punktfunk-plugin-playnite preview # the desired library (no host write)
|
|
||||||
// bunx punktfunk-plugin-playnite sync # reconcile into the live library
|
|
||||||
// bunx punktfunk-plugin-playnite uninstall # remove all this plugin's entries
|
|
||||||
// bunx punktfunk-plugin-playnite set-password <pw> # standalone-UI password
|
|
||||||
|
|
||||||
import { connect } from "@punktfunk/host";
|
|
||||||
import { Engine } from "./engine/index.js";
|
|
||||||
import { computeEntries } from "./engine/reconcile.js";
|
|
||||||
import { locateExport, readExport } from "./playnite.js";
|
|
||||||
import { hashPassword } from "./server/password.js";
|
|
||||||
import { loadConfig, saveConfig } from "./state.js";
|
|
||||||
|
|
||||||
const cmdWhere = (): void => {
|
|
||||||
const config = loadConfig();
|
|
||||||
const loc = locateExport(config);
|
|
||||||
console.log(`Playnite data dir: ${loc.dir ?? "(not found)"}`);
|
|
||||||
console.log(`Export file: ${loc.file ?? "(not found)"}`);
|
|
||||||
if (loc.mtime)
|
|
||||||
console.log(`Last written: ${new Date(loc.mtime).toISOString()}`);
|
|
||||||
console.log("Candidates probed:");
|
|
||||||
for (const c of loc.candidates) console.log(` ${c}`);
|
|
||||||
if (!loc.file) {
|
|
||||||
console.log(
|
|
||||||
"\nNo export found. Install the Punktfunk Sync extension in Playnite (see exporter/README.md),",
|
|
||||||
);
|
|
||||||
console.log("or set `playniteDir` in the plugin config.json.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cmdPreview = (): void => {
|
|
||||||
const config = loadConfig();
|
|
||||||
const loc = locateExport(config);
|
|
||||||
if (!loc.file) {
|
|
||||||
console.log("No exporter output found — run `where` for details.");
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const exp = readExport(loc.file);
|
|
||||||
const { entries, report } = computeEntries({
|
|
||||||
exp,
|
|
||||||
config,
|
|
||||||
artFor: () => undefined,
|
|
||||||
});
|
|
||||||
console.log(
|
|
||||||
`Preview: ${report.included}/${report.considered} game(s) would sync (${report.skipped.length} skipped).`,
|
|
||||||
);
|
|
||||||
const bySource = Object.entries(report.perSource).sort((a, b) => b[1] - a[1]);
|
|
||||||
for (const [s, n] of bySource) console.log(` ${s}: ${n}`);
|
|
||||||
console.log();
|
|
||||||
for (const e of entries.slice(0, 40))
|
|
||||||
console.log(` ${e.title} → ${e.launch?.value ?? "(no launch)"}`);
|
|
||||||
if (entries.length > 40) console.log(` … and ${entries.length - 40} more`);
|
|
||||||
if (report.skipped.length) {
|
|
||||||
console.log(`\n${report.skipped.length} skipped (first 20):`);
|
|
||||||
for (const s of report.skipped.slice(0, 20))
|
|
||||||
console.log(` ${s.title}: ${s.reason}`);
|
|
||||||
}
|
|
||||||
for (const w of report.warnings) console.log(`! ${w}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cmdSync = async (): Promise<void> => {
|
|
||||||
const pf = await connect();
|
|
||||||
try {
|
|
||||||
const report = await new Engine({ pf }).sync("cli");
|
|
||||||
if (report) {
|
|
||||||
console.log(
|
|
||||||
`Synced: ${report.included} title(s), ${report.skipped.length} skipped.`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log("Sync did not reconcile (no change, or see log).");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pf.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cmdUninstall = async (): Promise<void> => {
|
|
||||||
const pf = await connect();
|
|
||||||
try {
|
|
||||||
await new Engine({ pf }).uninstall();
|
|
||||||
console.log("Removed all playnite entries from the library.");
|
|
||||||
} finally {
|
|
||||||
pf.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cmdSetPassword = (password: string | undefined): void => {
|
|
||||||
if (!password) {
|
|
||||||
console.error("usage: set-password <password>");
|
|
||||||
process.exitCode = 2;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const config = loadConfig();
|
|
||||||
config.ui.passwordHash = hashPassword(password);
|
|
||||||
saveConfig(config);
|
|
||||||
console.log("Standalone-UI password set (config.ui.passwordHash).");
|
|
||||||
};
|
|
||||||
|
|
||||||
const main = async (): Promise<void> => {
|
|
||||||
const [cmd, arg] = process.argv.slice(2);
|
|
||||||
switch (cmd) {
|
|
||||||
case "where":
|
|
||||||
return cmdWhere();
|
|
||||||
case "preview":
|
|
||||||
return cmdPreview();
|
|
||||||
case "sync":
|
|
||||||
return cmdSync();
|
|
||||||
case "uninstall":
|
|
||||||
return cmdUninstall();
|
|
||||||
case "set-password":
|
|
||||||
return cmdSetPassword(arg);
|
|
||||||
default:
|
|
||||||
console.log(
|
|
||||||
"usage: punktfunk-plugin-playnite <where|preview|sync|uninstall|set-password>",
|
|
||||||
);
|
|
||||||
process.exitCode = cmd ? 2 : 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await main();
|
|
||||||
-117
@@ -1,117 +0,0 @@
|
|||||||
// The plugin's configuration model — the single source of truth for the sync engine, editable by hand
|
|
||||||
// (headless boxes) or by the web UI. Everything the engine needs is derivable from this file plus the
|
|
||||||
// exporter's `punktfunk-library.json`; `resolveConfig` fills defaults so the rest of the code works
|
|
||||||
// against a fully-populated shape.
|
|
||||||
|
|
||||||
/** When and how often we re-read the export and reconcile. */
|
|
||||||
export interface SyncOptions {
|
|
||||||
/** Interval poll in minutes — the backstop under the exporter-driven file watch. */
|
|
||||||
pollMinutes: number;
|
|
||||||
/** Watch the exporter's output directory and reconcile on change (the primary trigger). */
|
|
||||||
watch: boolean;
|
|
||||||
/** Debounce window for coalescing watch/poll-triggered re-reads (ms). */
|
|
||||||
debounceMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Which Playnite games become library entries. */
|
|
||||||
export interface FilterOptions {
|
|
||||||
/** Only sync games Playnite marks installed (default). Off = include not-yet-installed titles too. */
|
|
||||||
installedOnly: boolean;
|
|
||||||
/** Include games flagged Hidden in Playnite (default off). */
|
|
||||||
includeHidden: boolean;
|
|
||||||
/** If non-empty, keep ONLY games whose source is in this list ("Steam", "GOG", …). */
|
|
||||||
sources: string[];
|
|
||||||
/** Drop games whose source is in this list (applied after `sources`). */
|
|
||||||
excludeSources: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How cover art reaches the clients. Playnite art is local files on the host:
|
|
||||||
* - `host` (default): send the local file PATH; the host serves the bytes through its art proxy
|
|
||||||
* (`/library/art/…`, like Steam art). The reconcile payload carries no image data, so this scales
|
|
||||||
* to libraries of any size — the recommended mode.
|
|
||||||
* - `dataurl`: inline each cover as a size-capped `data:` URL. Self-contained but does NOT scale — a
|
|
||||||
* large library blows past the host's reconcile body limit. Small libraries only.
|
|
||||||
* - `off`: sync titles with no art. */
|
|
||||||
export type ArtMode = "host" | "dataurl" | "off";
|
|
||||||
|
|
||||||
export interface ArtOptions {
|
|
||||||
mode: ArtMode;
|
|
||||||
/** Skip an image whose file is larger than this (bytes) — a `data:` URL bloats the library payload,
|
|
||||||
* so covers over the cap are dropped rather than inlined. */
|
|
||||||
maxBytes: number;
|
|
||||||
/** Also inline the Playnite background as `hero` art (usually large — off by default). */
|
|
||||||
includeBackground: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UiOptions {
|
|
||||||
/** Fallback standalone mode: serve the UI on a fixed port behind a password instead of via the
|
|
||||||
* console proxy. For host-only installs without the console. */
|
|
||||||
standalone: boolean;
|
|
||||||
/** Standalone bind port. */
|
|
||||||
port: number;
|
|
||||||
/** Standalone bind address (defaults to loopback; a non-loopback bind REQUIRES a password). */
|
|
||||||
bind: string;
|
|
||||||
/** scrypt password hash (`scrypt$<saltB64>$<hashB64>`), required for a non-loopback standalone bind. */
|
|
||||||
passwordHash?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The on-disk config as authored (all optional — `resolveConfig` fills the rest). */
|
|
||||||
export interface RawConfig {
|
|
||||||
/** Override the auto-detected Playnite data directory (`%APPDATA%\Playnite`). Point it at a
|
|
||||||
* portable install, or at the interactive user's profile when the runner runs as a different user. */
|
|
||||||
playniteDir?: string;
|
|
||||||
sync?: Partial<SyncOptions>;
|
|
||||||
filter?: Partial<FilterOptions>;
|
|
||||||
art?: Partial<ArtOptions>;
|
|
||||||
ui?: Partial<UiOptions>;
|
|
||||||
/** Dev/acceptance flag: reconcile one hardcoded entry so the round-trip is observable. */
|
|
||||||
devEntry?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The fully-resolved config the engine consumes. */
|
|
||||||
export interface Config {
|
|
||||||
playniteDir?: string;
|
|
||||||
sync: SyncOptions;
|
|
||||||
filter: FilterOptions;
|
|
||||||
art: ArtOptions;
|
|
||||||
ui: UiOptions;
|
|
||||||
devEntry: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_SYNC: SyncOptions = {
|
|
||||||
pollMinutes: 5,
|
|
||||||
watch: true,
|
|
||||||
debounceMs: 1500,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_FILTER: FilterOptions = {
|
|
||||||
installedOnly: true,
|
|
||||||
includeHidden: false,
|
|
||||||
sources: [],
|
|
||||||
excludeSources: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_ART: ArtOptions = {
|
|
||||||
mode: "host",
|
|
||||||
maxBytes: 1_500_000,
|
|
||||||
includeBackground: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_UI: UiOptions = {
|
|
||||||
standalone: false,
|
|
||||||
port: 47994,
|
|
||||||
bind: "127.0.0.1",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Fill defaults over an authored config so the rest of the engine sees a total shape. */
|
|
||||||
export const resolveConfig = (raw: RawConfig): Config => ({
|
|
||||||
playniteDir: raw.playniteDir?.trim() || undefined,
|
|
||||||
sync: { ...DEFAULT_SYNC, ...raw.sync },
|
|
||||||
filter: { ...DEFAULT_FILTER, ...raw.filter },
|
|
||||||
art: { ...DEFAULT_ART, ...raw.art },
|
|
||||||
ui: { ...DEFAULT_UI, ...raw.ui },
|
|
||||||
devEntry: raw.devEntry ?? false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** The empty starting config for a fresh install. */
|
|
||||||
export const emptyConfig = (): Config => resolveConfig({});
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
// The sync engine: the headless half of the plugin. It ties the pure core (locate → read → compute)
|
|
||||||
// to the real world — reading the exporter's `punktfunk-library.json`, embedding cover art, and the
|
|
||||||
// host reconcile PUT — and drives it on start, on an interval poll, on export-file changes (debounced),
|
|
||||||
// and on demand from the UI/CLI. Fully functional from a hand-written `config.json` alone.
|
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import type { Punktfunk } from "@punktfunk/host";
|
|
||||||
import { buildArtwork } from "../art.js";
|
|
||||||
import type { Config } from "../config.js";
|
|
||||||
import type { ExportedGame } from "../export-schema.js";
|
|
||||||
import { deleteProvider, reconcileProvider } from "../host.js";
|
|
||||||
import { type Logger, makeLogger } from "../log.js";
|
|
||||||
import { ingestDir } from "../paths.js";
|
|
||||||
import {
|
|
||||||
ExportError,
|
|
||||||
type Location,
|
|
||||||
locateExport,
|
|
||||||
readExport,
|
|
||||||
} from "../playnite.js";
|
|
||||||
import {
|
|
||||||
type Cache,
|
|
||||||
loadCache,
|
|
||||||
loadConfig,
|
|
||||||
saveCache,
|
|
||||||
statePaths,
|
|
||||||
} from "../state.js";
|
|
||||||
import type { ProviderEntryInput } from "../wire.js";
|
|
||||||
import { computeEntries, type SyncReport } from "./reconcile.js";
|
|
||||||
|
|
||||||
export interface EngineDeps {
|
|
||||||
pf: Punktfunk;
|
|
||||||
platform?: NodeJS.Platform;
|
|
||||||
log?: Logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EngineStatus {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
/** Where the export is (or why it wasn't found) — drives the console's "connected?" banner. */
|
|
||||||
location: Location;
|
|
||||||
/** Last read error (schema/corruption), if any. */
|
|
||||||
exportError?: string;
|
|
||||||
lastSync?: Cache["lastSync"];
|
|
||||||
lastReport?: SyncReport;
|
|
||||||
paths: ReturnType<typeof statePaths>;
|
|
||||||
syncing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A stable fingerprint of (export bytes + the config knobs that affect output). Lets an unchanged
|
|
||||||
* re-read skip the expensive art-embed + PUT entirely. */
|
|
||||||
const fingerprint = (bytes: Buffer, config: Config): string =>
|
|
||||||
createHash("sha256")
|
|
||||||
.update(bytes)
|
|
||||||
.update(
|
|
||||||
JSON.stringify({
|
|
||||||
filter: config.filter,
|
|
||||||
art: config.art,
|
|
||||||
devEntry: config.devEntry,
|
|
||||||
platform: process.platform,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.digest("hex");
|
|
||||||
|
|
||||||
/** A synthetic entry proving the reconcile round-trip end-to-end (dev flag / acceptance). */
|
|
||||||
const devEntry = (platform: NodeJS.Platform): ProviderEntryInput => ({
|
|
||||||
external_id: "00000000-0000-0000-0000-000000000000",
|
|
||||||
title: "Playnite (dev entry)",
|
|
||||||
launch: {
|
|
||||||
kind: "command",
|
|
||||||
value: platform === "win32" ? "cmd /c echo hi" : "echo hi",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export class Engine {
|
|
||||||
private readonly pf: Punktfunk;
|
|
||||||
private readonly platform: NodeJS.Platform;
|
|
||||||
private readonly log: Logger;
|
|
||||||
private cache: Cache;
|
|
||||||
|
|
||||||
private syncing = false;
|
|
||||||
private pending = false;
|
|
||||||
private lastReport?: SyncReport;
|
|
||||||
private lastLocation: Location = {
|
|
||||||
dir: null,
|
|
||||||
candidates: [],
|
|
||||||
file: null,
|
|
||||||
mtime: null,
|
|
||||||
};
|
|
||||||
private lastError?: string;
|
|
||||||
private pollTimer?: ReturnType<typeof setInterval>;
|
|
||||||
private debounceTimer?: ReturnType<typeof setTimeout>;
|
|
||||||
private watchers: fs.FSWatcher[] = [];
|
|
||||||
private readonly listeners = new Set<(status: EngineStatus) => void>();
|
|
||||||
|
|
||||||
constructor(deps: EngineDeps) {
|
|
||||||
this.pf = deps.pf;
|
|
||||||
this.platform = deps.platform ?? process.platform;
|
|
||||||
this.log = deps.log ?? makeLogger();
|
|
||||||
this.cache = loadCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- lifecycle
|
|
||||||
|
|
||||||
async start(): Promise<void> {
|
|
||||||
await this.sync("startup");
|
|
||||||
this.schedulePoll();
|
|
||||||
this.setupWatchers();
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
||||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
||||||
for (const w of this.watchers) {
|
|
||||||
try {
|
|
||||||
w.close();
|
|
||||||
} catch {
|
|
||||||
// already closed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.watchers = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private schedulePoll(): void {
|
|
||||||
const config = loadConfig();
|
|
||||||
const ms = Math.max(1, config.sync.pollMinutes) * 60_000;
|
|
||||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
||||||
this.pollTimer = setInterval(() => void this.sync("poll"), ms);
|
|
||||||
(this.pollTimer as { unref?: () => void }).unref?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Watch the exporter's `ExtensionsData` tree so a library change reconciles within seconds. */
|
|
||||||
private setupWatchers(): void {
|
|
||||||
for (const w of this.watchers) {
|
|
||||||
try {
|
|
||||||
w.close();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.watchers = [];
|
|
||||||
const config = loadConfig();
|
|
||||||
if (!config.sync.watch) return;
|
|
||||||
const loc = locateExport(config);
|
|
||||||
|
|
||||||
const targets = new Set<string>();
|
|
||||||
// Always watch the ingest inbox when present — the de-privileged runner's source, so an
|
|
||||||
// exporter drop reconciles within seconds even when `loc.dir` resolved elsewhere (or nowhere).
|
|
||||||
const inbox = ingestDir();
|
|
||||||
if (fs.existsSync(inbox)) targets.add(inbox);
|
|
||||||
// Plus the located Playnite dir: prefer its ExtensionsData (where the file lives), else the
|
|
||||||
// dir itself so we notice the export appearing (same-user/SYSTEM runner, or Linux).
|
|
||||||
if (loc.dir) {
|
|
||||||
const extData = path.join(loc.dir, "ExtensionsData");
|
|
||||||
targets.add(fs.existsSync(extData) ? extData : loc.dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const target of targets) {
|
|
||||||
try {
|
|
||||||
this.watchers.push(
|
|
||||||
fs.watch(target, { recursive: true }, () => this.debouncedSync()),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
this.watchers.push(fs.watch(target, () => this.debouncedSync()));
|
|
||||||
} catch {
|
|
||||||
this.log(`watch: cannot watch ${target} (poll only)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private debouncedSync(): void {
|
|
||||||
const config = loadConfig();
|
|
||||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
||||||
this.debounceTimer = setTimeout(
|
|
||||||
() => void this.sync("fs-change"),
|
|
||||||
config.sync.debounceMs,
|
|
||||||
);
|
|
||||||
(this.debounceTimer as { unref?: () => void }).unref?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- compute
|
|
||||||
|
|
||||||
/** Locate + read + compute WITHOUT embedding art or touching the host (the UI preview). */
|
|
||||||
computeDry(): { location: Location; report?: SyncReport; error?: string } {
|
|
||||||
const config = loadConfig();
|
|
||||||
const location = locateExport(config);
|
|
||||||
if (!location.file) {
|
|
||||||
return { location, error: "no exporter output found yet" };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const exp = readExport(location.file);
|
|
||||||
const { report } = computeEntries({
|
|
||||||
exp,
|
|
||||||
config,
|
|
||||||
platform: this.platform,
|
|
||||||
artFor: () => undefined, // preview: skip the (heavy) art embed
|
|
||||||
});
|
|
||||||
return { location, report };
|
|
||||||
} catch (e) {
|
|
||||||
return { location, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- sync
|
|
||||||
|
|
||||||
/** The guarded reconcile: coalesces concurrent triggers, skips the read/embed/PUT when nothing changed. */
|
|
||||||
async sync(reason: string): Promise<SyncReport | undefined> {
|
|
||||||
if (this.syncing) {
|
|
||||||
this.pending = true;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
this.syncing = true;
|
|
||||||
this.notify();
|
|
||||||
try {
|
|
||||||
const config = loadConfig();
|
|
||||||
const location = locateExport(config);
|
|
||||||
this.lastLocation = location;
|
|
||||||
if (!location.file) {
|
|
||||||
this.lastError = `no exporter output under ${location.dir ?? "any Playnite data dir"} — is the Punktfunk Sync extension installed in Playnite?`;
|
|
||||||
this.log(`sync (${reason}): ${this.lastError}`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = fs.readFileSync(location.file);
|
|
||||||
const fp = fingerprint(bytes, config);
|
|
||||||
if (this.cache.lastSync?.fingerprint === fp) {
|
|
||||||
this.lastError = undefined;
|
|
||||||
this.log(
|
|
||||||
`sync (${reason}): no changes (${this.cache.lastSync.count} title(s))`,
|
|
||||||
);
|
|
||||||
return this.lastReport;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exp = readExport(location.file);
|
|
||||||
const { entries, report } = computeEntries({
|
|
||||||
exp,
|
|
||||||
config,
|
|
||||||
platform: this.platform,
|
|
||||||
artFor: (g: ExportedGame) => buildArtwork(g, config.art),
|
|
||||||
});
|
|
||||||
const finalEntries = config.devEntry
|
|
||||||
? [...entries, devEntry(this.platform)]
|
|
||||||
: entries;
|
|
||||||
|
|
||||||
await reconcileProvider(this.pf, finalEntries);
|
|
||||||
this.cache.lastSync = {
|
|
||||||
fingerprint: fp,
|
|
||||||
count: finalEntries.length,
|
|
||||||
at: Date.now(),
|
|
||||||
};
|
|
||||||
saveCache(this.cache);
|
|
||||||
this.lastReport = report;
|
|
||||||
this.lastError = undefined;
|
|
||||||
this.log(
|
|
||||||
`sync (${reason}): reconciled ${finalEntries.length} title(s) (${report.skipped.length} skipped)`,
|
|
||||||
);
|
|
||||||
this.logReport(report);
|
|
||||||
return report;
|
|
||||||
} catch (e) {
|
|
||||||
this.lastError = e instanceof ExportError ? e.message : String(e);
|
|
||||||
this.log(`sync (${reason}) failed: ${this.lastError}`);
|
|
||||||
return undefined;
|
|
||||||
} finally {
|
|
||||||
this.syncing = false;
|
|
||||||
this.notify();
|
|
||||||
if (this.pending) {
|
|
||||||
this.pending = false;
|
|
||||||
queueMicrotask(() => void this.sync("coalesced"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private logReport(report: SyncReport): void {
|
|
||||||
const bySource = Object.entries(report.perSource)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.map(([s, n]) => `${s}:${n}`)
|
|
||||||
.join(" ");
|
|
||||||
if (bySource) this.log(` sources — ${bySource}`);
|
|
||||||
for (const w of report.warnings.slice(0, 5)) this.log(` ${w}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reconfigure timers/watchers after a config change (e.g. the UI saved new filters). */
|
|
||||||
async reconfigure(): Promise<void> {
|
|
||||||
this.schedulePoll();
|
|
||||||
this.setupWatchers();
|
|
||||||
await this.sync("config-change");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove every entry this provider owns (CLI `uninstall`). */
|
|
||||||
async uninstall(): Promise<void> {
|
|
||||||
await deleteProvider(this.pf);
|
|
||||||
this.cache.lastSync = undefined;
|
|
||||||
saveCache(this.cache);
|
|
||||||
this.log("uninstalled: provider entries removed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- status
|
|
||||||
|
|
||||||
subscribe(cb: (status: EngineStatus) => void): () => void {
|
|
||||||
this.listeners.add(cb);
|
|
||||||
return () => this.listeners.delete(cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
private notify(): void {
|
|
||||||
const status = this.status();
|
|
||||||
for (const cb of this.listeners) {
|
|
||||||
try {
|
|
||||||
cb(status);
|
|
||||||
} catch {
|
|
||||||
// a dead SSE listener must not break sync
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
status(): EngineStatus {
|
|
||||||
return {
|
|
||||||
platform: this.platform,
|
|
||||||
location: this.lastLocation,
|
|
||||||
exportError: this.lastError,
|
|
||||||
lastSync: this.cache.lastSync,
|
|
||||||
lastReport: this.lastReport,
|
|
||||||
paths: statePaths(),
|
|
||||||
syncing: this.syncing,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
// The contract between the two halves of this plugin. The C# Playnite exporter (see `exporter/`)
|
|
||||||
// writes a `punktfunk-library.json` in this exact shape into its Playnite plugin-data directory on
|
|
||||||
// every library change; this TypeScript plugin reads it and reconciles it into the host library.
|
|
||||||
//
|
|
||||||
// Keep this in lockstep with `exporter/PunktfunkSyncPlugin.cs` — the C# `LibraryExport`/`ExportedGame`
|
|
||||||
// DTOs serialise to these property names. Bump `SCHEMA` on any breaking change and gate on it in the
|
|
||||||
// reader; the exporter stamps the version it wrote.
|
|
||||||
|
|
||||||
/** The file the exporter writes (inside `…/Playnite/ExtensionsData/<pluginId>/`). */
|
|
||||||
export const EXPORT_FILE = "punktfunk-library.json";
|
|
||||||
|
|
||||||
/** Schema version this reader understands. The exporter stamps `schema`; a newer major is refused. */
|
|
||||||
export const SCHEMA = 1;
|
|
||||||
|
|
||||||
/** One game as the Playnite exporter sees it. Art fields are ABSOLUTE paths on the host box (already
|
|
||||||
* resolved from Playnite's database-relative form via `IPlayniteAPI.Database.GetFullFilePath`). */
|
|
||||||
export interface ExportedGame {
|
|
||||||
/** Playnite's stable game `Guid` (lowercase, no braces) — our reconcile `external_id`. */
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
/** Whether Playnite considers the game installed (drives the default `installedOnly` filter). */
|
|
||||||
installed: boolean;
|
|
||||||
/** Playnite's per-game "Hidden" flag. */
|
|
||||||
hidden: boolean;
|
|
||||||
/** The library source name ("Steam", "GOG", "Epic", "Xbox", emulator name, …) or null. */
|
|
||||||
source: string | null;
|
|
||||||
/** Platform display names ("PC (Windows)", "Nintendo Switch", …). */
|
|
||||||
platforms: string[];
|
|
||||||
/** Absolute path to the cover image, or null. */
|
|
||||||
cover: string | null;
|
|
||||||
/** Absolute path to the background image, or null. */
|
|
||||||
background: string | null;
|
|
||||||
/** Absolute path to the icon, or null. */
|
|
||||||
icon: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The whole export document. */
|
|
||||||
export interface LibraryExport {
|
|
||||||
schema: number;
|
|
||||||
/** ISO-8601 timestamp the exporter wrote this. */
|
|
||||||
generatedAt: string;
|
|
||||||
playnite: {
|
|
||||||
version: string | null;
|
|
||||||
/** "Desktop" | "Fullscreen" — informational. */
|
|
||||||
mode: string | null;
|
|
||||||
};
|
|
||||||
games: ExportedGame[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A cheap structural check — enough to reject a truncated/foreign file before we trust it. */
|
|
||||||
export const isLibraryExport = (v: unknown): v is LibraryExport => {
|
|
||||||
if (typeof v !== "object" || v === null) return false;
|
|
||||||
const o = v as Record<string, unknown>;
|
|
||||||
return typeof o.schema === "number" && Array.isArray(o.games);
|
|
||||||
};
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
// The host side of the reconcile. We PUT the declarative entry list to the provider endpoint and let
|
|
||||||
// the host diff by `external_id` (stable ids, orphan drop, manual entries untouched). Done through
|
|
||||||
// `pf.request` rather than the typed `pf.api.*` deliberately: under the packaged runner the facade is
|
|
||||||
// built by the runner's *bundled* SDK copy, whose generated client may predate an endpoint — the
|
|
||||||
// untyped request seam is version-skew-proof (same reasoning as `servePluginUi`).
|
|
||||||
|
|
||||||
import type { Punktfunk } from "@punktfunk/host";
|
|
||||||
import { PROVIDER_ID } from "./const.js";
|
|
||||||
import type { ProviderEntryInput } from "./wire.js";
|
|
||||||
|
|
||||||
/** Full-replace reconcile: the host makes the library match `entries` exactly for this provider. */
|
|
||||||
export const reconcileProvider = (
|
|
||||||
pf: Punktfunk,
|
|
||||||
entries: ProviderEntryInput[],
|
|
||||||
): Promise<unknown> =>
|
|
||||||
pf.request("PUT", `/library/provider/${PROVIDER_ID}`, entries);
|
|
||||||
|
|
||||||
/** Clean uninstall: remove every entry this provider owns. */
|
|
||||||
export const deleteProvider = (pf: Punktfunk): Promise<unknown> =>
|
|
||||||
pf.request("DELETE", `/library/provider/${PROVIDER_ID}`);
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// The plugin entry. `main` is the plain-async form (NOT the Effect form): the packaged runner bundles
|
|
||||||
// its own effect/SDK copy and cross-instance Effect identity is unverified, so the async facade
|
|
||||||
// sidesteps it entirely. The runner supervises this with restart-on-crash and a structured SIGTERM
|
|
||||||
// shutdown; a direct `bun src/index.ts` run works too (bottom of file).
|
|
||||||
//
|
|
||||||
// Lifecycle: connect (done by the runner/facade) → start the sync engine → serve the UI (console-hosted
|
|
||||||
// by default, standalone if configured) → run until SIGTERM → deregister the UI and stop the engine.
|
|
||||||
// Provider entries are deliberately LEFT in the library on shutdown so the games survive plugin
|
|
||||||
// restarts and host reboots; a clean uninstall is the explicit `uninstall` CLI command.
|
|
||||||
|
|
||||||
import { connect, definePlugin, type Punktfunk } from "@punktfunk/host";
|
|
||||||
import { PLUGIN_NAME } from "./const.js";
|
|
||||||
import { Engine } from "./engine/index.js";
|
|
||||||
import { log } from "./log.js";
|
|
||||||
import { serveConsoleUi } from "./server/index.js";
|
|
||||||
import { serveStandaloneUi } from "./server/standalone.js";
|
|
||||||
import { loadConfig } from "./state.js";
|
|
||||||
import { readVersion } from "./version.js";
|
|
||||||
|
|
||||||
const plugin = definePlugin({
|
|
||||||
name: PLUGIN_NAME,
|
|
||||||
main: async (pf: Punktfunk) => {
|
|
||||||
const engine = new Engine({ pf });
|
|
||||||
const config = loadConfig();
|
|
||||||
|
|
||||||
// Headless engine first — the library syncs even if the UI can't start.
|
|
||||||
await engine.start();
|
|
||||||
|
|
||||||
let closeUi: () => Promise<void> = async () => {};
|
|
||||||
try {
|
|
||||||
if (config.ui.standalone) {
|
|
||||||
const handle = serveStandaloneUi(engine, config);
|
|
||||||
closeUi = handle.close;
|
|
||||||
} else {
|
|
||||||
const handle = await serveConsoleUi(pf, engine, {
|
|
||||||
version: readVersion(),
|
|
||||||
});
|
|
||||||
closeUi = handle.close;
|
|
||||||
log(`UI registered with the console (nav entry "${PLUGIN_NAME}")`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log(`UI server failed to start (engine keeps running headless): ${e}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
process.once("SIGINT", resolve);
|
|
||||||
process.once("SIGTERM", resolve);
|
|
||||||
});
|
|
||||||
|
|
||||||
log("shutting down");
|
|
||||||
await closeUi();
|
|
||||||
engine.stop();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default plugin;
|
|
||||||
|
|
||||||
// Allow a direct `bun src/index.ts` run outside the managed runner (dev).
|
|
||||||
if (import.meta.main) {
|
|
||||||
const pf = await connect();
|
|
||||||
await (plugin.main as (pf: Punktfunk) => Promise<void>)(pf);
|
|
||||||
pf.close();
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
// A tiny stamped logger, matching the runner's line format so the plugin's output reads consistently
|
|
||||||
// in the runner journal.
|
|
||||||
export type Logger = (line: string) => void;
|
|
||||||
|
|
||||||
export const makeLogger = (prefix = "playnite"): Logger => {
|
|
||||||
return (line: string) =>
|
|
||||||
console.log(`${new Date().toISOString()} [${prefix}] ${line}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const log: Logger = makeLogger();
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// Where the plugin's own files live. The host config-dir resolution mirrors the SDK's `configDir`
|
|
||||||
// (`@punktfunk/host` does not re-export it) so we always land in the same place the host and runner
|
|
||||||
// use; the plugin owns a `plugin-state/playnite/` subtree under it, created 0700.
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as os from "node:os";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
/** The host's config dir — the same resolution the host and SDK use. */
|
|
||||||
export const hostConfigDir = (): string => {
|
|
||||||
const explicit = process.env.PUNKTFUNK_CONFIG_DIR;
|
|
||||||
if (explicit) return explicit;
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
const base = process.env.ProgramData ?? process.env.APPDATA ?? ".";
|
|
||||||
return path.join(base, "punktfunk");
|
|
||||||
}
|
|
||||||
const base =
|
|
||||||
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
|
||||||
return path.join(base, "punktfunk");
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This plugin's private directory: `<config_dir>/plugin-state/playnite`.
|
|
||||||
*
|
|
||||||
* The state lives under `plugin-state/`, not straight under the config dir, because the managed
|
|
||||||
* runner is de-privileged on Windows (`NT AUTHORITY\LocalService`) and the config dir is locked
|
|
||||||
* read-only there — `punktfunk-host plugins enable` grants the runner write on exactly
|
|
||||||
* `plugin-state`. (Mirrors `@punktfunk/host`'s `pluginStateDir`; reimplemented locally like
|
|
||||||
* `hostConfigDir`, since we don't gate on an SDK version bump.) On Linux the runner owns the config
|
|
||||||
* dir, so the same path is writable with no special step.
|
|
||||||
*/
|
|
||||||
export const pluginDir = (): string =>
|
|
||||||
path.join(hostConfigDir(), "plugin-state", "playnite");
|
|
||||||
|
|
||||||
/** `<config_dir>/plugin-state/playnite/config.json` — operator-editable, atomic-written. */
|
|
||||||
export const configPath = (): string => path.join(pluginDir(), "config.json");
|
|
||||||
|
|
||||||
/** `<config_dir>/plugin-state/playnite/cache.json` — disposable derived state (last fingerprint). */
|
|
||||||
export const cachePath = (): string => path.join(pluginDir(), "cache.json");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The cross-account **ingest inbox** the Playnite exporter drops the library export into:
|
|
||||||
* `<config_dir>/ingest/playnite`. (Mirrors `@punktfunk/host`'s `pluginIngestDir`.)
|
|
||||||
*
|
|
||||||
* The exporter runs inside Playnite as the interactive user; the de-privileged LocalService runner
|
|
||||||
* cannot read that user's `%APPDATA%\Playnite\ExtensionsData\…`. `punktfunk-host plugins enable`
|
|
||||||
* makes `ingest` user-writable, so the exporter drops the file here for the runner to read. Read
|
|
||||||
* this BEFORE the ExtensionsData scan (which only works for a same-user/SYSTEM runner or on Linux).
|
|
||||||
*/
|
|
||||||
export const ingestDir = (): string =>
|
|
||||||
path.join(hostConfigDir(), "ingest", "playnite");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locate the built SPA directory (`dist/ui`) at runtime. The package ships as ONE self-contained
|
|
||||||
* bundle so it installs from the Gitea registry with no external deps — which means `import.meta.url`
|
|
||||||
* can be `dist/index.js` (bundled) or `dist/server/index.js` (unbundled dev). We walk up from the
|
|
||||||
* calling module looking for a `ui/index.html`, so both layouts resolve. `fromUrl` is the caller's
|
|
||||||
* `import.meta.url`.
|
|
||||||
*/
|
|
||||||
export const resolveUiDir = (fromUrl: string): string => {
|
|
||||||
let dir = path.dirname(fileURLToPath(fromUrl));
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
for (const cand of [path.join(dir, "ui"), path.join(dir, "dist", "ui")]) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(path.join(cand, "index.html"))) return cand;
|
|
||||||
} catch {
|
|
||||||
// keep walking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const parent = path.dirname(dir);
|
|
||||||
if (parent === dir) break;
|
|
||||||
dir = parent;
|
|
||||||
}
|
|
||||||
return path.join(path.dirname(fileURLToPath(fromUrl)), "ui");
|
|
||||||
};
|
|
||||||
-162
@@ -1,162 +0,0 @@
|
|||||||
// Locating and reading the exporter's output. The companion Playnite extension (`exporter/`) writes
|
|
||||||
// `punktfunk-library.json` into its own Playnite plugin-data directory, i.e.
|
|
||||||
// `<PlayniteData>/ExtensionsData/<pluginId-guid>/punktfunk-library.json`. We don't hardcode that guid
|
|
||||||
// — we glob `ExtensionsData/*/punktfunk-library.json` and take the newest, so the reader is decoupled
|
|
||||||
// from the exporter's id.
|
|
||||||
//
|
|
||||||
// The plugin runs on the same box as Playnite (it's the streaming host), so these are plain local
|
|
||||||
// reads. The wrinkle is *which account* can see the file. The managed runner is de-privileged
|
|
||||||
// (`NT AUTHORITY\LocalService`) and cannot read the interactive user's `%APPDATA%`, so the reliable
|
|
||||||
// source is the **ingest inbox** (`<config_dir>/ingest/playnite/punktfunk-library.json`) the
|
|
||||||
// exporter drops the file into — checked FIRST. The `%APPDATA%`/`C:\Users\*` ExtensionsData scan
|
|
||||||
// still works for a same-user/SYSTEM runner or on Linux, and an explicit `playniteDir` override
|
|
||||||
// joins the candidate list.
|
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import type { Config } from "./config.js";
|
|
||||||
import {
|
|
||||||
EXPORT_FILE,
|
|
||||||
isLibraryExport,
|
|
||||||
type LibraryExport,
|
|
||||||
SCHEMA,
|
|
||||||
} from "./export-schema.js";
|
|
||||||
import { ingestDir } from "./paths.js";
|
|
||||||
|
|
||||||
const exists = (p: string): boolean => {
|
|
||||||
try {
|
|
||||||
fs.accessSync(p);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Candidate Playnite data directories, most-specific first (config override handled by the caller). */
|
|
||||||
export const candidatePlayniteDirs = (): string[] => {
|
|
||||||
const out: string[] = [];
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
const appdata = process.env.APPDATA;
|
|
||||||
if (appdata) out.push(path.join(appdata, "Playnite"));
|
|
||||||
// SYSTEM/other-user runner: scan every local profile's roaming AppData.
|
|
||||||
const usersRoot = path.join(process.env.SystemDrive ?? "C:", "\\", "Users");
|
|
||||||
try {
|
|
||||||
for (const user of fs.readdirSync(usersRoot)) {
|
|
||||||
out.push(path.join(usersRoot, user, "AppData", "Roaming", "Playnite"));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// no C:\Users (or not Windows-like) — fine
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Playnite is Windows-only, but a Wine/portable layout may set these — best effort.
|
|
||||||
const home = process.env.HOME;
|
|
||||||
if (home) {
|
|
||||||
out.push(path.join(home, ".local", "share", "Playnite"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// De-dupe, preserve order.
|
|
||||||
return [...new Set(out)];
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface Location {
|
|
||||||
/** The resolved Playnite data dir, or null if none found. */
|
|
||||||
dir: string | null;
|
|
||||||
/** The candidate dirs we considered (for the UI's diagnostics view). */
|
|
||||||
candidates: string[];
|
|
||||||
/** The resolved export file, or null if the exporter hasn't written one yet. */
|
|
||||||
file: string | null;
|
|
||||||
/** mtime (epoch ms) of the export file, or null. */
|
|
||||||
mtime: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find the newest `punktfunk-library.json` under a Playnite dir's `ExtensionsData`. */
|
|
||||||
const findExportUnder = (
|
|
||||||
dir: string,
|
|
||||||
): { file: string; mtime: number } | null => {
|
|
||||||
const base = path.join(dir, "ExtensionsData");
|
|
||||||
let best: { file: string; mtime: number } | null = null;
|
|
||||||
let subdirs: string[];
|
|
||||||
try {
|
|
||||||
subdirs = fs.readdirSync(base);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (const sub of subdirs) {
|
|
||||||
const file = path.join(base, sub, EXPORT_FILE);
|
|
||||||
try {
|
|
||||||
const st = fs.statSync(file);
|
|
||||||
if (!best || st.mtimeMs > best.mtime) best = { file, mtime: st.mtimeMs };
|
|
||||||
} catch {
|
|
||||||
// no export in this extension's dir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Resolve where the export lives. `config.playniteDir` wins; otherwise probe the candidates. */
|
|
||||||
export const locateExport = (config: Config): Location => {
|
|
||||||
const override = config.playniteDir;
|
|
||||||
const candidates = override
|
|
||||||
? [override, ...candidatePlayniteDirs()]
|
|
||||||
: candidatePlayniteDirs();
|
|
||||||
|
|
||||||
// The ingest inbox first: the exporter drops the library here (a flat file, not an
|
|
||||||
// `ExtensionsData/<guid>` tree) so the de-privileged LocalService runner can read it — it can't
|
|
||||||
// reach the interactive user's `%APPDATA%`. This is the reliable path on a managed Windows host.
|
|
||||||
const inbox = ingestDir();
|
|
||||||
const inboxFile = path.join(inbox, EXPORT_FILE);
|
|
||||||
try {
|
|
||||||
const st = fs.statSync(inboxFile);
|
|
||||||
return {
|
|
||||||
dir: inbox,
|
|
||||||
candidates: [inbox, ...candidates],
|
|
||||||
file: inboxFile,
|
|
||||||
mtime: st.mtimeMs,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
// no ingest drop yet — fall back to scanning Playnite's own data dirs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then, a Playnite data dir that actually holds an export file (same-user/SYSTEM runner, Linux).
|
|
||||||
for (const dir of candidates) {
|
|
||||||
const hit = findExportUnder(dir);
|
|
||||||
if (hit)
|
|
||||||
return {
|
|
||||||
dir,
|
|
||||||
candidates: [inbox, ...candidates],
|
|
||||||
file: hit.file,
|
|
||||||
mtime: hit.mtime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Else, the first dir that at least exists (so the UI can say "Playnite found, exporter not
|
|
||||||
// installed yet" vs "Playnite not found").
|
|
||||||
const dir = candidates.find(exists) ?? null;
|
|
||||||
return { dir, candidates: [inbox, ...candidates], file: null, mtime: null };
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ExportError extends Error {}
|
|
||||||
|
|
||||||
/** Read + validate the export file. Throws `ExportError` on a missing/corrupt/too-new file. */
|
|
||||||
export const readExport = (file: string): LibraryExport => {
|
|
||||||
let raw: string;
|
|
||||||
try {
|
|
||||||
raw = fs.readFileSync(file, "utf8");
|
|
||||||
} catch (e) {
|
|
||||||
throw new ExportError(`cannot read ${file}: ${e}`);
|
|
||||||
}
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch (e) {
|
|
||||||
throw new ExportError(`${file} is not valid JSON: ${e}`);
|
|
||||||
}
|
|
||||||
if (!isLibraryExport(parsed)) {
|
|
||||||
throw new ExportError(`${file} is not a punktfunk library export`);
|
|
||||||
}
|
|
||||||
if (Math.floor(parsed.schema) > SCHEMA) {
|
|
||||||
throw new ExportError(
|
|
||||||
`${file} is schema ${parsed.schema}; this plugin understands up to ${SCHEMA} — update the plugin`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
// The console-hosted UI server (primary path). `servePluginUi` from the SDK owns the whole plugin side
|
|
||||||
// — a loopback ephemeral port behind a per-boot secret, registration + lease renewal with the host,
|
|
||||||
// and the console reverse-proxy contract — so we just hand it the built SPA directory and the
|
|
||||||
// plugin-local API router. The operator signs into the console once; the "Playnite" nav entry appears
|
|
||||||
// automatically. Zero human auth here.
|
|
||||||
|
|
||||||
import {
|
|
||||||
type PluginUiHandle,
|
|
||||||
type Punktfunk,
|
|
||||||
servePluginUi,
|
|
||||||
} from "@punktfunk/host";
|
|
||||||
import { PLUGIN_NAME, UI_ICON, UI_TITLE } from "../const.js";
|
|
||||||
import type { Engine } from "../engine/index.js";
|
|
||||||
import { resolveUiDir } from "../paths.js";
|
|
||||||
import { makeRouter } from "./router.js";
|
|
||||||
|
|
||||||
/** Serve the UI through the console. The SPA dir resolves whether we run bundled or from source. */
|
|
||||||
export const serveConsoleUi = (
|
|
||||||
pf: Punktfunk,
|
|
||||||
engine: Engine,
|
|
||||||
opts?: { version?: string },
|
|
||||||
): Promise<PluginUiHandle> =>
|
|
||||||
servePluginUi(pf, {
|
|
||||||
id: PLUGIN_NAME,
|
|
||||||
title: UI_TITLE,
|
|
||||||
icon: UI_ICON,
|
|
||||||
version: opts?.version,
|
|
||||||
staticDir: resolveUiDir(import.meta.url),
|
|
||||||
fetch: makeRouter(engine),
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
// scrypt password hashing for the standalone fallback UI. Format: `scrypt$<saltB64url>$<hashB64url>`.
|
|
||||||
// Verification is constant-time. Only used by the standalone server; the console-hosted path has no
|
|
||||||
// password of its own.
|
|
||||||
|
|
||||||
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
||||||
|
|
||||||
const KEYLEN = 32;
|
|
||||||
|
|
||||||
/** Hash a password for storage in `config.ui.passwordHash`. */
|
|
||||||
export const hashPassword = (password: string): string => {
|
|
||||||
const salt = randomBytes(16);
|
|
||||||
const hash = scryptSync(password, salt, KEYLEN);
|
|
||||||
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Constant-time verify a password against a stored `scrypt$...` hash. */
|
|
||||||
export const verifyPassword = (password: string, stored: string): boolean => {
|
|
||||||
const parts = stored.split("$");
|
|
||||||
if (parts.length !== 3 || parts[0] !== "scrypt" || !parts[1] || !parts[2])
|
|
||||||
return false;
|
|
||||||
const salt = Buffer.from(parts[1], "base64url");
|
|
||||||
const expected = Buffer.from(parts[2], "base64url");
|
|
||||||
let actual: Buffer;
|
|
||||||
try {
|
|
||||||
actual = scryptSync(password, salt, expected.length);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
// The plugin-local REST/SSE API — a pure `(Request) => Response | undefined` the UI talks to. Paths
|
|
||||||
// arrive prefix-stripped (the console proxy already removed `/plugin-ui/playnite`), so we match
|
|
||||||
// `/api/...` directly; a non-`/api` path returns `undefined` to fall through to the static SPA. The
|
|
||||||
// same router backs both the console-proxied server and the standalone fallback.
|
|
||||||
|
|
||||||
import { type RawConfig, resolveConfig } from "../config.js";
|
|
||||||
import type { Engine, EngineStatus } from "../engine/index.js";
|
|
||||||
import { loadConfig, saveRawConfig } from "../state.js";
|
|
||||||
|
|
||||||
const json = (data: unknown, status = 200): Response =>
|
|
||||||
Response.json(data, { status });
|
|
||||||
|
|
||||||
/** A Server-Sent-Events stream that pushes the engine status on every change. */
|
|
||||||
const sse = (engine: Engine): Response => {
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
let unsubscribe = () => {};
|
|
||||||
let ping: ReturnType<typeof setInterval> | undefined;
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
const send = (status: EngineStatus) => {
|
|
||||||
try {
|
|
||||||
controller.enqueue(
|
|
||||||
enc.encode(`event: status\ndata: ${JSON.stringify(status)}\n\n`),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// stream already closed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
send(engine.status());
|
|
||||||
unsubscribe = engine.subscribe(send);
|
|
||||||
ping = setInterval(() => {
|
|
||||||
try {
|
|
||||||
controller.enqueue(enc.encode(": ping\n\n"));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, 15_000);
|
|
||||||
(ping as { unref?: () => void }).unref?.();
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
unsubscribe();
|
|
||||||
if (ping) clearInterval(ping);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
"content-type": "text/event-stream",
|
|
||||||
"cache-control": "no-cache",
|
|
||||||
connection: "keep-alive",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Build the plugin-local API router bound to an engine. */
|
|
||||||
export const makeRouter =
|
|
||||||
(engine: Engine) =>
|
|
||||||
async (req: Request): Promise<Response | undefined> => {
|
|
||||||
const { pathname } = new URL(req.url);
|
|
||||||
const method = req.method;
|
|
||||||
if (!pathname.startsWith("/api/")) return undefined; // static SPA handles it
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (pathname === "/api/status" && method === "GET") {
|
|
||||||
return json(engine.status());
|
|
||||||
}
|
|
||||||
if (pathname === "/api/config" && method === "GET") {
|
|
||||||
return json(loadConfig());
|
|
||||||
}
|
|
||||||
if (pathname === "/api/config" && method === "PUT") {
|
|
||||||
const body = (await req.json()) as RawConfig;
|
|
||||||
// Persist the authored shape; re-resolve so the response echoes defaults.
|
|
||||||
saveRawConfig(body);
|
|
||||||
const resolved = resolveConfig(body);
|
|
||||||
await engine.reconfigure();
|
|
||||||
return json(resolved);
|
|
||||||
}
|
|
||||||
if (pathname === "/api/preview" && method === "GET") {
|
|
||||||
return json(engine.computeDry());
|
|
||||||
}
|
|
||||||
if (pathname === "/api/sync" && method === "POST") {
|
|
||||||
const report = await engine.sync("ui");
|
|
||||||
return report ? json(report) : json({ status: engine.status() }, 200);
|
|
||||||
}
|
|
||||||
if (pathname === "/api/events" && method === "GET") {
|
|
||||||
return sse(engine);
|
|
||||||
}
|
|
||||||
return json({ error: "not found" }, 404);
|
|
||||||
} catch (e) {
|
|
||||||
return json({ error: String(e) }, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
// The standalone fallback UI server: for host-only installs without the console, or as insurance if
|
|
||||||
// the console surface is unavailable. Serves the SAME SPA + router, but on a fixed port with its own
|
|
||||||
// password/session auth instead of the console proxy. Fail-closed: a non-loopback bind REQUIRES a
|
|
||||||
// password (a UI that can rewrite the config — and thus the launch commands — is host-user code).
|
|
||||||
|
|
||||||
import * as nodePath from "node:path";
|
|
||||||
import type { Config } from "../config.js";
|
|
||||||
import type { Engine } from "../engine/index.js";
|
|
||||||
import { log } from "../log.js";
|
|
||||||
import { resolveUiDir } from "../paths.js";
|
|
||||||
import { verifyPassword } from "./password.js";
|
|
||||||
import { makeRouter } from "./router.js";
|
|
||||||
|
|
||||||
export interface StandaloneHandle {
|
|
||||||
url: string;
|
|
||||||
close(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
||||||
const COOKIE = "pf_playnite_session";
|
|
||||||
|
|
||||||
const loginPage = (error?: string): Response =>
|
|
||||||
new Response(
|
|
||||||
`<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
|
|
||||||
<title>Playnite · Punktfunk</title>
|
|
||||||
<style>body{font:15px system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0b0b0f;color:#e5e7eb}
|
|
||||||
form{display:grid;gap:.75rem;width:260px}input{padding:.5rem;border-radius:.5rem;border:1px solid #333;background:#111;color:#eee}
|
|
||||||
button{padding:.5rem;border-radius:.5rem;border:0;background:#6c5bf3;color:#fff;cursor:pointer}.e{color:#f87171;font-size:13px}</style>
|
|
||||||
<form method=post action="./login"><h1>Playnite Sync</h1>${error ? `<div class=e>${error}</div>` : ""}
|
|
||||||
<input type=password name=password placeholder="Password" autofocus><button>Sign in</button></form>`,
|
|
||||||
{
|
|
||||||
status: error ? 401 : 200,
|
|
||||||
headers: { "content-type": "text/html; charset=utf-8" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Resolve a request path to a file inside `root`, or null on traversal (mirrors servePluginUi). */
|
|
||||||
const staticFile = (root: string, pathname: string): string | null => {
|
|
||||||
let rel: string;
|
|
||||||
try {
|
|
||||||
rel = decodeURIComponent(pathname);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (rel.endsWith("/")) rel += "index.html";
|
|
||||||
if (!rel.startsWith("/")) rel = `/${rel}`;
|
|
||||||
const abs = nodePath.resolve(root, `.${rel}`);
|
|
||||||
const rootAbs = nodePath.resolve(root);
|
|
||||||
if (abs !== rootAbs && !abs.startsWith(rootAbs + nodePath.sep)) return null;
|
|
||||||
return abs;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cookieHas = (req: Request, name: string, value: string): boolean => {
|
|
||||||
const raw = req.headers.get("cookie") ?? "";
|
|
||||||
return raw.split(/;\s*/).some((c) => c === `${name}=${value}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Serve the standalone UI. Throws (fail-closed) if a non-loopback bind lacks a password. */
|
|
||||||
export const serveStandaloneUi = (
|
|
||||||
engine: Engine,
|
|
||||||
config: Config,
|
|
||||||
): StandaloneHandle => {
|
|
||||||
const { port, bind, passwordHash } = config.ui;
|
|
||||||
const isLoopback = LOOPBACK.has(bind);
|
|
||||||
if (!isLoopback && !passwordHash) {
|
|
||||||
throw new Error(
|
|
||||||
`standalone UI refuses to bind ${bind} without a password (set ui.passwordHash via \`punktfunk-plugin-playnite set-password\`, or bind 127.0.0.1)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const authRequired = Boolean(passwordHash);
|
|
||||||
const sessionToken = crypto.randomUUID().replace(/-/g, "");
|
|
||||||
const root = resolveUiDir(import.meta.url);
|
|
||||||
const router = makeRouter(engine);
|
|
||||||
|
|
||||||
const authed = (req: Request) =>
|
|
||||||
!authRequired || cookieHas(req, COOKIE, sessionToken);
|
|
||||||
|
|
||||||
const server = Bun.serve({
|
|
||||||
hostname: bind,
|
|
||||||
port,
|
|
||||||
async fetch(req) {
|
|
||||||
const { pathname } = new URL(req.url);
|
|
||||||
if (pathname === "/__health") return Response.json({ ok: true });
|
|
||||||
|
|
||||||
if (authRequired && pathname === "/login" && req.method === "POST") {
|
|
||||||
const form = await req.formData().catch(() => null);
|
|
||||||
const password = form?.get("password");
|
|
||||||
if (
|
|
||||||
typeof password === "string" &&
|
|
||||||
passwordHash &&
|
|
||||||
verifyPassword(password, passwordHash)
|
|
||||||
) {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 303,
|
|
||||||
headers: {
|
|
||||||
location: "./",
|
|
||||||
"set-cookie": `${COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return loginPage("Incorrect password");
|
|
||||||
}
|
|
||||||
if (pathname === "/logout" && req.method === "POST") {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 303,
|
|
||||||
headers: {
|
|
||||||
location: "./",
|
|
||||||
"set-cookie": `${COOKIE}=; Max-Age=0; Path=/`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authed(req)) {
|
|
||||||
if (pathname.startsWith("/api/"))
|
|
||||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
||||||
return loginPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) plugin-local API
|
|
||||||
const api = await router(req);
|
|
||||||
if (api) return api;
|
|
||||||
// 2) static asset
|
|
||||||
const file = staticFile(root, pathname);
|
|
||||||
if (file) {
|
|
||||||
const bf = Bun.file(file);
|
|
||||||
if (await bf.exists()) return new Response(bf);
|
|
||||||
}
|
|
||||||
// 3) SPA fallback
|
|
||||||
if (
|
|
||||||
req.method === "GET" &&
|
|
||||||
(req.headers.get("accept") ?? "").includes("text/html")
|
|
||||||
) {
|
|
||||||
const index = Bun.file(nodePath.join(root, "index.html"));
|
|
||||||
if (await index.exists()) return new Response(index);
|
|
||||||
}
|
|
||||||
return new Response("not found", { status: 404 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = `http://${isLoopback ? "127.0.0.1" : bind}:${server.port}`;
|
|
||||||
log(
|
|
||||||
`standalone UI on ${url}${authRequired ? " (password-protected)" : " (loopback, no password)"}`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
async close() {
|
|
||||||
server.stop(true);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
// Config/cache persistence: the plugin owns two files under `<config_dir>/playnite/`, created 0700.
|
|
||||||
// `config.json` is operator-editable and atomically rewritten (temp + rename); the plugin refuses a
|
|
||||||
// group/world-writable `config.json` (the same sshd rule the runner and hooks enforce, since the UI
|
|
||||||
// can rewrite it and the launch commands it produces run as the host user). `cache.json` is disposable
|
|
||||||
// derived state.
|
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import { type Config, type RawConfig, resolveConfig } from "./config.js";
|
|
||||||
import { cachePath, configPath, pluginDir } from "./paths.js";
|
|
||||||
|
|
||||||
export interface Cache {
|
|
||||||
/** Fingerprint + count of the last reconcile — lets a re-read skip an unchanged PUT. */
|
|
||||||
lastSync?: { fingerprint: string; count: number; at: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const emptyCache = (): Cache => ({});
|
|
||||||
|
|
||||||
/** Create the plugin dir 0700 if absent (idempotent). */
|
|
||||||
const ensureDir = (): void => {
|
|
||||||
fs.mkdirSync(pluginDir(), { recursive: true, mode: 0o700 });
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Refuse a group/world-writable config file (POSIX only; Windows config dir is DACL'd). */
|
|
||||||
const assertNotWorldWritable = (file: string): void => {
|
|
||||||
if (process.platform === "win32") return;
|
|
||||||
let mode: number;
|
|
||||||
try {
|
|
||||||
mode = fs.statSync(file).mode;
|
|
||||||
} catch {
|
|
||||||
return; // absent — nothing to guard
|
|
||||||
}
|
|
||||||
if ((mode & 0o022) !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`refusing ${file}: it is group/world-writable (chmod go-w it first) — this file controls commands run as the host user`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Atomic write: temp file in the same dir, then rename. */
|
|
||||||
const atomicWrite = (file: string, data: string): void => {
|
|
||||||
ensureDir();
|
|
||||||
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
||||||
fs.writeFileSync(tmp, data, { mode: 0o600 });
|
|
||||||
fs.renameSync(tmp, file);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Load the authored config (defaults filled). A missing file yields the empty config. */
|
|
||||||
export const loadConfig = (): Config => {
|
|
||||||
const file = configPath();
|
|
||||||
let raw = "";
|
|
||||||
try {
|
|
||||||
raw = fs.readFileSync(file, "utf8");
|
|
||||||
} catch {
|
|
||||||
return resolveConfig({});
|
|
||||||
}
|
|
||||||
assertNotWorldWritable(file);
|
|
||||||
const parsed = JSON.parse(raw) as RawConfig;
|
|
||||||
return resolveConfig(parsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Persist a config (atomic). */
|
|
||||||
export const saveConfig = (config: Config): void => {
|
|
||||||
atomicWrite(configPath(), `${JSON.stringify(config, null, 2)}\n`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Save the raw (operator-authored) config verbatim — the shape the UI edits. */
|
|
||||||
export const saveRawConfig = (raw: RawConfig): void => {
|
|
||||||
atomicWrite(configPath(), `${JSON.stringify(raw, null, 2)}\n`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadCache = (): Cache => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache;
|
|
||||||
} catch {
|
|
||||||
return emptyCache();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveCache = (cache: Cache): void => {
|
|
||||||
atomicWrite(cachePath(), JSON.stringify(cache));
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Absolute paths, exported for the UI/CLI status view. */
|
|
||||||
export const statePaths = () => ({
|
|
||||||
dir: pluginDir(),
|
|
||||||
config: configPath(),
|
|
||||||
cache: cachePath(),
|
|
||||||
relConfig: path.basename(configPath()),
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// Best-effort read of the plugin's own version from package.json (shown in the console page header).
|
|
||||||
// `../package.json` resolves to the repo root whether this module runs from `src/` or `dist/`.
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
export const readVersion = (): string | undefined => {
|
|
||||||
try {
|
|
||||||
const file = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
||||||
const pkg = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
|
||||||
version?: string;
|
|
||||||
};
|
|
||||||
return pkg.version;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
-34
@@ -1,34 +0,0 @@
|
|||||||
// The host provider-reconcile wire shapes (mirrors `@punktfunk/host`'s generated `ProviderEntryInput`
|
|
||||||
// et al. — the facade doesn't re-export them, and we PUT the raw array via `pf.request`, so we own the
|
|
||||||
// shape here). Kept minimal and stable; the host validates it.
|
|
||||||
|
|
||||||
/** Cover art — all URLs (or `data:` URLs). The console/clients prefer `portrait` for a grid. */
|
|
||||||
export interface Artwork {
|
|
||||||
portrait?: string | null;
|
|
||||||
hero?: string | null;
|
|
||||||
logo?: string | null;
|
|
||||||
header?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How the host launches a title. For this plugin always `{ kind: "command", value: <shell command> }`
|
|
||||||
* — the host wraps it as `cmd.exe /c <value>` in the interactive user session (Windows). */
|
|
||||||
export interface LaunchSpec {
|
|
||||||
kind: "command";
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A per-title prep/undo step: `do` runs before launch, `undo` at session end (reverse order). */
|
|
||||||
export interface PrepCmd {
|
|
||||||
do: string;
|
|
||||||
undo?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One title in the declarative reconcile payload (`PUT /api/v1/library/provider/playnite`). */
|
|
||||||
export interface ProviderEntryInput {
|
|
||||||
/** The provider's stable id for this title — the reconcile diff key (Playnite's game Guid). */
|
|
||||||
external_id: string;
|
|
||||||
title: string;
|
|
||||||
art?: Artwork;
|
|
||||||
launch?: LaunchSpec | null;
|
|
||||||
prep?: PrepCmd[];
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
// State persistence lands under `<config_dir>/plugin-state/playnite` — the one dir the
|
|
||||||
// de-privileged Windows runner (LocalService) may write. A regression here (writing straight under
|
|
||||||
// the config dir) would EPERM under the runner and lose the operator's config.
|
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as os from "node:os";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import { loadConfig, saveRawConfig } from "../src/state.js";
|
|
||||||
|
|
||||||
let root: string;
|
|
||||||
let saved: string | undefined;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
saved = process.env.PUNKTFUNK_CONFIG_DIR;
|
|
||||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "pn-state-"));
|
|
||||||
process.env.PUNKTFUNK_CONFIG_DIR = root;
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
|
|
||||||
else process.env.PUNKTFUNK_CONFIG_DIR = saved;
|
|
||||||
fs.rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("state location", () => {
|
|
||||||
test("persists under plugin-state/playnite and round-trips", () => {
|
|
||||||
saveRawConfig({ playniteDir: "/games/playnite" });
|
|
||||||
expect(
|
|
||||||
fs.existsSync(path.join(root, "plugin-state", "playnite", "config.json")),
|
|
||||||
).toBe(true);
|
|
||||||
// Not written straight under the config dir (the LocalService-unwritable location).
|
|
||||||
expect(fs.existsSync(path.join(root, "playnite", "config.json"))).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(loadConfig().playniteDir).toBe("/games/playnite");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"noEmit": false,
|
|
||||||
"declaration": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"moduleResolution": "bundler"
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["src/**/*.test.ts", "test", "ui", "dist", "node_modules"]
|
|
||||||
}
|
|
||||||
-342
@@ -1,342 +0,0 @@
|
|||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"name": "punktfunk-plugin-playnite-ui",
|
|
||||||
"dependencies": {
|
|
||||||
"lucide-react": "^0.469.0",
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"react-dom": "^19.2.0",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
|
||||||
"@types/react": "^19.2.0",
|
|
||||||
"@types/react-dom": "^19.2.0",
|
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
|
||||||
"tailwindcss": "^4.3.2",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"vite": "^7.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages": {
|
|
||||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
|
||||||
|
|
||||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
|
||||||
|
|
||||||
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
|
||||||
|
|
||||||
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
|
||||||
|
|
||||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
|
||||||
|
|
||||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
|
||||||
|
|
||||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
|
||||||
|
|
||||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
|
||||||
|
|
||||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
|
||||||
|
|
||||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
|
||||||
|
|
||||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
|
||||||
|
|
||||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
|
||||||
|
|
||||||
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
|
||||||
|
|
||||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
|
||||||
|
|
||||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
|
|
||||||
|
|
||||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
|
|
||||||
|
|
||||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
|
||||||
|
|
||||||
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
|
||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
|
||||||
|
|
||||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
|
||||||
|
|
||||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
|
||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
|
||||||
|
|
||||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
|
|
||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
|
||||||
|
|
||||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
|
||||||
|
|
||||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
|
||||||
|
|
||||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
|
||||||
|
|
||||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
|
||||||
|
|
||||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="],
|
|
||||||
|
|
||||||
"browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="],
|
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
|
||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="],
|
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.24.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw=="],
|
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
|
||||||
|
|
||||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
|
||||||
|
|
||||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
|
||||||
|
|
||||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
|
||||||
|
|
||||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
|
||||||
|
|
||||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
|
||||||
|
|
||||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
|
||||||
|
|
||||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
|
||||||
|
|
||||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
|
||||||
|
|
||||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
|
||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
|
||||||
|
|
||||||
"lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
|
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
|
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
|
|
||||||
|
|
||||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
|
||||||
|
|
||||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
|
||||||
|
|
||||||
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
|
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
|
||||||
|
|
||||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
||||||
|
|
||||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
|
||||||
|
|
||||||
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
|
||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en" class="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Playnite · Punktfunk</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+24
-8
@@ -2,24 +2,40 @@
|
|||||||
"name": "punktfunk-plugin-playnite-ui",
|
"name": "punktfunk-plugin-playnite-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "The Playnite plugin SPA — built into ../dist/ui and served by servePluginUi. Self-contained (Tailwind + the Punktfunk brand tokens) so it builds with no design-system registry auth.",
|
"description": "The Playnite plugin SPA — Punktfunk console polish (@unom/ui + @unom/app-ui), an Effect-native data layer derived from the shared HttpApi contract, and a zero-host mock dev loop. Builds into plugin/dist/ui.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:live": "VITE_API_MODE=live vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"storybook": "storybook dev -p 6014",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@effect/atom-react": "4.0.0-beta.99",
|
||||||
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
|
"@punktfunk/plugin-kit": "^0.1.4",
|
||||||
|
"@unom/app-ui": "^0.2.0",
|
||||||
|
"@unom/style": "^0.4.4",
|
||||||
|
"@unom/ui": "^0.9.1",
|
||||||
|
"effect": "4.0.0-beta.99",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"react": "^19.2.0",
|
"motion": "^12.42.2",
|
||||||
"react-dom": "^19.2.0"
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playnite/contract": "workspace:*",
|
||||||
|
"@storybook/react-vite": "^10.4.6",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@types/react": "^19.2.0",
|
"@types/node": "^22.20.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react": "^19.2.17",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"storybook": "^10.4.6",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.2",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-424
@@ -1,424 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle2,
|
|
||||||
FolderSearch,
|
|
||||||
Gamepad2,
|
|
||||||
Library,
|
|
||||||
Loader2,
|
|
||||||
RefreshCw,
|
|
||||||
Save,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { type ReactNode, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
type Config,
|
|
||||||
getConfig,
|
|
||||||
putConfig,
|
|
||||||
runSync,
|
|
||||||
type Status,
|
|
||||||
useStatusStream,
|
|
||||||
} from "./api.js";
|
|
||||||
|
|
||||||
const relTime = (ms?: number): string => {
|
|
||||||
if (!ms) return "never";
|
|
||||||
const s = Math.round((Date.now() - ms) / 1000);
|
|
||||||
if (s < 60) return `${s}s ago`;
|
|
||||||
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
||||||
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
|
||||||
return `${Math.round(s / 86400)}d ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Card({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
children,
|
|
||||||
right,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
right?: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section className="rounded-[var(--radius)] border border-border bg-card p-5">
|
|
||||||
<header className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<h2 className="flex items-center gap-2 text-sm font-semibold tracking-wide text-muted uppercase">
|
|
||||||
<span className="text-brand">{icon}</span>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{right}
|
|
||||||
</header>
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toggle({
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
hint?: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="flex cursor-pointer items-start justify-between gap-4 py-2">
|
|
||||||
<span>
|
|
||||||
<span className="block text-sm">{label}</span>
|
|
||||||
{hint && <span className="block text-xs text-muted">{hint}</span>}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? "bg-brand" : "bg-elevated"}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`block h-5 w-5 rounded-full bg-white transition-transform ${checked ? "translate-x-5" : "translate-x-0.5"}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputCls =
|
|
||||||
"w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-brand";
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
const status = useStatusStream();
|
|
||||||
const [config, setConfig] = useState<Config>();
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [syncing, setSyncing] = useState(false);
|
|
||||||
const [msg, setMsg] = useState<string>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getConfig()
|
|
||||||
.then(setConfig)
|
|
||||||
.catch((e) => setMsg(String(e)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const patch = (p: Partial<Config>) =>
|
|
||||||
setConfig((c) => (c ? { ...c, ...p } : c));
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (!config) return;
|
|
||||||
setSaving(true);
|
|
||||||
setMsg(undefined);
|
|
||||||
try {
|
|
||||||
setConfig(await putConfig(config));
|
|
||||||
setMsg("Saved — re-syncing with the new settings.");
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(String(e));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sync = async () => {
|
|
||||||
setSyncing(true);
|
|
||||||
setMsg(undefined);
|
|
||||||
try {
|
|
||||||
await runSync();
|
|
||||||
setMsg("Sync requested.");
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(String(e));
|
|
||||||
} finally {
|
|
||||||
setSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl px-5 py-8">
|
|
||||||
<header className="mb-6 flex items-center gap-3">
|
|
||||||
<span className="grid h-10 w-10 place-items-center rounded-xl bg-brand text-brand-fg">
|
|
||||||
<Gamepad2 size={22} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">Playnite</h1>
|
|
||||||
<p className="text-xs text-muted">
|
|
||||||
Sync your Playnite library into the Punktfunk game library.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{status ? (
|
|
||||||
<div className="grid gap-5">
|
|
||||||
<Connection status={status} />
|
|
||||||
<LibrarySummary status={status} onSync={sync} syncing={syncing} />
|
|
||||||
{config && (
|
|
||||||
<Settings
|
|
||||||
config={config}
|
|
||||||
patch={patch}
|
|
||||||
onSave={save}
|
|
||||||
saving={saving}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted">Loading…</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{msg && (
|
|
||||||
<p className="mt-5 rounded-lg border border-border bg-surface px-4 py-2 text-sm text-muted">
|
|
||||||
{msg}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Connection({ status }: { status: Status }) {
|
|
||||||
const found = Boolean(status.location.file);
|
|
||||||
return (
|
|
||||||
<Card title="Playnite connection" icon={<FolderSearch size={16} />}>
|
|
||||||
{found ? (
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CheckCircle2 className="mt-0.5 shrink-0 text-ok" size={18} />
|
|
||||||
<div className="min-w-0 text-sm">
|
|
||||||
<p>
|
|
||||||
Exporter connected — updated{" "}
|
|
||||||
<span className="text-fg">
|
|
||||||
{relTime(status.location.mtime ?? undefined)}
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 truncate font-mono text-xs text-muted">
|
|
||||||
{status.location.file}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<AlertTriangle className="mt-0.5 shrink-0 text-warn" size={18} />
|
|
||||||
<div className="text-sm">
|
|
||||||
<p>
|
|
||||||
No exporter output found. Install the{" "}
|
|
||||||
<span className="text-fg">Punktfunk Sync</span> extension in
|
|
||||||
Playnite (then restart Playnite).
|
|
||||||
</p>
|
|
||||||
{status.exportError && (
|
|
||||||
<p className="mt-1 text-xs text-muted">{status.exportError}</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-2 text-xs text-muted">
|
|
||||||
Looked under:{" "}
|
|
||||||
{status.location.dir ?? "no Playnite data dir found"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LibrarySummary({
|
|
||||||
status,
|
|
||||||
onSync,
|
|
||||||
syncing,
|
|
||||||
}: {
|
|
||||||
status: Status;
|
|
||||||
onSync: () => void;
|
|
||||||
syncing: boolean;
|
|
||||||
}) {
|
|
||||||
const report = status.lastReport;
|
|
||||||
const busy = syncing || status.syncing;
|
|
||||||
const sources = report
|
|
||||||
? Object.entries(report.perSource).sort((a, b) => b[1] - a[1])
|
|
||||||
: [];
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
title="Library"
|
|
||||||
icon={<Library size={16} />}
|
|
||||||
right={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSync}
|
|
||||||
disabled={busy}
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{busy ? (
|
|
||||||
<Loader2 className="animate-spin" size={15} />
|
|
||||||
) : (
|
|
||||||
<RefreshCw size={15} />
|
|
||||||
)}
|
|
||||||
Sync now
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-3xl font-semibold">
|
|
||||||
{status.lastSync?.count ?? 0}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted">
|
|
||||||
title(s) synced · last {relTime(status.lastSync?.at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{sources.length > 0 && (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{sources.map(([src, n]) => (
|
|
||||||
<span
|
|
||||||
key={src}
|
|
||||||
className="rounded-full border border-border bg-surface px-2.5 py-1 text-xs"
|
|
||||||
>
|
|
||||||
{src} <span className="text-muted">{n}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{report && report.skipped.length > 0 && (
|
|
||||||
<p className="mt-3 text-xs text-muted">
|
|
||||||
{report.skipped.length} skipped (e.g. {report.skipped[0]?.title}:{" "}
|
|
||||||
{report.skipped[0]?.reason})
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{report?.warnings.map((w) => (
|
|
||||||
<p key={w} className="mt-2 text-xs text-warn">
|
|
||||||
{w}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Settings({
|
|
||||||
config,
|
|
||||||
patch,
|
|
||||||
onSave,
|
|
||||||
saving,
|
|
||||||
}: {
|
|
||||||
config: Config;
|
|
||||||
patch: (p: Partial<Config>) => void;
|
|
||||||
onSave: () => void;
|
|
||||||
saving: boolean;
|
|
||||||
}) {
|
|
||||||
const csv = (a: string[]) => a.join(", ");
|
|
||||||
const parseCsv = (s: string) =>
|
|
||||||
s
|
|
||||||
.split(",")
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
title="Settings"
|
|
||||||
icon={<Gamepad2 size={16} />}
|
|
||||||
right={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-1.5 text-sm hover:bg-elevated disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<Loader2 className="animate-spin" size={15} />
|
|
||||||
) : (
|
|
||||||
<Save size={15} />
|
|
||||||
)}
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
<Toggle
|
|
||||||
label="Installed games only"
|
|
||||||
hint="Uncheck to also sync games Playnite hasn't installed yet."
|
|
||||||
checked={config.filter.installedOnly}
|
|
||||||
onChange={(v) =>
|
|
||||||
patch({ filter: { ...config.filter, installedOnly: v } })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label="Include hidden games"
|
|
||||||
checked={config.filter.includeHidden}
|
|
||||||
onChange={(v) =>
|
|
||||||
patch({ filter: { ...config.filter, includeHidden: v } })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label="Embed cover art"
|
|
||||||
hint="Inlines each cover as a data URL. Turn off for a lighter library payload."
|
|
||||||
checked={config.art.mode === "dataurl"}
|
|
||||||
onChange={(v) =>
|
|
||||||
patch({ art: { ...config.art, mode: v ? "dataurl" : "off" } })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label="Embed background art too"
|
|
||||||
hint="Backgrounds are large — off by default."
|
|
||||||
checked={config.art.includeBackground}
|
|
||||||
onChange={(v) =>
|
|
||||||
patch({ art: { ...config.art, includeBackground: v } })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-3">
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1 block text-xs text-muted">
|
|
||||||
Only these sources (comma-separated, blank = all)
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
className={inputCls}
|
|
||||||
placeholder="Steam, GOG, Epic"
|
|
||||||
defaultValue={csv(config.filter.sources)}
|
|
||||||
onBlur={(e) =>
|
|
||||||
patch({
|
|
||||||
filter: { ...config.filter, sources: parseCsv(e.target.value) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1 block text-xs text-muted">Exclude sources</span>
|
|
||||||
<input
|
|
||||||
className={inputCls}
|
|
||||||
placeholder="Xbox"
|
|
||||||
defaultValue={csv(config.filter.excludeSources)}
|
|
||||||
onBlur={(e) =>
|
|
||||||
patch({
|
|
||||||
filter: {
|
|
||||||
...config.filter,
|
|
||||||
excludeSources: parseCsv(e.target.value),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1 block text-xs text-muted">
|
|
||||||
Poll every (min)
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className={inputCls}
|
|
||||||
defaultValue={config.sync.pollMinutes}
|
|
||||||
onBlur={(e) =>
|
|
||||||
patch({
|
|
||||||
sync: {
|
|
||||||
...config.sync,
|
|
||||||
pollMinutes: Math.max(1, Number(e.target.value) || 5),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1 block text-xs text-muted">
|
|
||||||
Playnite dir override
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
className={inputCls}
|
|
||||||
placeholder="(auto-detect)"
|
|
||||||
defaultValue={config.playniteDir ?? ""}
|
|
||||||
onBlur={(e) =>
|
|
||||||
patch({ playniteDir: e.target.value.trim() || undefined })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-108
@@ -1,108 +0,0 @@
|
|||||||
// Typed client for the plugin-local API. Relative paths — the SPA is mounted under the console proxy
|
|
||||||
// prefix, so `api/...` resolves to `/plugin-ui/playnite/api/...`. Types mirror `src/engine` +
|
|
||||||
// `src/config`.
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export interface Location {
|
|
||||||
dir: string | null;
|
|
||||||
candidates: string[];
|
|
||||||
file: string | null;
|
|
||||||
mtime: number | null;
|
|
||||||
}
|
|
||||||
export interface Skipped {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
export interface Report {
|
|
||||||
generatedAt: string;
|
|
||||||
considered: number;
|
|
||||||
included: number;
|
|
||||||
skipped: Skipped[];
|
|
||||||
warnings: string[];
|
|
||||||
perSource: Record<string, number>;
|
|
||||||
}
|
|
||||||
export interface LastSync {
|
|
||||||
fingerprint: string;
|
|
||||||
count: number;
|
|
||||||
at: number;
|
|
||||||
}
|
|
||||||
export interface Status {
|
|
||||||
platform: string;
|
|
||||||
location: Location;
|
|
||||||
exportError?: string;
|
|
||||||
lastSync?: LastSync;
|
|
||||||
lastReport?: Report;
|
|
||||||
paths: { dir: string; config: string; cache: string; relConfig: string };
|
|
||||||
syncing: boolean;
|
|
||||||
}
|
|
||||||
export interface Config {
|
|
||||||
playniteDir?: string;
|
|
||||||
sync: { pollMinutes: number; watch: boolean; debounceMs: number };
|
|
||||||
filter: {
|
|
||||||
installedOnly: boolean;
|
|
||||||
includeHidden: boolean;
|
|
||||||
sources: string[];
|
|
||||||
excludeSources: string[];
|
|
||||||
};
|
|
||||||
art: {
|
|
||||||
mode: "dataurl" | "off";
|
|
||||||
maxBytes: number;
|
|
||||||
includeBackground: boolean;
|
|
||||||
};
|
|
||||||
ui: {
|
|
||||||
standalone: boolean;
|
|
||||||
port: number;
|
|
||||||
bind: string;
|
|
||||||
passwordHash?: string;
|
|
||||||
};
|
|
||||||
devEntry: boolean;
|
|
||||||
}
|
|
||||||
export interface Preview {
|
|
||||||
location: Location;
|
|
||||||
report?: Report;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = async <T>(path: string, opts?: RequestInit): Promise<T> => {
|
|
||||||
const res = await fetch(path, {
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
...opts,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
||||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as T;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStatus = () => api<Status>("api/status");
|
|
||||||
export const getConfig = () => api<Config>("api/config");
|
|
||||||
export const putConfig = (config: Config) =>
|
|
||||||
api<Config>("api/config", { method: "PUT", body: JSON.stringify(config) });
|
|
||||||
export const getPreview = () => api<Preview>("api/preview");
|
|
||||||
export const runSync = () => api<unknown>("api/sync", { method: "POST" });
|
|
||||||
|
|
||||||
/** Live engine status via SSE (falls back to a one-shot fetch if EventSource fails). */
|
|
||||||
export const useStatusStream = (): Status | undefined => {
|
|
||||||
const [status, setStatus] = useState<Status>();
|
|
||||||
useEffect(() => {
|
|
||||||
getStatus()
|
|
||||||
.then(setStatus)
|
|
||||||
.catch(() => {});
|
|
||||||
try {
|
|
||||||
const es = new EventSource("api/events");
|
|
||||||
es.addEventListener("status", (e) => {
|
|
||||||
try {
|
|
||||||
setStatus(JSON.parse((e as MessageEvent).data));
|
|
||||||
} catch {
|
|
||||||
// ignore malformed frame
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => es.close();
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
return status;
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { StrictMode } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { App } from "./App.js";
|
|
||||||
import "./styles.css";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
/* Punktfunk violet identity — dark only (the SPA is embedded on the console's dark canvas). The token
|
|
||||||
names feed Tailwind v4 utilities directly: `bg-card`, `text-muted`, `bg-brand`, `border-border`… */
|
|
||||||
@theme {
|
|
||||||
--color-bg: #0b0b0f;
|
|
||||||
--color-surface: #131120;
|
|
||||||
--color-card: #1a1726;
|
|
||||||
--color-elevated: #221d33;
|
|
||||||
--color-border: #2a2440;
|
|
||||||
--color-fg: #e9e6f3;
|
|
||||||
--color-muted: #a39fbb;
|
|
||||||
--color-brand: #6c5bf3;
|
|
||||||
--color-brand-hover: #7d6ef5;
|
|
||||||
--color-brand-fg: #ffffff;
|
|
||||||
--color-ok: #34d399;
|
|
||||||
--color-warn: #fbbf24;
|
|
||||||
--color-err: #f87171;
|
|
||||||
--radius: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-fg);
|
|
||||||
font-family:
|
|
||||||
"Geist", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
*::-webkit-scrollbar {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
*::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--color-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
|
|
||||||
// `base: "./"` (relative asset URLs) is the plugin-ui-surface contract: the SPA is mounted under
|
|
||||||
// `/plugin-ui/playnite/` behind the console proxy. Built into `../dist/ui`, which `servePluginUi`'s
|
|
||||||
// staticDir points at.
|
|
||||||
export default defineConfig({
|
|
||||||
base: "./",
|
|
||||||
plugins: [react(), tailwindcss()],
|
|
||||||
build: {
|
|
||||||
outDir: "../dist/ui",
|
|
||||||
emptyOutDir: true,
|
|
||||||
},
|
|
||||||
server: { port: 5601 },
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user