feat: Playnite library sync plugin + exporter
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:
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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/"
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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/<this-id>/</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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 )
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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({});
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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}`);
|
||||
@@ -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
@@ -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();
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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()),
|
||||
});
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user