From 6694be9848fcc7a5e36c5d638f42a5db46937f4d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 18 Jul 2026 23:35:35 +0200 Subject: [PATCH] feat: Playnite library sync plugin + exporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A two-part Punktfunk plugin that syncs the Playnite library into the host game library as the `playnite` provider: - exporter/ — a minimal C# Playnite GenericPlugin ("Punktfunk Sync") that writes punktfunk-library.json on every library change. Reading Playnite's live-locked LiteDB from outside isn't robust, so this adapter runs inside Playnite. Builds net462 via reference assemblies; packaged as a .pext. - src/ — the TS plugin (on @punktfunk/host, rom-manager shape): watches the export, embeds covers as data URLs, reconciles via PUT /library/provider/playnite, with a console-hosted web UI + standalone fallback + CLI. Launch = playnite://playnite/start/, run by the host in the interactive session so Playnite performs the real launch. Verified locally: backend tsc + 23 tests + biome clean, SPA build, C# exporter build + .pext pack. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yml | 101 ++++++++ .gitignore | 11 + LICENSE-APACHE | 201 +++++++++++++++ LICENSE-MIT | 21 ++ README.md | 128 ++++++++++ biome.json | 51 ++++ bun.lock | 93 +++++++ bunfig.toml | 5 + config.example.json | 24 ++ exporter/Model.cs | 68 +++++ exporter/PunktfunkSync.csproj | 40 +++ exporter/PunktfunkSyncPlugin.cs | 136 ++++++++++ exporter/README.md | 69 ++++++ exporter/extension.yaml | 9 + package.json | 55 +++++ src/art.ts | 60 +++++ src/cli.ts | 126 ++++++++++ src/config.ts | 113 +++++++++ src/const.ts | 8 + src/engine/index.ts | 318 ++++++++++++++++++++++++ src/engine/reconcile.ts | 148 +++++++++++ src/export-schema.ts | 55 +++++ src/host.ts | 20 ++ src/index.ts | 63 +++++ src/log.ts | 10 + src/paths.ts | 53 ++++ src/playnite.ts | 135 ++++++++++ src/server/index.ts | 30 +++ src/server/password.ts | 30 +++ src/server/router.ts | 91 +++++++ src/server/standalone.ts | 150 +++++++++++ src/state.ts | 90 +++++++ src/version.ts | 16 ++ src/wire.ts | 34 +++ test/art.test.ts | 82 ++++++ test/playnite.test.ts | 71 ++++++ test/reconcile.test.ts | 149 +++++++++++ tsconfig.build.json | 12 + tsconfig.json | 16 ++ ui/bun.lock | 342 ++++++++++++++++++++++++++ ui/index.html | 12 + ui/package.json | 25 ++ ui/src/App.tsx | 424 ++++++++++++++++++++++++++++++++ ui/src/api.ts | 108 ++++++++ ui/src/main.tsx | 10 + ui/src/styles.css | 41 +++ ui/tsconfig.json | 15 ++ ui/vite.config.ts | 16 ++ 48 files changed, 3885 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 bunfig.toml create mode 100644 config.example.json create mode 100644 exporter/Model.cs create mode 100644 exporter/PunktfunkSync.csproj create mode 100644 exporter/PunktfunkSyncPlugin.cs create mode 100644 exporter/README.md create mode 100644 exporter/extension.yaml create mode 100644 package.json create mode 100644 src/art.ts create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/const.ts create mode 100644 src/engine/index.ts create mode 100644 src/engine/reconcile.ts create mode 100644 src/export-schema.ts create mode 100644 src/host.ts create mode 100644 src/index.ts create mode 100644 src/log.ts create mode 100644 src/paths.ts create mode 100644 src/playnite.ts create mode 100644 src/server/index.ts create mode 100644 src/server/password.ts create mode 100644 src/server/router.ts create mode 100644 src/server/standalone.ts create mode 100644 src/state.ts create mode 100644 src/version.ts create mode 100644 src/wire.ts create mode 100644 test/art.test.ts create mode 100644 test/playnite.test.ts create mode 100644 test/reconcile.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 ui/bun.lock create mode 100644 ui/index.html create mode 100644 ui/package.json create mode 100644 ui/src/App.tsx create mode 100644 ui/src/api.ts create mode 100644 ui/src/main.tsx create mode 100644 ui/src/styles.css create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..1b51aa3 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,101 @@ +# CI for @punktfunk/plugin-playnite (Gitea Actions). +# build — the TS plugin + SPA: lint, typecheck, test, bundle (mirrors the rom-manager plugin CI). +# exporter — the C# Playnite extension → a `.pext`, built net462 with the .NET SDK on Linux (no +# Windows/MSBuild needed) and uploaded as a workflow artifact. +# publish — npm publish to the Gitea registry on a `v*` tag. +# Installs resolve @punktfunk/* from the Gitea registry via the bunfig scope map + REGISTRY_TOKEN; +# everything else (effect, react, tailwind…) comes from npm. +name: CI + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-24.04 + container: + image: oven/bun:1 + timeout-minutes: 15 + steps: + # oven/bun's slim base ships neither git nor a CA bundle — actions/checkout's HTTPS fetch needs both. + - name: Install git + CA certs + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs + - uses: actions/checkout@v4 + - name: Registry auth + env: + TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; } + printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc" + - name: Install + run: bun install --frozen-lockfile + - name: Lint & format + run: bunx biome check + - name: Typecheck (backend) + run: bunx tsc --noEmit + - name: Test (engine) + run: bun test + - name: Build backend + SPA + run: bun run build:all + - name: Typecheck (UI) + working-directory: ui + run: bunx tsc --noEmit + - name: Sanity — plugin default export is a valid PluginDef + run: | + bun -e 'import p from "./dist/index.js"; const d = p.default; if (d?.name !== "playnite" || typeof d?.main !== "function") { console.error("bad default export", d); process.exit(1); } console.log("ok:", d.name)' + + exporter: + runs-on: ubuntu-24.04 + container: + image: mcr.microsoft.com/dotnet/sdk:8.0 + timeout-minutes: 15 + steps: + # The SDK image is Debian but ships neither git (for checkout) nor zip (for packaging). + - name: Install git + zip + run: apt-get update && apt-get install -y --no-install-recommends git zip ca-certificates nodejs + - uses: actions/checkout@v4 + - name: Build exporter (net462) + run: dotnet build exporter/PunktfunkSync.csproj -c Release + - name: Package .pext + run: | + mkdir -p "$GITHUB_WORKSPACE/out" + cd exporter/bin/Release + zip -j -X "$GITHUB_WORKSPACE/out/punktfunk-sync.pext" extension.yaml PunktfunkSync.dll + unzip -l "$GITHUB_WORKSPACE/out/punktfunk-sync.pext" + # v3: Gitea's API rejects upload-artifact@v4. + - name: Upload .pext + uses: actions/upload-artifact@v3 + with: + name: punktfunk-sync-pext + path: out/punktfunk-sync.pext + + publish: + needs: [build, exporter] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-24.04 + container: + image: oven/bun:1 + timeout-minutes: 15 + steps: + - name: Install git + CA certs + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs + - uses: actions/checkout@v4 + - name: Registry auth + env: + TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; } + printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc" + - name: Install + run: bun install --frozen-lockfile + - name: Tag matches package version + run: | + TAG="${GITHUB_REF_NAME#v}" + PKG="$(node -p "require('./package.json').version")" + test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME != package version $PKG"; exit 1; } + - name: Publish (prepublishOnly builds backend + SPA) + run: bun publish diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38219fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +dist +ui/dist +ui/node_modules +*.log +.DS_Store + +# C# exporter build output +exporter/bin +exporter/obj +*.pext diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..ce5770d --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 unom + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..f42d1f9 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 unom + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..56b22f5 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# @punktfunk/plugin-playnite + +Sync your **[Playnite](https://playnite.link)** library into the Punktfunk host game library. Every +store and emulator Playnite manages — Steam, GOG, Epic, Xbox, itch, RetroArch, standalone emulators, +manually-added games — shows up in Punktfunk's grid on every client, and launching a title hands it +straight back to Playnite, which performs the real launch. + +It has two halves, both on the **same Windows box** as the Punktfunk host: + +``` +┌─────────────────────────────┐ ┌──────────────────────────────────────────┐ +│ Playnite │ │ Punktfunk host (this plugin, in the │ +│ └ Punktfunk Sync extension │ JSON │ scripting runner) │ +│ writes │ ─────▶ │ reads punktfunk-library.json, embeds art, │ +│ punktfunk-library.json │ │ PUT /library/provider/playnite │ +└─────────────────────────────┘ └──────────────────────────────────────────┘ +``` + +- **The Playnite exporter** (`exporter/`, C#) is a tiny [GenericPlugin](https://api.playnite.link/docs/tutorials/extensions/genericPlugins.html) + that writes your library to a JSON file whenever it changes. Playnite locks its library database + while running, so reading it from *inside* Playnite is the only robust way — this is that adapter, + and nothing more. +- **This plugin** (TypeScript, on [`@punktfunk/host`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk)) + watches that file and reconciles it into the host library as the `playnite` provider, with a + console-hosted web UI. Zero-config auth via the runner. + +## Install + +Two one-time steps. + +### 1 · This plugin (on the host) + +```powershell +cd "$(punktfunk config-dir)\plugins" # e.g. C:\ProgramData\punktfunk\plugins +# bunfig.toml points the @punktfunk scope at the registry (once) +bun add @punktfunk/plugin-playnite +Enable-ScheduledTask PunktfunkScripting # enable the scripting runner if it isn't already +``` + +Open the Punktfunk console — a **Playnite** entry appears in the nav. (Headless box without the +console? The engine is fully functional from a `config.json` alone — see [Headless](#headless--cli).) + +### 2 · The Punktfunk Sync extension (in Playnite) + +Download **`punktfunk-sync.pext`** from the [latest CI build](https://git.unom.io/unom/punktfunk-plugin-playnite/actions) +(the `exporter` job's artifact) and **double-click it** — Playnite installs it like any add-on. +Restart Playnite once. + +That's it. The console's **Playnite** page shows "Exporter connected" and your games sync within +seconds of any library change. + +> The console page can also **deploy the exporter for you** if you'd rather not handle the `.pext` — +> it drops the extension into `%APPDATA%\Playnite\Extensions\` and asks you to restart Playnite. +> (Best-effort: it needs read/write access to the interactive user's `%APPDATA%`; the `.pext` is the +> reliable fallback.) + +## Launching + +Each title syncs with a launch command of `start "" "playnite://playnite/start/"`. The host +runs that in the interactive Windows session, so **Playnite** owns the actual launch — the correct +store client or emulator, exactly as if you'd hit Play in Playnite. No per-store launch mapping to +maintain. + +## Box art + +Playnite stores cover art as **local files**, and the host only proxies Steam art — so this plugin +inlines each cover as a size-capped `data:` URL that clients render directly (no network fetch). + +| `art.mode` | Behaviour | +|--------------|-----------------------------------------------------------------| +| `dataurl` | Embed the cover (default). `maxBytes` caps a single image; oversized covers are skipped. | +| `off` | Sync titles with no art (lightest payload). | + +`art.includeBackground` additionally embeds the Playnite background as `hero` art (large — off by +default). For a very large library, inlined covers make a heavy library payload — the console warns +above ~3000 titles; narrow it with a source filter or set `art.mode` to `off`. + +## Filters + +| Key | Default | Meaning | +|-------------------------|---------|----------------------------------------------------------------| +| `filter.installedOnly` | `true` | Only sync games Playnite marks installed. | +| `filter.includeHidden` | `false` | Include games flagged Hidden in Playnite. | +| `filter.sources` | `[]` | If non-empty, keep only these sources (`"Steam"`, `"GOG"`…). Case-insensitive. | +| `filter.excludeSources` | `[]` | Drop these sources. | + +Edit them in the console's **Playnite** page, or in `config.json` directly. + +## Headless / CLI + +The plugin owns `/playnite/config.json` (see [`config.example.json`](./config.example.json)). +It's fully functional from that file alone. A debug CLI mirrors the engine: + +```sh +bunx punktfunk-plugin-playnite where # where the exporter output was found (+ probed paths) +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 every entry this plugin owns +``` + +For a host without the console, set `ui.standalone: true` and a password +(`bunx punktfunk-plugin-playnite set-password `); the UI then serves on `ui.port` (default 47994). + +## Requirements + +- A **Windows** Punktfunk host running the scripting runner (`punktfunk-scripting`). +- **Playnite** on the same box, with the Punktfunk Sync extension installed. +- The mgmt-API provider reconcile is loopback + token; the runner supplies both automatically. + +## Build from source + +```sh +bun install +bun run build:all # backend (dist/) + SPA (dist/ui/) +bun test +``` + +The exporter (needs the .NET SDK; builds net462 on any OS via reference assemblies): + +```sh +dotnet build exporter/PunktfunkSync.csproj -c Release +# package the .pext: +( cd exporter/bin/Release && zip -j ../../../punktfunk-sync.pext extension.yaml PunktfunkSync.dll ) +``` + +## License + +MIT OR Apache-2.0. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..612391b --- /dev/null +++ b/biome.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["**", "!dist", "!ui/dist", "!**/node_modules", "!exporter"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "suspicious": { + "noArrayIndexKey": "off" + }, + "style": { + "noNonNullAssertion": "off" + }, + "a11y": { + "noLabelWithoutControl": "off" + }, + "correctness": { + "useExhaustiveDependencies": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..f1e695d --- /dev/null +++ b/bun.lock @@ -0,0 +1,93 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@punktfunk/plugin-playnite", + "dependencies": { + "@punktfunk/host": "^0.1.1", + "effect": "^4.0.0-beta.98", + }, + "devDependencies": { + "@biomejs/biome": "^2.5.2", + "@types/bun": "^1.3.0", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.5.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.4", "@biomejs/cli-darwin-x64": "2.5.4", "@biomejs/cli-linux-arm64": "2.5.4", "@biomejs/cli-linux-arm64-musl": "2.5.4", "@biomejs/cli-linux-x64": "2.5.4", "@biomejs/cli-linux-x64-musl": "2.5.4", "@biomejs/cli-win32-arm64": "2.5.4", "@biomejs/cli-win32-x64": "2.5.4" }, "bin": { "biome": "bin/biome" } }, "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@punktfunk/host": ["@punktfunk/host@0.1.1", "https://git.unom.io/api/packages/unom/npm/%40punktfunk%2Fhost/-/0.1.1/host-0.1.1.tgz", { "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "dist/runner-cli.js" } }, "sha512-Lk9QmeFYFXih//hc+ZQSTg/KhmUOMga01mZoOI0v0fg7vyjLzOi0E3Fe6p1Csysm29mDpKis+9mwxKp+ICId2w=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "effect": ["effect@4.0.0-beta.99", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-hP1C61uzINfLl/4kKMwcqksxd34s4sQ3VSjsWjhGrkx9CRlXaqnfOK9dpTEKynQ6rA7wU9rb3c+48eDYw7uzxA=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..dfb06f3 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +# Resolve the `@punktfunk` scope from the Gitea npm registry (like `@punktfunk/host` itself), so +# `bun add @punktfunk/plugin-playnite` pulls the shared `@punktfunk/host` + `effect` deps out of the +# box. During local development the SDK is consumed via `bun link @punktfunk/host`. +[install.scopes] +"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/" diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..7226cbb --- /dev/null +++ b/config.example.json @@ -0,0 +1,24 @@ +{ + "filter": { + "installedOnly": true, + "includeHidden": false, + "sources": [], + "excludeSources": ["Xbox"] + }, + "art": { + "mode": "dataurl", + "maxBytes": 1500000, + "includeBackground": false + }, + "sync": { + "pollMinutes": 5, + "watch": true, + "debounceMs": 1500 + }, + "ui": { + "standalone": false, + "port": 47994, + "bind": "127.0.0.1" + }, + "playniteDir": "" +} diff --git a/exporter/Model.cs b/exporter/Model.cs new file mode 100644 index 0000000..c8e1d5b --- /dev/null +++ b/exporter/Model.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using Playnite.SDK.Data; + +namespace PunktfunkSync +{ + // The on-disk contract with the Punktfunk `playnite` plugin. Keep these property names in lockstep + // with `src/export-schema.ts` (ExportedGame / LibraryExport). Serialized via the Playnite SDK's + // `Serialization.ToJson`, so `[SerializationPropertyName]` pins the JSON keys regardless of the + // serializer's default casing. + + public class ExportedGame + { + // Playnite's stable game Guid ("d"-format, lowercase with dashes) — the reconcile external_id. + [SerializationPropertyName("id")] + public string Id { get; set; } + + [SerializationPropertyName("name")] + public string Name { get; set; } + + [SerializationPropertyName("installed")] + public bool Installed { get; set; } + + [SerializationPropertyName("hidden")] + public bool Hidden { get; set; } + + // Library source name ("Steam", "GOG", "Epic", emulator name…) or null. + [SerializationPropertyName("source")] + public string Source { get; set; } + + [SerializationPropertyName("platforms")] + public List Platforms { get; set; } + + // ABSOLUTE paths on the host box (already resolved from Playnite's database-relative form), or + // null. A remote (http) art URL is passed through verbatim. + [SerializationPropertyName("cover")] + public string Cover { get; set; } + + [SerializationPropertyName("background")] + public string Background { get; set; } + + [SerializationPropertyName("icon")] + public string Icon { get; set; } + } + + public class PlayniteInfo + { + [SerializationPropertyName("version")] + public string Version { get; set; } + + [SerializationPropertyName("mode")] + public string Mode { get; set; } + } + + public class LibraryExport + { + [SerializationPropertyName("schema")] + public int Schema { get; set; } + + [SerializationPropertyName("generatedAt")] + public string GeneratedAt { get; set; } + + [SerializationPropertyName("playnite")] + public PlayniteInfo Playnite { get; set; } + + [SerializationPropertyName("games")] + public List Games { get; set; } + } +} diff --git a/exporter/PunktfunkSync.csproj b/exporter/PunktfunkSync.csproj new file mode 100644 index 0000000..7fa7e6f --- /dev/null +++ b/exporter/PunktfunkSync.csproj @@ -0,0 +1,40 @@ + + + + + net462 + PunktfunkSync + PunktfunkSync + latest + disable + false + true + none + true + 0.1.0 + + + + + runtime + + + all + + + + + + PreserveNewest + + + + diff --git a/exporter/PunktfunkSyncPlugin.cs b/exporter/PunktfunkSyncPlugin.cs new file mode 100644 index 0000000..0b8d60e --- /dev/null +++ b/exporter/PunktfunkSyncPlugin.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Timers; +using Playnite.SDK; +using Playnite.SDK.Data; +using Playnite.SDK.Events; +using Playnite.SDK.Models; +using Playnite.SDK.Plugins; + +namespace PunktfunkSync +{ + /// + /// Writes the Playnite library to punktfunk-library.json in this plugin's data directory + /// (…/Playnite/ExtensionsData/<this-id>/) whenever it changes. The Punktfunk + /// playnite plugin globs for that file and reconciles it into the host game library — this + /// side stays a dumb, dependency-free exporter. + /// + public class PunktfunkSyncPlugin : GenericPlugin + { + private const int Schema = 1; + private static readonly ILogger logger = LogManager.GetLogger(); + + // Stable id → stable ExtensionsData folder. Never change it (it's the folder name the reader + // finds), and keep it distinct from every other extension. + public override Guid Id { get; } = Guid.Parse("d9f1b3a2-7c64-4e58-9a1b-6f2e3c4d5a6b"); + + private readonly string exportPath; + private readonly Timer debounce; + + public PunktfunkSyncPlugin(IPlayniteAPI api) : base(api) + { + Properties = new GenericPluginProperties { HasSettings = false }; + exportPath = Path.Combine(GetPluginUserDataPath(), "punktfunk-library.json"); + + // Coalesce bursts of per-item events (a metadata download touches many games) into one write. + debounce = new Timer(3000) { AutoReset = false }; + debounce.Elapsed += (s, e) => Export("changed"); + } + + public override void OnApplicationStarted(OnApplicationStartedEventArgs args) + { + // Initial export, then react to any subsequent library mutation. + Export("app-started"); + PlayniteApi.Database.Games.ItemCollectionChanged += (s, e) => ScheduleExport(); + PlayniteApi.Database.Games.ItemUpdated += (s, e) => ScheduleExport(); + } + + public override void OnLibraryUpdated(OnLibraryUpdatedEventArgs args) + { + Export("library-updated"); + } + + public override IEnumerable GetMainMenuItems(GetMainMenuItemsArgs args) + { + yield return new MainMenuItem + { + Description = "Export library to Punktfunk now", + MenuSection = "@Punktfunk Sync", + Action = a => Export("manual"), + }; + } + + private void ScheduleExport() + { + debounce.Stop(); + debounce.Start(); + } + + /// Resolve a Playnite media reference to an absolute path (remote URLs pass through). + private string ResolveArt(string reference) + { + if (string.IsNullOrEmpty(reference)) return null; + if (reference.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return reference; + try + { + var full = PlayniteApi.Database.GetFullFilePath(reference); + return File.Exists(full) ? full : null; + } + catch (Exception ex) + { + logger.Warn(ex, $"Punktfunk Sync: could not resolve art path '{reference}'"); + return null; + } + } + + private void Export(string reason) + { + try + { + var games = PlayniteApi.Database.Games.Select(g => new ExportedGame + { + Id = g.Id.ToString("D"), + Name = g.Name ?? string.Empty, + Installed = g.IsInstalled, + Hidden = g.Hidden, + Source = g.Source?.Name, + Platforms = g.Platforms?.Select(p => p.Name).ToList() ?? new List(), + Cover = ResolveArt(g.CoverImage), + Background = ResolveArt(g.BackgroundImage), + Icon = ResolveArt(g.Icon), + }).ToList(); + + var export = new LibraryExport + { + Schema = Schema, + GeneratedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), + Playnite = new PlayniteInfo + { + Version = PlayniteApi.ApplicationInfo.ApplicationVersion?.ToString(), + Mode = PlayniteApi.ApplicationInfo.Mode.ToString(), + }, + Games = games, + }; + + WriteAtomic(Serialization.ToJson(export, true)); + logger.Info($"Punktfunk Sync: exported {games.Count} game(s) ({reason}) to {exportPath}"); + } + catch (Exception ex) + { + logger.Error(ex, "Punktfunk Sync: export failed"); + } + } + + /// Write via a temp file + move so a reader never sees a half-written export. + private void WriteAtomic(string json) + { + Directory.CreateDirectory(Path.GetDirectoryName(exportPath)); + var tmp = exportPath + ".tmp"; + File.WriteAllText(tmp, json); + if (File.Exists(exportPath)) File.Delete(exportPath); + File.Move(tmp, exportPath); + } + } +} diff --git a/exporter/README.md b/exporter/README.md new file mode 100644 index 0000000..176eaa6 --- /dev/null +++ b/exporter/README.md @@ -0,0 +1,69 @@ +# Punktfunk Sync — Playnite exporter + +The Playnite side of [`@punktfunk/plugin-playnite`](../README.md): a minimal +[GenericPlugin](https://api.playnite.link/docs/tutorials/extensions/genericPlugins.html) that writes +your library to `punktfunk-library.json` whenever it changes. The Punktfunk plugin on the host reads +that file and reconciles it into the streaming library. + +It exists because Playnite keeps its library database (LiteDB) open and locked while running, so the +only robust way to read the library — with correct install state, art paths and metadata — is from +*inside* Playnite. This plugin does exactly that and nothing else: no network, no settings, no UI. + +## What it writes + +On startup, on every `OnLibraryUpdated`, and (debounced) on any per-game add/update/remove, it writes: + +``` +%APPDATA%\Playnite\ExtensionsData\\punktfunk-library.json +``` + +Shape (kept in lockstep with `../src/export-schema.ts`): + +```jsonc +{ + "schema": 1, + "generatedAt": "2026-07-18T21:00:00Z", + "playnite": { "version": "10.x", "mode": "Desktop" }, + "games": [ + { + "id": "e3b0c442-98fc-1c14-9afb-4c8996fb9242", // Playnite Guid = reconcile external_id + "name": "Hollow Knight", + "installed": true, + "hidden": false, + "source": "Steam", + "platforms": ["PC (Windows)"], + "cover": "C:\\Users\\me\\AppData\\Roaming\\Playnite\\library\\files\\ab\\cover.jpg", + "background": null, + "icon": null + } + ] +} +``` + +Art paths are **absolute** (resolved via `IPlayniteAPI.Database.GetFullFilePath`). Filtering +(installed-only, hidden, per-source) happens on the Punktfunk side, so the export is the full library. + +You can also trigger a write by hand: Playnite **main menu → Punktfunk Sync → Export library to +Punktfunk now**. + +## Install (for users) + +Double-click `punktfunk-sync.pext` — Playnite installs it like any add-on — and restart Playnite. +Grab the `.pext` from the repo's [CI artifacts](https://git.unom.io/unom/punktfunk-plugin-playnite/actions). + +## Build + +Targets .NET Framework 4.6.2 (Playnite's runtime). Thanks to +`Microsoft.NETFramework.ReferenceAssemblies` it builds with the plain .NET SDK on any OS — no Windows +or MSBuild needed: + +```sh +dotnet build PunktfunkSync.csproj -c Release +# output: bin/Release/PunktfunkSync.dll + extension.yaml (PlayniteSDK.dll is provided by Playnite) +``` + +Package the `.pext` (a zip of the two files): + +```sh +( cd bin/Release && zip -j ../../punktfunk-sync.pext extension.yaml PunktfunkSync.dll ) +``` diff --git a/exporter/extension.yaml b/exporter/extension.yaml new file mode 100644 index 0000000..924b5d5 --- /dev/null +++ b/exporter/extension.yaml @@ -0,0 +1,9 @@ +Id: Punktfunk_LibraryExporter +Name: Punktfunk Sync +Author: unom +Version: 0.1.0 +Module: PunktfunkSync.dll +Type: GenericPlugin +Links: + - Name: Repository + Url: https://git.unom.io/unom/punktfunk-plugin-playnite diff --git a/package.json b/package.json new file mode 100644 index 0000000..46c9995 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "@punktfunk/plugin-playnite", + "version": "0.1.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.", + "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": "./dist/index.d.ts", + "bin": { + "punktfunk-plugin-playnite": "./dist/cli.js" + }, + "files": [ + "dist" + ], + "publishConfig": { + "registry": "https://git.unom.io/api/packages/unom/npm/" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test", + "build": "tsc -p tsconfig.build.json", + "build:ui": "cd ui && bun install --silent && bun run build", + "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": { + "@biomejs/biome": "^2.5.2", + "@types/bun": "^1.3.0", + "typescript": "^5.9.3" + } +} diff --git a/src/art.ts b/src/art.ts new file mode 100644 index 0000000..b16d370 --- /dev/null +++ b/src/art.ts @@ -0,0 +1,60 @@ +// Cover art. Playnite stores art as local files on the host; the host's library only proxies Steam +// art, and provider entries are fetched by clients directly — so for a Playnite title we inline the +// cover as a `data:` URL the clients render without any network fetch. A `data:` URL bloats the +// library payload, so anything over `maxBytes` is dropped rather than embedded (design default: cover +// only; backgrounds are large and opt-in). + +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 = { + ".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; + 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; +}; diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..267df1b --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,126 @@ +#!/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 # 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 => { + 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 => { + 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 "); + 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 => { + 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 ", + ); + process.exitCode = cmd ? 2 : 0; + } +}; + +await main(); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..c094fb6 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,113 @@ +// 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, and the host only + * proxies Steam art — so for a provider entry we inline the cover as a size-capped `data:` URL that + * clients render directly. `off` syncs titles with no art. */ +export type ArtMode = "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$$`), 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; + filter?: Partial; + art?: Partial; + ui?: Partial; + /** 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: "dataurl", + 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({}); diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..c8d06b2 --- /dev/null +++ b/src/const.ts @@ -0,0 +1,8 @@ +// Plugin identity. The package name is `@punktfunk/plugin-playnite` (the runner's scoped glob), but +// the `definePlugin` name, the library provider id, and the console-nav id are all the short +// `playnite`. +export const PLUGIN_NAME = "playnite"; +export const PROVIDER_ID = "playnite"; +export const UI_TITLE = "Playnite"; +/** A lucide icon name for the console nav entry. */ +export const UI_ICON = "library-big"; diff --git a/src/engine/index.ts b/src/engine/index.ts new file mode 100644 index 0000000..3af096e --- /dev/null +++ b/src/engine/index.ts @@ -0,0 +1,318 @@ +// 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 { + 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; + 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; + private debounceTimer?: ReturnType; + 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 { + 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); + if (!loc.dir) return; + // Prefer watching ExtensionsData (where the file lives); fall back to the Playnite dir if the + // exporter hasn't created it yet, so we notice it appearing. + const extData = path.join(loc.dir, "ExtensionsData"); + const target = fs.existsSync(extData) ? extData : loc.dir; + try { + const watcher = fs.watch(target, { recursive: true }, () => + this.debouncedSync(), + ); + this.watchers.push(watcher); + } 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 { + 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 { + this.schedulePoll(); + this.setupWatchers(); + await this.sync("config-change"); + } + + /** Remove every entry this provider owns (CLI `uninstall`). */ + async uninstall(): Promise { + 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, + }; + } +} diff --git a/src/engine/reconcile.ts b/src/engine/reconcile.ts new file mode 100644 index 0000000..dae353f --- /dev/null +++ b/src/engine/reconcile.ts @@ -0,0 +1,148 @@ +// The pure core: turn a validated library export + the config into the declarative entry list the host +// reconcile expects, plus a report of what was included and why anything was skipped. No IO here (art +// is injected via `artFor`), so it's fully unit-testable. + +import type { Config } from "../config.js"; +import type { ExportedGame, LibraryExport } from "../export-schema.js"; +import type { Artwork, ProviderEntryInput } from "../wire.js"; + +export interface SkippedGame { + id: string; + title: string; + 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; +} + +/** 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; +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 + * real launch (any store or emulator, uniformly). The host runs `command` values via + * `cmd.exe /c ` in the interactive session, so `start "" ""` invokes the URI handler. */ +export const launchCommand = ( + id: string, + platform: NodeJS.Platform = process.platform, +): ProviderEntryInput["launch"] => { + const uri = `playnite://playnite/start/${id}`; + // Playnite is Windows-only; the Windows host is the supported target. `start ""` gives cmd a + // (empty) window title so a quoted URI isn't mistaken for one. + if (platform === "win32") + return { kind: "command", value: `start "" "${uri}"` }; + // Best-effort elsewhere (Wine/portable): a desktop opener resolves the registered handler. + return { kind: "command", value: `xdg-open "${uri}"` }; +}; + +const sourceKey = (g: ExportedGame): string => g.source ?? "(none)"; + +export interface ComputeInput { + exp: LibraryExport; + config: Config; + platform?: NodeJS.Platform; + /** Art builder (IO lives in the engine; tests pass a stub). */ + artFor: (game: ExportedGame) => Artwork | undefined; +} + +export interface ComputeResult { + entries: ProviderEntryInput[]; + report: SyncReport; +} + +/** Warn above this many entries — every cover is an inlined `data:` URL, so a huge library makes a + * heavy library payload (see README "Box art"). */ +const WARN_ENTRIES = 3000; + +export const computeEntries = ({ + exp, + config, + platform = process.platform, + artFor, +}: ComputeInput): ComputeResult => { + const { filter } = config; + const includeSources = new Set(filter.sources.map((s) => s.toLowerCase())); + const excludeSources = new Set( + filter.excludeSources.map((s) => s.toLowerCase()), + ); + + const entries: ProviderEntryInput[] = []; + const skipped: SkippedGame[] = []; + const warnings: string[] = []; + const perSource: Record = {}; + + for (const g of exp.games) { + const title = (g.name ?? "").trim(); + const skip = (reason: string) => + skipped.push({ id: g.id, title: title || g.id, reason }); + + if (!title) { + skip("no title"); + continue; + } + if (!isGuid(g.id)) { + skip("invalid game id"); + continue; + } + if (filter.installedOnly && !g.installed) { + skip("not installed"); + continue; + } + if (!filter.includeHidden && g.hidden) { + skip("hidden in Playnite"); + continue; + } + const src = (g.source ?? "").toLowerCase(); + if (includeSources.size > 0 && !includeSources.has(src)) { + skip(`source "${g.source ?? "(none)"}" not in include list`); + continue; + } + if (excludeSources.has(src)) { + skip(`source "${g.source ?? "(none)"}" excluded`); + continue; + } + + const entry: ProviderEntryInput = { + external_id: g.id, + title, + launch: launchCommand(g.id, platform), + }; + const art = artFor(g); + if (art) entry.art = art; + entries.push(entry); + const key = sourceKey(g); + perSource[key] = (perSource[key] ?? 0) + 1; + } + + // Stable order (by title) so the reconcile fingerprint is deterministic across reads. + entries.sort((a, b) => a.title.localeCompare(b.title)); + + if (entries.length > WARN_ENTRIES) { + warnings.push( + `${entries.length} entries — large library; inlined covers make a heavy library payload. Consider art.mode "off" or a tighter source filter.`, + ); + } + + return { + entries, + report: { + generatedAt: exp.generatedAt, + considered: exp.games.length, + included: entries.length, + skipped, + warnings, + perSource, + }, + }; +}; diff --git a/src/export-schema.ts b/src/export-schema.ts new file mode 100644 index 0000000..f786974 --- /dev/null +++ b/src/export-schema.ts @@ -0,0 +1,55 @@ +// 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//`). */ +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; + return typeof o.schema === "number" && Array.isArray(o.games); +}; diff --git a/src/host.ts b/src/host.ts new file mode 100644 index 0000000..886b912 --- /dev/null +++ b/src/host.ts @@ -0,0 +1,20 @@ +// 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 => + pf.request("PUT", `/library/provider/${PROVIDER_ID}`, entries); + +/** Clean uninstall: remove every entry this provider owns. */ +export const deleteProvider = (pf: Punktfunk): Promise => + pf.request("DELETE", `/library/provider/${PROVIDER_ID}`); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d0e4fc7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,63 @@ +// 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 = 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((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)(pf); + pf.close(); +} diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..b63c0a2 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,10 @@ +// 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(); diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..6b31094 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,53 @@ +// 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 `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: `/playnite`. */ +export const pluginDir = (): string => path.join(hostConfigDir(), "playnite"); + +/** `/playnite/config.json` — operator-editable, atomic-written. */ +export const configPath = (): string => path.join(pluginDir(), "config.json"); + +/** `/playnite/cache.json` — disposable derived state (last reconcile fingerprint). */ +export const cachePath = (): string => path.join(pluginDir(), "cache.json"); + +/** + * 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"); +}; diff --git a/src/playnite.ts b/src/playnite.ts new file mode 100644 index 0000000..ab8b4c4 --- /dev/null +++ b/src/playnite.ts @@ -0,0 +1,135 @@ +// 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. +// `/ExtensionsData//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 one wrinkle is *which user's* `%APPDATA%`: if the runner runs as the interactive user we +// find it directly; if it runs as SYSTEM/another user we fall back to scanning `C:\Users\*`. An +// explicit `playniteDir` in the config always wins. + +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"; + +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(); + + // First, a dir that actually holds an export file. + for (const dir of candidates) { + const hit = findExportUnder(dir); + if (hit) return { dir, 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, 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; +}; diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..71a8d04 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,30 @@ +// 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 => + servePluginUi(pf, { + id: PLUGIN_NAME, + title: UI_TITLE, + icon: UI_ICON, + version: opts?.version, + staticDir: resolveUiDir(import.meta.url), + fetch: makeRouter(engine), + }); diff --git a/src/server/password.ts b/src/server/password.ts new file mode 100644 index 0000000..8ccf823 --- /dev/null +++ b/src/server/password.ts @@ -0,0 +1,30 @@ +// scrypt password hashing for the standalone fallback UI. Format: `scrypt$$`. +// 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); +}; diff --git a/src/server/router.ts b/src/server/router.ts new file mode 100644 index 0000000..b5468af --- /dev/null +++ b/src/server/router.ts @@ -0,0 +1,91 @@ +// 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 | 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 => { + 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); + } + }; diff --git a/src/server/standalone.ts b/src/server/standalone.ts new file mode 100644 index 0000000..8c0be92 --- /dev/null +++ b/src/server/standalone.ts @@ -0,0 +1,150 @@ +// 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; +} + +const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]); +const COOKIE = "pf_playnite_session"; + +const loginPage = (error?: string): Response => + new Response( + ` +Playnite · Punktfunk + +

Playnite Sync

${error ? `
${error}
` : ""} +
`, + { + 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); + }, + }; +}; diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..f2c2c87 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,90 @@ +// Config/cache persistence: the plugin owns two files under `/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()), +}); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..f29e33f --- /dev/null +++ b/src/version.ts @@ -0,0 +1,16 @@ +// 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; + } +}; diff --git a/src/wire.ts b/src/wire.ts new file mode 100644 index 0000000..14a0404 --- /dev/null +++ b/src/wire.ts @@ -0,0 +1,34 @@ +// 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: }` + * — the host wraps it as `cmd.exe /c ` 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[]; +} diff --git a/test/art.test.ts b/test/art.test.ts new file mode 100644 index 0000000..69a86b5 --- /dev/null +++ b/test/art.test.ts @@ -0,0 +1,82 @@ +import { afterAll, 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 { buildArtwork, fileToDataUrl } from "../src/art.js"; +import { DEFAULT_ART } from "../src/config.js"; +import type { ExportedGame } from "../src/export-schema.js"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-art-")); +const write = (name: string, bytes: Buffer): string => { + const p = path.join(dir, name); + fs.writeFileSync(p, bytes); + return p; +}; +afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + +const game = (over: Partial): ExportedGame => ({ + id: "11111111-1111-1111-1111-111111111111", + name: "Game", + installed: true, + hidden: false, + source: "Steam", + platforms: [], + cover: null, + background: null, + icon: null, + ...over, +}); + +describe("fileToDataUrl", () => { + test("encodes a small png as a data URL", () => { + const p = write("cover.png", Buffer.from([1, 2, 3, 4])); + expect(fileToDataUrl(p, 1000)).toBe( + `data:image/png;base64,${Buffer.from([1, 2, 3, 4]).toString("base64")}`, + ); + }); + test("drops files over the size cap", () => { + const p = write("big.jpg", Buffer.alloc(2000, 7)); + expect(fileToDataUrl(p, 1000)).toBeNull(); + }); + test("rejects unknown extensions and null", () => { + const p = write("weird.xyz", Buffer.from([1])); + expect(fileToDataUrl(p, 1000)).toBeNull(); + expect(fileToDataUrl(null, 1000)).toBeNull(); + }); + test("mime follows the extension", () => { + const p = write("c.jpeg", Buffer.from([9])); + expect(fileToDataUrl(p, 1000)?.startsWith("data:image/jpeg;base64,")).toBe( + true, + ); + }); +}); + +describe("buildArtwork", () => { + test("off mode yields no art", () => { + const cover = write("c1.png", Buffer.from([1])); + expect( + buildArtwork(game({ cover }), { ...DEFAULT_ART, mode: "off" }), + ).toBeUndefined(); + }); + test("dataurl mode embeds the cover as portrait", () => { + const cover = write("c2.png", Buffer.from([1, 2])); + const art = buildArtwork(game({ cover }), DEFAULT_ART); + expect(art?.portrait?.startsWith("data:image/png;base64,")).toBe(true); + expect(art?.hero).toBeUndefined(); + }); + test("includeBackground adds hero", () => { + const cover = write("c3.png", Buffer.from([1])); + const background = write("b3.jpg", Buffer.from([2])); + const art = buildArtwork(game({ cover, background }), { + ...DEFAULT_ART, + includeBackground: true, + }); + expect(art?.portrait).toBeTruthy(); + expect(art?.hero?.startsWith("data:image/jpeg;base64,")).toBe(true); + }); + test("no readable art yields undefined", () => { + expect( + buildArtwork(game({ cover: "/does/not/exist.png" }), DEFAULT_ART), + ).toBeUndefined(); + }); +}); diff --git a/test/playnite.test.ts b/test/playnite.test.ts new file mode 100644 index 0000000..b8f7f4f --- /dev/null +++ b/test/playnite.test.ts @@ -0,0 +1,71 @@ +import { afterAll, 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 { resolveConfig } from "../src/config.js"; +import { EXPORT_FILE } from "../src/export-schema.js"; +import { ExportError, locateExport, readExport } from "../src/playnite.js"; + +const root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-loc-")); +afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + +/** Lay out a fake Playnite data dir with an export under ExtensionsData//. */ +const layout = (name: string, doc: unknown): string => { + const dir = path.join(root, name); + const ext = path.join(dir, "ExtensionsData", "abc-guid"); + fs.mkdirSync(ext, { recursive: true }); + if (doc !== undefined) + fs.writeFileSync(path.join(ext, EXPORT_FILE), JSON.stringify(doc)); + return dir; +}; + +const validDoc = { + schema: 1, + generatedAt: "2026-01-01T00:00:00Z", + playnite: { version: "10", mode: "Desktop" }, + games: [], +}; + +describe("locateExport", () => { + test("finds the export under an overridden Playnite dir", () => { + const dir = layout("found", validDoc); + const loc = locateExport(resolveConfig({ playniteDir: dir })); + expect(loc.dir).toBe(dir); + expect(loc.file).toBe( + path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE), + ); + expect(loc.mtime).toBeGreaterThan(0); + }); + + test("reports the dir but no file when the exporter hasn't written yet", () => { + const dir = path.join(root, "empty"); + fs.mkdirSync(dir, { recursive: true }); + const loc = locateExport(resolveConfig({ playniteDir: dir })); + expect(loc.dir).toBe(dir); + expect(loc.file).toBeNull(); + }); +}); + +describe("readExport", () => { + test("reads a valid export", () => { + const dir = layout("valid", validDoc); + const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE); + expect(readExport(file).schema).toBe(1); + }); + + test("rejects a future schema", () => { + const dir = layout("future", { ...validDoc, schema: 99 }); + const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE); + expect(() => readExport(file)).toThrow(ExportError); + }); + + test("rejects non-JSON and foreign shapes", () => { + const bad = path.join(root, "bad.json"); + fs.writeFileSync(bad, "{not json"); + expect(() => readExport(bad)).toThrow(ExportError); + + const foreign = path.join(root, "foreign.json"); + fs.writeFileSync(foreign, JSON.stringify({ hello: "world" })); + expect(() => readExport(foreign)).toThrow(ExportError); + }); +}); diff --git a/test/reconcile.test.ts b/test/reconcile.test.ts new file mode 100644 index 0000000..4e24778 --- /dev/null +++ b/test/reconcile.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import { resolveConfig } from "../src/config.js"; +import { + computeEntries, + isGuid, + launchCommand, +} from "../src/engine/reconcile.js"; +import type { ExportedGame, LibraryExport } from "../src/export-schema.js"; + +const G1 = "11111111-1111-1111-1111-111111111111"; +const G2 = "22222222-2222-2222-2222-222222222222"; + +const game = (over: Partial): ExportedGame => ({ + id: G1, + name: "Game", + installed: true, + hidden: false, + source: "Steam", + platforms: ["PC (Windows)"], + cover: null, + background: null, + icon: null, + ...over, +}); + +const exp = (games: ExportedGame[]): LibraryExport => ({ + schema: 1, + generatedAt: "2026-01-01T00:00:00Z", + playnite: { version: "10", mode: "Desktop" }, + games, +}); + +const noArt = () => undefined; + +describe("isGuid", () => { + test("accepts a real Playnite guid", () => { + expect(isGuid(G1)).toBe(true); + }); + test("rejects junk", () => { + expect(isGuid("not-a-guid")).toBe(false); + expect(isGuid('570" & calc')).toBe(false); + }); +}); + +describe("launchCommand", () => { + test("Windows uses start + playnite:// uri", () => { + expect(launchCommand(G1, "win32")).toEqual({ + kind: "command", + value: `start "" "playnite://playnite/start/${G1}"`, + }); + }); + test("non-Windows falls back to xdg-open", () => { + expect(launchCommand(G1, "linux")?.value).toContain("xdg-open"); + }); +}); + +describe("computeEntries filtering", () => { + test("installedOnly drops uninstalled games", () => { + const config = resolveConfig({}); + const { entries, report } = computeEntries({ + exp: exp([game({ id: G1 }), game({ id: G2, installed: false })]), + config, + platform: "win32", + artFor: noArt, + }); + expect(entries.map((e) => e.external_id)).toEqual([G1]); + expect(report.skipped[0]?.reason).toBe("not installed"); + }); + + test("includeHidden lets hidden games through", () => { + const hidden = exp([game({ id: G1, hidden: true })]); + expect( + computeEntries({ + exp: hidden, + config: resolveConfig({}), + platform: "win32", + artFor: noArt, + }).entries.length, + ).toBe(0); + expect( + computeEntries({ + exp: hidden, + config: resolveConfig({ filter: { includeHidden: true } }), + platform: "win32", + artFor: noArt, + }).entries.length, + ).toBe(1); + }); + + test("source include/exclude is case-insensitive", () => { + const games = exp([ + game({ id: G1, source: "Steam" }), + game({ id: G2, source: "GOG" }), + ]); + const only = computeEntries({ + exp: games, + config: resolveConfig({ filter: { sources: ["steam"] } }), + platform: "win32", + artFor: noArt, + }); + expect(only.entries.map((e) => e.external_id)).toEqual([G1]); + + const without = computeEntries({ + exp: games, + config: resolveConfig({ filter: { excludeSources: ["GOG"] } }), + platform: "win32", + artFor: noArt, + }); + expect(without.entries.map((e) => e.external_id)).toEqual([G1]); + }); + + test("invalid ids and empty titles are skipped", () => { + const { entries, report } = computeEntries({ + exp: exp([game({ id: "bogus" }), game({ id: G2, name: " " })]), + config: resolveConfig({}), + platform: "win32", + artFor: noArt, + }); + expect(entries.length).toBe(0); + expect(report.skipped.map((s) => s.reason).sort()).toEqual([ + "invalid game id", + "no title", + ]); + }); + + test("entries are title-sorted and per-source counted", () => { + const { entries, report } = computeEntries({ + exp: exp([ + game({ id: G1, name: "Zelda", source: "Nintendo" }), + game({ id: G2, name: "Antichamber", source: "Steam" }), + ]), + config: resolveConfig({}), + platform: "win32", + artFor: noArt, + }); + expect(entries.map((e) => e.title)).toEqual(["Antichamber", "Zelda"]); + expect(report.perSource).toEqual({ Nintendo: 1, Steam: 1 }); + }); + + test("artFor result is attached", () => { + const { entries } = computeEntries({ + exp: exp([game({ id: G1 })]), + config: resolveConfig({}), + platform: "win32", + artFor: () => ({ portrait: "data:image/png;base64,AAAA" }), + }); + expect(entries[0]?.art?.portrait).toBe("data:image/png;base64,AAAA"); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..92c6a62 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "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"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b75dc4d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "types": ["bun"] + }, + "include": ["src", "test"], + "exclude": ["ui", "dist", "node_modules"] +} diff --git a/ui/bun.lock b/ui/bun.lock new file mode 100644 index 0000000..7b033e5 --- /dev/null +++ b/ui/bun.lock @@ -0,0 +1,342 @@ +{ + "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=="], + } +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..7624ac6 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Playnite · Punktfunk + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..b816750 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,25 @@ +{ + "name": "punktfunk-plugin-playnite-ui", + "private": true, + "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.", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "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" + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..06463a8 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,424 @@ +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 ( +
+
+

+ {icon} + {title} +

+ {right} +
+ {children} +
+ ); +} + +function Toggle({ + label, + hint, + checked, + onChange, +}: { + label: string; + hint?: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} + +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(); + const [saving, setSaving] = useState(false); + const [syncing, setSyncing] = useState(false); + const [msg, setMsg] = useState(); + + useEffect(() => { + getConfig() + .then(setConfig) + .catch((e) => setMsg(String(e))); + }, []); + + const patch = (p: Partial) => + 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 ( +
+
+ + + +
+

Playnite

+

+ Sync your Playnite library into the Punktfunk game library. +

+
+
+ + {status ? ( +
+ + + {config && ( + + )} +
+ ) : ( +

Loading…

+ )} + + {msg && ( +

+ {msg} +

+ )} +
+ ); +} + +function Connection({ status }: { status: Status }) { + const found = Boolean(status.location.file); + return ( + }> + {found ? ( +
+ +
+

+ Exporter connected — updated{" "} + + {relTime(status.location.mtime ?? undefined)} + + . +

+

+ {status.location.file} +

+
+
+ ) : ( +
+ +
+

+ No exporter output found. Install the{" "} + Punktfunk Sync extension in + Playnite (then restart Playnite). +

+ {status.exportError && ( +

{status.exportError}

+ )} +

+ Looked under:{" "} + {status.location.dir ?? "no Playnite data dir found"} +

+
+
+ )} +
+ ); +} + +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 ( + } + right={ + + } + > +
+ + {status.lastSync?.count ?? 0} + + + title(s) synced · last {relTime(status.lastSync?.at)} + +
+ {sources.length > 0 && ( +
+ {sources.map(([src, n]) => ( + + {src} {n} + + ))} +
+ )} + {report && report.skipped.length > 0 && ( +

+ {report.skipped.length} skipped (e.g. {report.skipped[0]?.title}:{" "} + {report.skipped[0]?.reason}) +

+ )} + {report?.warnings.map((w) => ( +

+ {w} +

+ ))} +
+ ); +} + +function Settings({ + config, + patch, + onSave, + saving, +}: { + config: Config; + patch: (p: Partial) => void; + onSave: () => void; + saving: boolean; +}) { + const csv = (a: string[]) => a.join(", "); + const parseCsv = (s: string) => + s + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + + return ( + } + right={ + + } + > +
+ + patch({ filter: { ...config.filter, installedOnly: v } }) + } + /> + + patch({ filter: { ...config.filter, includeHidden: v } }) + } + /> + + patch({ art: { ...config.art, mode: v ? "dataurl" : "off" } }) + } + /> + + patch({ art: { ...config.art, includeBackground: v } }) + } + /> +
+ +
+ + +
+ + +
+
+
+ ); +} diff --git a/ui/src/api.ts b/ui/src/api.ts new file mode 100644 index 0000000..63eb4f9 --- /dev/null +++ b/ui/src/api.ts @@ -0,0 +1,108 @@ +// 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; +} +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 (path: string, opts?: RequestInit): Promise => { + 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("api/status"); +export const getConfig = () => api("api/config"); +export const putConfig = (config: Config) => + api("api/config", { method: "PUT", body: JSON.stringify(config) }); +export const getPreview = () => api("api/preview"); +export const runSync = () => api("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(); + 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; +}; diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..15f25a8 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.js"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/ui/src/styles.css b/ui/src/styles.css new file mode 100644 index 0000000..3d0d178 --- /dev/null +++ b/ui/src/styles.css @@ -0,0 +1,41 @@ +@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; +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..a8b335d --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..1c02818 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,16 @@ +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 }, +});