feat: Playnite library sync plugin + exporter
CI / exporter (push) Failing after 24s
CI / build (push) Failing after 25s
CI / publish (push) Has been skipped

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/<id>, 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 23:35:35 +02:00
commit 6694be9848
48 changed files with 3885 additions and 0 deletions
+101
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
ui/dist
ui/node_modules
*.log
.DS_Store
# C# exporter build output
exporter/bin
exporter/obj
*.pext
+201
View File
@@ -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.
+21
View File
@@ -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.
+128
View File
@@ -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/<gameId>"`. 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 `<config_dir>/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 <pw>`); 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.
+51
View File
@@ -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"
}
}
}
+93
View File
@@ -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=="],
}
}
+5
View File
@@ -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/"
+24
View File
@@ -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": ""
}
+68
View File
@@ -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<string> 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<ExportedGame> Games { get; set; }
}
}
+40
View File
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The Playnite side of the plugin: a GenericPlugin that writes `punktfunk-library.json` into its own
plugin-data dir on every library change, for the Punktfunk `playnite` plugin to read and reconcile.
Targets .NET Framework 4.6.2 (Playnite's runtime). `Microsoft.NETFramework.ReferenceAssemblies`
lets `dotnet build` compile net462 on Linux/macOS (no Windows/MSBuild needed), so CI builds it on
the same ubuntu runner as the TS side. PlayniteSDK is a COMPILE-only reference — Playnite provides
it at runtime — so `ExcludeAssets=runtime` keeps it out of the packaged `.pext`.
-->
<PropertyGroup>
<TargetFramework>net462</TargetFramework>
<AssemblyName>PunktfunkSync</AssemblyName>
<RootNamespace>PunktfunkSync</RootNamespace>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<Deterministic>true</Deterministic>
<DebugType>none</DebugType>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<Version>0.1.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PlayniteSDK" Version="6.11.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Include="extension.yaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+136
View File
@@ -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
{
/// <summary>
/// Writes the Playnite library to <c>punktfunk-library.json</c> in this plugin's data directory
/// (<c>…/Playnite/ExtensionsData/&lt;this-id&gt;/</c>) whenever it changes. The Punktfunk
/// <c>playnite</c> plugin globs for that file and reconciles it into the host game library — this
/// side stays a dumb, dependency-free exporter.
/// </summary>
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<MainMenuItem> 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();
}
/// <summary>Resolve a Playnite media reference to an absolute path (remote URLs pass through).</summary>
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<string>(),
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");
}
}
/// <summary>Write via a temp file + move so a reader never sees a half-written export.</summary>
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);
}
}
}
+69
View File
@@ -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\<plugin-guid>\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 )
```
+9
View File
@@ -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
+55
View File
@@ -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"
}
}
+60
View File
@@ -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<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".ico": "image/x-icon",
".tga": "image/x-tga",
};
const mimeFor = (file: string): string | null =>
MIME[path.extname(file).toLowerCase()] ?? null;
/** Read a local image file into a `data:` URL, or null if missing, oversized, or an unknown type. */
export const fileToDataUrl = (
file: string | null,
maxBytes: number,
): string | null => {
if (!file) return null;
const mime = mimeFor(file);
if (!mime) return null;
try {
const st = fs.statSync(file);
if (!st.isFile() || st.size === 0 || st.size > maxBytes) return null;
const b64 = fs.readFileSync(file).toString("base64");
return `data:${mime};base64,${b64}`;
} catch {
return null;
}
};
/** Build the artwork block for a game per the art config. Returns undefined when nothing was embedded. */
export const buildArtwork = (
game: ExportedGame,
art: ArtOptions,
): Artwork | undefined => {
if (art.mode === "off") return undefined;
const portrait = fileToDataUrl(game.cover, art.maxBytes);
const hero = art.includeBackground
? fileToDataUrl(game.background, art.maxBytes)
: null;
if (!portrait && !hero) return undefined;
const out: Artwork = {};
if (portrait) out.portrait = portrait;
if (hero) out.hero = hero;
return out;
};
+126
View File
@@ -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 <pw> # standalone-UI password
import { connect } from "@punktfunk/host";
import { Engine } from "./engine/index.js";
import { computeEntries } from "./engine/reconcile.js";
import { locateExport, readExport } from "./playnite.js";
import { hashPassword } from "./server/password.js";
import { loadConfig, saveConfig } from "./state.js";
const cmdWhere = (): void => {
const config = loadConfig();
const loc = locateExport(config);
console.log(`Playnite data dir: ${loc.dir ?? "(not found)"}`);
console.log(`Export file: ${loc.file ?? "(not found)"}`);
if (loc.mtime)
console.log(`Last written: ${new Date(loc.mtime).toISOString()}`);
console.log("Candidates probed:");
for (const c of loc.candidates) console.log(` ${c}`);
if (!loc.file) {
console.log(
"\nNo export found. Install the Punktfunk Sync extension in Playnite (see exporter/README.md),",
);
console.log("or set `playniteDir` in the plugin config.json.");
}
};
const cmdPreview = (): void => {
const config = loadConfig();
const loc = locateExport(config);
if (!loc.file) {
console.log("No exporter output found — run `where` for details.");
process.exitCode = 1;
return;
}
const exp = readExport(loc.file);
const { entries, report } = computeEntries({
exp,
config,
artFor: () => undefined,
});
console.log(
`Preview: ${report.included}/${report.considered} game(s) would sync (${report.skipped.length} skipped).`,
);
const bySource = Object.entries(report.perSource).sort((a, b) => b[1] - a[1]);
for (const [s, n] of bySource) console.log(` ${s}: ${n}`);
console.log();
for (const e of entries.slice(0, 40))
console.log(` ${e.title}${e.launch?.value ?? "(no launch)"}`);
if (entries.length > 40) console.log(` … and ${entries.length - 40} more`);
if (report.skipped.length) {
console.log(`\n${report.skipped.length} skipped (first 20):`);
for (const s of report.skipped.slice(0, 20))
console.log(` ${s.title}: ${s.reason}`);
}
for (const w of report.warnings) console.log(`! ${w}`);
};
const cmdSync = async (): Promise<void> => {
const pf = await connect();
try {
const report = await new Engine({ pf }).sync("cli");
if (report) {
console.log(
`Synced: ${report.included} title(s), ${report.skipped.length} skipped.`,
);
} else {
console.log("Sync did not reconcile (no change, or see log).");
}
} finally {
pf.close();
}
};
const cmdUninstall = async (): Promise<void> => {
const pf = await connect();
try {
await new Engine({ pf }).uninstall();
console.log("Removed all playnite entries from the library.");
} finally {
pf.close();
}
};
const cmdSetPassword = (password: string | undefined): void => {
if (!password) {
console.error("usage: set-password <password>");
process.exitCode = 2;
return;
}
const config = loadConfig();
config.ui.passwordHash = hashPassword(password);
saveConfig(config);
console.log("Standalone-UI password set (config.ui.passwordHash).");
};
const main = async (): Promise<void> => {
const [cmd, arg] = process.argv.slice(2);
switch (cmd) {
case "where":
return cmdWhere();
case "preview":
return cmdPreview();
case "sync":
return cmdSync();
case "uninstall":
return cmdUninstall();
case "set-password":
return cmdSetPassword(arg);
default:
console.log(
"usage: punktfunk-plugin-playnite <where|preview|sync|uninstall|set-password>",
);
process.exitCode = cmd ? 2 : 0;
}
};
await main();
+113
View File
@@ -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$<saltB64>$<hashB64>`), required for a non-loopback standalone bind. */
passwordHash?: string;
}
/** The on-disk config as authored (all optional — `resolveConfig` fills the rest). */
export interface RawConfig {
/** Override the auto-detected Playnite data directory (`%APPDATA%\Playnite`). Point it at a
* portable install, or at the interactive user's profile when the runner runs as a different user. */
playniteDir?: string;
sync?: Partial<SyncOptions>;
filter?: Partial<FilterOptions>;
art?: Partial<ArtOptions>;
ui?: Partial<UiOptions>;
/** Dev/acceptance flag: reconcile one hardcoded entry so the round-trip is observable. */
devEntry?: boolean;
}
/** The fully-resolved config the engine consumes. */
export interface Config {
playniteDir?: string;
sync: SyncOptions;
filter: FilterOptions;
art: ArtOptions;
ui: UiOptions;
devEntry: boolean;
}
export const DEFAULT_SYNC: SyncOptions = {
pollMinutes: 5,
watch: true,
debounceMs: 1500,
};
export const DEFAULT_FILTER: FilterOptions = {
installedOnly: true,
includeHidden: false,
sources: [],
excludeSources: [],
};
export const DEFAULT_ART: ArtOptions = {
mode: "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({});
+8
View File
@@ -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";
+318
View File
@@ -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<typeof statePaths>;
syncing: boolean;
}
/** A stable fingerprint of (export bytes + the config knobs that affect output). Lets an unchanged
* re-read skip the expensive art-embed + PUT entirely. */
const fingerprint = (bytes: Buffer, config: Config): string =>
createHash("sha256")
.update(bytes)
.update(
JSON.stringify({
filter: config.filter,
art: config.art,
devEntry: config.devEntry,
platform: process.platform,
}),
)
.digest("hex");
/** A synthetic entry proving the reconcile round-trip end-to-end (dev flag / acceptance). */
const devEntry = (platform: NodeJS.Platform): ProviderEntryInput => ({
external_id: "00000000-0000-0000-0000-000000000000",
title: "Playnite (dev entry)",
launch: {
kind: "command",
value: platform === "win32" ? "cmd /c echo hi" : "echo hi",
},
});
export class Engine {
private readonly pf: Punktfunk;
private readonly platform: NodeJS.Platform;
private readonly log: Logger;
private cache: Cache;
private syncing = false;
private pending = false;
private lastReport?: SyncReport;
private lastLocation: Location = {
dir: null,
candidates: [],
file: null,
mtime: null,
};
private lastError?: string;
private pollTimer?: ReturnType<typeof setInterval>;
private debounceTimer?: ReturnType<typeof setTimeout>;
private watchers: fs.FSWatcher[] = [];
private readonly listeners = new Set<(status: EngineStatus) => void>();
constructor(deps: EngineDeps) {
this.pf = deps.pf;
this.platform = deps.platform ?? process.platform;
this.log = deps.log ?? makeLogger();
this.cache = loadCache();
}
// ---------------------------------------------------------------- lifecycle
async start(): Promise<void> {
await this.sync("startup");
this.schedulePoll();
this.setupWatchers();
}
stop(): void {
if (this.pollTimer) clearInterval(this.pollTimer);
if (this.debounceTimer) clearTimeout(this.debounceTimer);
for (const w of this.watchers) {
try {
w.close();
} catch {
// already closed
}
}
this.watchers = [];
}
private schedulePoll(): void {
const config = loadConfig();
const ms = Math.max(1, config.sync.pollMinutes) * 60_000;
if (this.pollTimer) clearInterval(this.pollTimer);
this.pollTimer = setInterval(() => void this.sync("poll"), ms);
(this.pollTimer as { unref?: () => void }).unref?.();
}
/** Watch the exporter's `ExtensionsData` tree so a library change reconciles within seconds. */
private setupWatchers(): void {
for (const w of this.watchers) {
try {
w.close();
} catch {
// ignore
}
}
this.watchers = [];
const config = loadConfig();
if (!config.sync.watch) return;
const loc = locateExport(config);
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<SyncReport | undefined> {
if (this.syncing) {
this.pending = true;
return undefined;
}
this.syncing = true;
this.notify();
try {
const config = loadConfig();
const location = locateExport(config);
this.lastLocation = location;
if (!location.file) {
this.lastError = `no exporter output under ${location.dir ?? "any Playnite data dir"} — is the Punktfunk Sync extension installed in Playnite?`;
this.log(`sync (${reason}): ${this.lastError}`);
return undefined;
}
const bytes = fs.readFileSync(location.file);
const fp = fingerprint(bytes, config);
if (this.cache.lastSync?.fingerprint === fp) {
this.lastError = undefined;
this.log(
`sync (${reason}): no changes (${this.cache.lastSync.count} title(s))`,
);
return this.lastReport;
}
const exp = readExport(location.file);
const { entries, report } = computeEntries({
exp,
config,
platform: this.platform,
artFor: (g: ExportedGame) => buildArtwork(g, config.art),
});
const finalEntries = config.devEntry
? [...entries, devEntry(this.platform)]
: entries;
await reconcileProvider(this.pf, finalEntries);
this.cache.lastSync = {
fingerprint: fp,
count: finalEntries.length,
at: Date.now(),
};
saveCache(this.cache);
this.lastReport = report;
this.lastError = undefined;
this.log(
`sync (${reason}): reconciled ${finalEntries.length} title(s) (${report.skipped.length} skipped)`,
);
this.logReport(report);
return report;
} catch (e) {
this.lastError = e instanceof ExportError ? e.message : String(e);
this.log(`sync (${reason}) failed: ${this.lastError}`);
return undefined;
} finally {
this.syncing = false;
this.notify();
if (this.pending) {
this.pending = false;
queueMicrotask(() => void this.sync("coalesced"));
}
}
}
private logReport(report: SyncReport): void {
const bySource = Object.entries(report.perSource)
.sort((a, b) => b[1] - a[1])
.map(([s, n]) => `${s}:${n}`)
.join(" ");
if (bySource) this.log(` sources — ${bySource}`);
for (const w of report.warnings.slice(0, 5)) this.log(` ${w}`);
}
/** Reconfigure timers/watchers after a config change (e.g. the UI saved new filters). */
async reconfigure(): Promise<void> {
this.schedulePoll();
this.setupWatchers();
await this.sync("config-change");
}
/** Remove every entry this provider owns (CLI `uninstall`). */
async uninstall(): Promise<void> {
await deleteProvider(this.pf);
this.cache.lastSync = undefined;
saveCache(this.cache);
this.log("uninstalled: provider entries removed");
}
// ---------------------------------------------------------------- status
subscribe(cb: (status: EngineStatus) => void): () => void {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
private notify(): void {
const status = this.status();
for (const cb of this.listeners) {
try {
cb(status);
} catch {
// a dead SSE listener must not break sync
}
}
}
status(): EngineStatus {
return {
platform: this.platform,
location: this.lastLocation,
exportError: this.lastError,
lastSync: this.cache.lastSync,
lastReport: this.lastReport,
paths: statePaths(),
syncing: this.syncing,
};
}
}
+148
View File
@@ -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<string, number>;
}
/** Playnite ids are .NET Guids. Validate the shape before interpolating into a launch command — the
* id comes from our own exporter, but the guard is cheap and keeps the command line unambiguous. */
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
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 <value>` in the interactive session, so `start "" "<uri>"` 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<string, number> = {};
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,
},
};
};
+55
View File
@@ -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/<pluginId>/`). */
export const EXPORT_FILE = "punktfunk-library.json";
/** Schema version this reader understands. The exporter stamps `schema`; a newer major is refused. */
export const SCHEMA = 1;
/** One game as the Playnite exporter sees it. Art fields are ABSOLUTE paths on the host box (already
* resolved from Playnite's database-relative form via `IPlayniteAPI.Database.GetFullFilePath`). */
export interface ExportedGame {
/** Playnite's stable game `Guid` (lowercase, no braces) — our reconcile `external_id`. */
id: string;
name: string;
/** Whether Playnite considers the game installed (drives the default `installedOnly` filter). */
installed: boolean;
/** Playnite's per-game "Hidden" flag. */
hidden: boolean;
/** The library source name ("Steam", "GOG", "Epic", "Xbox", emulator name, …) or null. */
source: string | null;
/** Platform display names ("PC (Windows)", "Nintendo Switch", …). */
platforms: string[];
/** Absolute path to the cover image, or null. */
cover: string | null;
/** Absolute path to the background image, or null. */
background: string | null;
/** Absolute path to the icon, or null. */
icon: string | null;
}
/** The whole export document. */
export interface LibraryExport {
schema: number;
/** ISO-8601 timestamp the exporter wrote this. */
generatedAt: string;
playnite: {
version: string | null;
/** "Desktop" | "Fullscreen" — informational. */
mode: string | null;
};
games: ExportedGame[];
}
/** A cheap structural check — enough to reject a truncated/foreign file before we trust it. */
export const isLibraryExport = (v: unknown): v is LibraryExport => {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return typeof o.schema === "number" && Array.isArray(o.games);
};
+20
View File
@@ -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<unknown> =>
pf.request("PUT", `/library/provider/${PROVIDER_ID}`, entries);
/** Clean uninstall: remove every entry this provider owns. */
export const deleteProvider = (pf: Punktfunk): Promise<unknown> =>
pf.request("DELETE", `/library/provider/${PROVIDER_ID}`);
+63
View File
@@ -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<void> = async () => {};
try {
if (config.ui.standalone) {
const handle = serveStandaloneUi(engine, config);
closeUi = handle.close;
} else {
const handle = await serveConsoleUi(pf, engine, {
version: readVersion(),
});
closeUi = handle.close;
log(`UI registered with the console (nav entry "${PLUGIN_NAME}")`);
}
} catch (e) {
log(`UI server failed to start (engine keeps running headless): ${e}`);
}
await new Promise<void>((resolve) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
log("shutting down");
await closeUi();
engine.stop();
},
});
export default plugin;
// Allow a direct `bun src/index.ts` run outside the managed runner (dev).
if (import.meta.main) {
const pf = await connect();
await (plugin.main as (pf: Punktfunk) => Promise<void>)(pf);
pf.close();
}
+10
View File
@@ -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();
+53
View File
@@ -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: `<config_dir>/playnite`. */
export const pluginDir = (): string => path.join(hostConfigDir(), "playnite");
/** `<config_dir>/playnite/config.json` — operator-editable, atomic-written. */
export const configPath = (): string => path.join(pluginDir(), "config.json");
/** `<config_dir>/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");
};
+135
View File
@@ -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.
// `<PlayniteData>/ExtensionsData/<pluginId-guid>/punktfunk-library.json`. We don't hardcode that guid
// — we glob `ExtensionsData/*/punktfunk-library.json` and take the newest, so the reader is decoupled
// from the exporter's id.
//
// The plugin runs on the same box as Playnite (it's the streaming host), so these are plain local
// reads. The 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;
};
+30
View File
@@ -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<PluginUiHandle> =>
servePluginUi(pf, {
id: PLUGIN_NAME,
title: UI_TITLE,
icon: UI_ICON,
version: opts?.version,
staticDir: resolveUiDir(import.meta.url),
fetch: makeRouter(engine),
});
+30
View File
@@ -0,0 +1,30 @@
// scrypt password hashing for the standalone fallback UI. Format: `scrypt$<saltB64url>$<hashB64url>`.
// Verification is constant-time. Only used by the standalone server; the console-hosted path has no
// password of its own.
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
const KEYLEN = 32;
/** Hash a password for storage in `config.ui.passwordHash`. */
export const hashPassword = (password: string): string => {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, KEYLEN);
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
};
/** Constant-time verify a password against a stored `scrypt$...` hash. */
export const verifyPassword = (password: string, stored: string): boolean => {
const parts = stored.split("$");
if (parts.length !== 3 || parts[0] !== "scrypt" || !parts[1] || !parts[2])
return false;
const salt = Buffer.from(parts[1], "base64url");
const expected = Buffer.from(parts[2], "base64url");
let actual: Buffer;
try {
actual = scryptSync(password, salt, expected.length);
} catch {
return false;
}
return actual.length === expected.length && timingSafeEqual(actual, expected);
};
+91
View File
@@ -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<typeof setInterval> | undefined;
const stream = new ReadableStream({
start(controller) {
const send = (status: EngineStatus) => {
try {
controller.enqueue(
enc.encode(`event: status\ndata: ${JSON.stringify(status)}\n\n`),
);
} catch {
// stream already closed
}
};
send(engine.status());
unsubscribe = engine.subscribe(send);
ping = setInterval(() => {
try {
controller.enqueue(enc.encode(": ping\n\n"));
} catch {
// ignore
}
}, 15_000);
(ping as { unref?: () => void }).unref?.();
},
cancel() {
unsubscribe();
if (ping) clearInterval(ping);
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
},
});
};
/** Build the plugin-local API router bound to an engine. */
export const makeRouter =
(engine: Engine) =>
async (req: Request): Promise<Response | undefined> => {
const { pathname } = new URL(req.url);
const method = req.method;
if (!pathname.startsWith("/api/")) return undefined; // static SPA handles it
try {
if (pathname === "/api/status" && method === "GET") {
return json(engine.status());
}
if (pathname === "/api/config" && method === "GET") {
return json(loadConfig());
}
if (pathname === "/api/config" && method === "PUT") {
const body = (await req.json()) as RawConfig;
// Persist the authored shape; re-resolve so the response echoes defaults.
saveRawConfig(body);
const resolved = resolveConfig(body);
await engine.reconfigure();
return json(resolved);
}
if (pathname === "/api/preview" && method === "GET") {
return json(engine.computeDry());
}
if (pathname === "/api/sync" && method === "POST") {
const report = await engine.sync("ui");
return report ? json(report) : json({ status: engine.status() }, 200);
}
if (pathname === "/api/events" && method === "GET") {
return sse(engine);
}
return json({ error: "not found" }, 404);
} catch (e) {
return json({ error: String(e) }, 500);
}
};
+150
View File
@@ -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<void>;
}
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
const COOKIE = "pf_playnite_session";
const loginPage = (error?: string): Response =>
new Response(
`<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>Playnite · Punktfunk</title>
<style>body{font:15px system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0b0b0f;color:#e5e7eb}
form{display:grid;gap:.75rem;width:260px}input{padding:.5rem;border-radius:.5rem;border:1px solid #333;background:#111;color:#eee}
button{padding:.5rem;border-radius:.5rem;border:0;background:#6c5bf3;color:#fff;cursor:pointer}.e{color:#f87171;font-size:13px}</style>
<form method=post action="./login"><h1>Playnite Sync</h1>${error ? `<div class=e>${error}</div>` : ""}
<input type=password name=password placeholder="Password" autofocus><button>Sign in</button></form>`,
{
status: error ? 401 : 200,
headers: { "content-type": "text/html; charset=utf-8" },
},
);
/** Resolve a request path to a file inside `root`, or null on traversal (mirrors servePluginUi). */
const staticFile = (root: string, pathname: string): string | null => {
let rel: string;
try {
rel = decodeURIComponent(pathname);
} catch {
return null;
}
if (rel.endsWith("/")) rel += "index.html";
if (!rel.startsWith("/")) rel = `/${rel}`;
const abs = nodePath.resolve(root, `.${rel}`);
const rootAbs = nodePath.resolve(root);
if (abs !== rootAbs && !abs.startsWith(rootAbs + nodePath.sep)) return null;
return abs;
};
const cookieHas = (req: Request, name: string, value: string): boolean => {
const raw = req.headers.get("cookie") ?? "";
return raw.split(/;\s*/).some((c) => c === `${name}=${value}`);
};
/** Serve the standalone UI. Throws (fail-closed) if a non-loopback bind lacks a password. */
export const serveStandaloneUi = (
engine: Engine,
config: Config,
): StandaloneHandle => {
const { port, bind, passwordHash } = config.ui;
const isLoopback = LOOPBACK.has(bind);
if (!isLoopback && !passwordHash) {
throw new Error(
`standalone UI refuses to bind ${bind} without a password (set ui.passwordHash via \`punktfunk-plugin-playnite set-password\`, or bind 127.0.0.1)`,
);
}
const authRequired = Boolean(passwordHash);
const sessionToken = crypto.randomUUID().replace(/-/g, "");
const root = resolveUiDir(import.meta.url);
const router = makeRouter(engine);
const authed = (req: Request) =>
!authRequired || cookieHas(req, COOKIE, sessionToken);
const server = Bun.serve({
hostname: bind,
port,
async fetch(req) {
const { pathname } = new URL(req.url);
if (pathname === "/__health") return Response.json({ ok: true });
if (authRequired && pathname === "/login" && req.method === "POST") {
const form = await req.formData().catch(() => null);
const password = form?.get("password");
if (
typeof password === "string" &&
passwordHash &&
verifyPassword(password, passwordHash)
) {
return new Response(null, {
status: 303,
headers: {
location: "./",
"set-cookie": `${COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
},
});
}
return loginPage("Incorrect password");
}
if (pathname === "/logout" && req.method === "POST") {
return new Response(null, {
status: 303,
headers: {
location: "./",
"set-cookie": `${COOKIE}=; Max-Age=0; Path=/`,
},
});
}
if (!authed(req)) {
if (pathname.startsWith("/api/"))
return Response.json({ error: "unauthorized" }, { status: 401 });
return loginPage();
}
// 1) plugin-local API
const api = await router(req);
if (api) return api;
// 2) static asset
const file = staticFile(root, pathname);
if (file) {
const bf = Bun.file(file);
if (await bf.exists()) return new Response(bf);
}
// 3) SPA fallback
if (
req.method === "GET" &&
(req.headers.get("accept") ?? "").includes("text/html")
) {
const index = Bun.file(nodePath.join(root, "index.html"));
if (await index.exists()) return new Response(index);
}
return new Response("not found", { status: 404 });
},
});
const url = `http://${isLoopback ? "127.0.0.1" : bind}:${server.port}`;
log(
`standalone UI on ${url}${authRequired ? " (password-protected)" : " (loopback, no password)"}`,
);
return {
url,
async close() {
server.stop(true);
},
};
};
+90
View File
@@ -0,0 +1,90 @@
// Config/cache persistence: the plugin owns two files under `<config_dir>/playnite/`, created 0700.
// `config.json` is operator-editable and atomically rewritten (temp + rename); the plugin refuses a
// group/world-writable `config.json` (the same sshd rule the runner and hooks enforce, since the UI
// can rewrite it and the launch commands it produces run as the host user). `cache.json` is disposable
// derived state.
import * as fs from "node:fs";
import * as path from "node:path";
import { type Config, type RawConfig, resolveConfig } from "./config.js";
import { cachePath, configPath, pluginDir } from "./paths.js";
export interface Cache {
/** Fingerprint + count of the last reconcile — lets a re-read skip an unchanged PUT. */
lastSync?: { fingerprint: string; count: number; at: number };
}
export const emptyCache = (): Cache => ({});
/** Create the plugin dir 0700 if absent (idempotent). */
const ensureDir = (): void => {
fs.mkdirSync(pluginDir(), { recursive: true, mode: 0o700 });
};
/** Refuse a group/world-writable config file (POSIX only; Windows config dir is DACL'd). */
const assertNotWorldWritable = (file: string): void => {
if (process.platform === "win32") return;
let mode: number;
try {
mode = fs.statSync(file).mode;
} catch {
return; // absent — nothing to guard
}
if ((mode & 0o022) !== 0) {
throw new Error(
`refusing ${file}: it is group/world-writable (chmod go-w it first) — this file controls commands run as the host user`,
);
}
};
/** Atomic write: temp file in the same dir, then rename. */
const atomicWrite = (file: string, data: string): void => {
ensureDir();
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, data, { mode: 0o600 });
fs.renameSync(tmp, file);
};
/** Load the authored config (defaults filled). A missing file yields the empty config. */
export const loadConfig = (): Config => {
const file = configPath();
let raw = "";
try {
raw = fs.readFileSync(file, "utf8");
} catch {
return resolveConfig({});
}
assertNotWorldWritable(file);
const parsed = JSON.parse(raw) as RawConfig;
return resolveConfig(parsed);
};
/** Persist a config (atomic). */
export const saveConfig = (config: Config): void => {
atomicWrite(configPath(), `${JSON.stringify(config, null, 2)}\n`);
};
/** Save the raw (operator-authored) config verbatim — the shape the UI edits. */
export const saveRawConfig = (raw: RawConfig): void => {
atomicWrite(configPath(), `${JSON.stringify(raw, null, 2)}\n`);
};
export const loadCache = (): Cache => {
try {
return JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache;
} catch {
return emptyCache();
}
};
export const saveCache = (cache: Cache): void => {
atomicWrite(cachePath(), JSON.stringify(cache));
};
/** Absolute paths, exported for the UI/CLI status view. */
export const statePaths = () => ({
dir: pluginDir(),
config: configPath(),
cache: cachePath(),
relConfig: path.basename(configPath()),
});
+16
View File
@@ -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;
}
};
+34
View File
@@ -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: <shell command> }`
* — the host wraps it as `cmd.exe /c <value>` in the interactive user session (Windows). */
export interface LaunchSpec {
kind: "command";
value: string;
}
/** A per-title prep/undo step: `do` runs before launch, `undo` at session end (reverse order). */
export interface PrepCmd {
do: string;
undo?: string | null;
}
/** One title in the declarative reconcile payload (`PUT /api/v1/library/provider/playnite`). */
export interface ProviderEntryInput {
/** The provider's stable id for this title — the reconcile diff key (Playnite's game Guid). */
external_id: string;
title: string;
art?: Artwork;
launch?: LaunchSpec | null;
prep?: PrepCmd[];
}
+82
View File
@@ -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>): 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();
});
});
+71
View File
@@ -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/<guid>/. */
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);
});
});
+149
View File
@@ -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>): 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");
});
});
+12
View File
@@ -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"]
}
+16
View File
@@ -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"]
}
+342
View File
@@ -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=="],
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Playnite · Punktfunk</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -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"
}
}
+424
View File
@@ -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 (
<section className="rounded-[var(--radius)] border border-border bg-card p-5">
<header className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold tracking-wide text-muted uppercase">
<span className="text-brand">{icon}</span>
{title}
</h2>
{right}
</header>
{children}
</section>
);
}
function Toggle({
label,
hint,
checked,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4 py-2">
<span>
<span className="block text-sm">{label}</span>
{hint && <span className="block text-xs text-muted">{hint}</span>}
</span>
<button
type="button"
onClick={() => onChange(!checked)}
className={`mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? "bg-brand" : "bg-elevated"}`}
>
<span
className={`block h-5 w-5 rounded-full bg-white transition-transform ${checked ? "translate-x-5" : "translate-x-0.5"}`}
/>
</button>
</label>
);
}
const inputCls =
"w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-brand";
export function App() {
const status = useStatusStream();
const [config, setConfig] = useState<Config>();
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [msg, setMsg] = useState<string>();
useEffect(() => {
getConfig()
.then(setConfig)
.catch((e) => setMsg(String(e)));
}, []);
const patch = (p: Partial<Config>) =>
setConfig((c) => (c ? { ...c, ...p } : c));
const save = async () => {
if (!config) return;
setSaving(true);
setMsg(undefined);
try {
setConfig(await putConfig(config));
setMsg("Saved — re-syncing with the new settings.");
} catch (e) {
setMsg(String(e));
} finally {
setSaving(false);
}
};
const sync = async () => {
setSyncing(true);
setMsg(undefined);
try {
await runSync();
setMsg("Sync requested.");
} catch (e) {
setMsg(String(e));
} finally {
setSyncing(false);
}
};
return (
<div className="mx-auto max-w-3xl px-5 py-8">
<header className="mb-6 flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-brand text-brand-fg">
<Gamepad2 size={22} />
</span>
<div>
<h1 className="text-xl font-semibold">Playnite</h1>
<p className="text-xs text-muted">
Sync your Playnite library into the Punktfunk game library.
</p>
</div>
</header>
{status ? (
<div className="grid gap-5">
<Connection status={status} />
<LibrarySummary status={status} onSync={sync} syncing={syncing} />
{config && (
<Settings
config={config}
patch={patch}
onSave={save}
saving={saving}
/>
)}
</div>
) : (
<p className="text-sm text-muted">Loading</p>
)}
{msg && (
<p className="mt-5 rounded-lg border border-border bg-surface px-4 py-2 text-sm text-muted">
{msg}
</p>
)}
</div>
);
}
function Connection({ status }: { status: Status }) {
const found = Boolean(status.location.file);
return (
<Card title="Playnite connection" icon={<FolderSearch size={16} />}>
{found ? (
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 shrink-0 text-ok" size={18} />
<div className="min-w-0 text-sm">
<p>
Exporter connected updated{" "}
<span className="text-fg">
{relTime(status.location.mtime ?? undefined)}
</span>
.
</p>
<p className="mt-1 truncate font-mono text-xs text-muted">
{status.location.file}
</p>
</div>
</div>
) : (
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 shrink-0 text-warn" size={18} />
<div className="text-sm">
<p>
No exporter output found. Install the{" "}
<span className="text-fg">Punktfunk Sync</span> extension in
Playnite (then restart Playnite).
</p>
{status.exportError && (
<p className="mt-1 text-xs text-muted">{status.exportError}</p>
)}
<p className="mt-2 text-xs text-muted">
Looked under:{" "}
{status.location.dir ?? "no Playnite data dir found"}
</p>
</div>
</div>
)}
</Card>
);
}
function LibrarySummary({
status,
onSync,
syncing,
}: {
status: Status;
onSync: () => void;
syncing: boolean;
}) {
const report = status.lastReport;
const busy = syncing || status.syncing;
const sources = report
? Object.entries(report.perSource).sort((a, b) => b[1] - a[1])
: [];
return (
<Card
title="Library"
icon={<Library size={16} />}
right={
<button
type="button"
onClick={onSync}
disabled={busy}
className="flex items-center gap-2 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>
{busy ? (
<Loader2 className="animate-spin" size={15} />
) : (
<RefreshCw size={15} />
)}
Sync now
</button>
}
>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{status.lastSync?.count ?? 0}
</span>
<span className="text-sm text-muted">
title(s) synced · last {relTime(status.lastSync?.at)}
</span>
</div>
{sources.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{sources.map(([src, n]) => (
<span
key={src}
className="rounded-full border border-border bg-surface px-2.5 py-1 text-xs"
>
{src} <span className="text-muted">{n}</span>
</span>
))}
</div>
)}
{report && report.skipped.length > 0 && (
<p className="mt-3 text-xs text-muted">
{report.skipped.length} skipped (e.g. {report.skipped[0]?.title}:{" "}
{report.skipped[0]?.reason})
</p>
)}
{report?.warnings.map((w) => (
<p key={w} className="mt-2 text-xs text-warn">
{w}
</p>
))}
</Card>
);
}
function Settings({
config,
patch,
onSave,
saving,
}: {
config: Config;
patch: (p: Partial<Config>) => void;
onSave: () => void;
saving: boolean;
}) {
const csv = (a: string[]) => a.join(", ");
const parseCsv = (s: string) =>
s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
return (
<Card
title="Settings"
icon={<Gamepad2 size={16} />}
right={
<button
type="button"
onClick={onSave}
disabled={saving}
className="flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-1.5 text-sm hover:bg-elevated disabled:opacity-50"
>
{saving ? (
<Loader2 className="animate-spin" size={15} />
) : (
<Save size={15} />
)}
Save
</button>
}
>
<div className="divide-y divide-border">
<Toggle
label="Installed games only"
hint="Uncheck to also sync games Playnite hasn't installed yet."
checked={config.filter.installedOnly}
onChange={(v) =>
patch({ filter: { ...config.filter, installedOnly: v } })
}
/>
<Toggle
label="Include hidden games"
checked={config.filter.includeHidden}
onChange={(v) =>
patch({ filter: { ...config.filter, includeHidden: v } })
}
/>
<Toggle
label="Embed cover art"
hint="Inlines each cover as a data URL. Turn off for a lighter library payload."
checked={config.art.mode === "dataurl"}
onChange={(v) =>
patch({ art: { ...config.art, mode: v ? "dataurl" : "off" } })
}
/>
<Toggle
label="Embed background art too"
hint="Backgrounds are large — off by default."
checked={config.art.includeBackground}
onChange={(v) =>
patch({ art: { ...config.art, includeBackground: v } })
}
/>
</div>
<div className="mt-4 grid gap-3">
<label className="block">
<span className="mb-1 block text-xs text-muted">
Only these sources (comma-separated, blank = all)
</span>
<input
className={inputCls}
placeholder="Steam, GOG, Epic"
defaultValue={csv(config.filter.sources)}
onBlur={(e) =>
patch({
filter: { ...config.filter, sources: parseCsv(e.target.value) },
})
}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">Exclude sources</span>
<input
className={inputCls}
placeholder="Xbox"
defaultValue={csv(config.filter.excludeSources)}
onBlur={(e) =>
patch({
filter: {
...config.filter,
excludeSources: parseCsv(e.target.value),
},
})
}
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="mb-1 block text-xs text-muted">
Poll every (min)
</span>
<input
type="number"
min={1}
className={inputCls}
defaultValue={config.sync.pollMinutes}
onBlur={(e) =>
patch({
sync: {
...config.sync,
pollMinutes: Math.max(1, Number(e.target.value) || 5),
},
})
}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">
Playnite dir override
</span>
<input
className={inputCls}
placeholder="(auto-detect)"
defaultValue={config.playniteDir ?? ""}
onBlur={(e) =>
patch({ playniteDir: e.target.value.trim() || undefined })
}
/>
</label>
</div>
</div>
</Card>
);
}
+108
View File
@@ -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<string, number>;
}
export interface LastSync {
fingerprint: string;
count: number;
at: number;
}
export interface Status {
platform: string;
location: Location;
exportError?: string;
lastSync?: LastSync;
lastReport?: Report;
paths: { dir: string; config: string; cache: string; relConfig: string };
syncing: boolean;
}
export interface Config {
playniteDir?: string;
sync: { pollMinutes: number; watch: boolean; debounceMs: number };
filter: {
installedOnly: boolean;
includeHidden: boolean;
sources: string[];
excludeSources: string[];
};
art: {
mode: "dataurl" | "off";
maxBytes: number;
includeBackground: boolean;
};
ui: {
standalone: boolean;
port: number;
bind: string;
passwordHash?: string;
};
devEntry: boolean;
}
export interface Preview {
location: Location;
report?: Report;
error?: string;
}
const api = async <T>(path: string, opts?: RequestInit): Promise<T> => {
const res = await fetch(path, {
headers: { "content-type": "application/json" },
...opts,
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return (await res.json()) as T;
};
export const getStatus = () => api<Status>("api/status");
export const getConfig = () => api<Config>("api/config");
export const putConfig = (config: Config) =>
api<Config>("api/config", { method: "PUT", body: JSON.stringify(config) });
export const getPreview = () => api<Preview>("api/preview");
export const runSync = () => api<unknown>("api/sync", { method: "POST" });
/** Live engine status via SSE (falls back to a one-shot fetch if EventSource fails). */
export const useStatusStream = (): Status | undefined => {
const [status, setStatus] = useState<Status>();
useEffect(() => {
getStatus()
.then(setStatus)
.catch(() => {});
try {
const es = new EventSource("api/events");
es.addEventListener("status", (e) => {
try {
setStatus(JSON.parse((e as MessageEvent).data));
} catch {
// ignore malformed frame
}
});
return () => es.close();
} catch {
return;
}
}, []);
return status;
};
+10
View File
@@ -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(
<StrictMode>
<App />
</StrictMode>,
);
+41
View File
@@ -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;
}
+15
View File
@@ -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"]
}
+16
View File
@@ -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 },
});