Tell people where the .pext comes from, and cut real releases so there is somewhere to send them (0.4.3) #5

Merged
enricobuehler merged 1 commits from feat/pext-discovery-and-releases into main 2026-08-14 16:07:48 +00:00
10 changed files with 256 additions and 14 deletions
+57 -3
View File
@@ -2,10 +2,14 @@
# build — the three bun workspaces (contract + plugin + ui): one root install, lint,
# per-package typecheck, domain tests, bundle + SPA. Mirrors rom-manager's.
# 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.
# Linux (no Windows/MSBuild needed). Uploaded as a workflow artifact on every
# build, and on a `v*` tag ALSO attached to that tag's Gitea release — which is
# the only route a user should ever be pointed at (see the step's comment).
# publish — npm publish to the Gitea registry on a `v*` tag, from plugin/.
# @punktfunk/* and @unom/* resolve from the Gitea registry via the root bunfig scope maps
# + REGISTRY_TOKEN; everything else (effect, react, tailwind…) comes from npm.
# ⚠ REGISTRY_TOKEN must carry `write:repository` as well as `write:package` — the release
# attach 403s on a package-only token while every other job stays green.
name: CI
on:
@@ -72,8 +76,9 @@ jobs:
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
# curl + python3 are for the release attach below.
- name: Install git + zip + release tools
run: apt-get update && apt-get install -y --no-install-recommends git zip curl python3 ca-certificates nodejs
- uses: actions/checkout@v4
- name: Build exporter (net462)
run: dotnet build exporter/PunktfunkSync.csproj -c Release
@@ -84,12 +89,61 @@ jobs:
zip -j -X "$GITHUB_WORKSPACE/out/punktfunk-sync.pext" extension.yaml PunktfunkSync.dll
ls -l "$GITHUB_WORKSPACE/out/punktfunk-sync.pext"
# v3: Gitea's API rejects upload-artifact@v4.
# Kept for BRANCH builds — a way to test an unreleased exporter. It is deliberately no longer
# the way an end user gets the file (see the release step below).
- name: Upload .pext
uses: actions/upload-artifact@v3
with:
name: punktfunk-sync-pext
path: out/punktfunk-sync.pext
# The `.pext` is HALF THE PLUGIN — without it this plugin sits at "Waiting for Playnite"
# forever — and until now the only way to obtain one was: sign in to Gitea, find the Actions
# tab, open the newest run, download a zip artifact and unzip it. That is not something a user
# discovers, and nothing in the console ever said to try. A tag now produces an actual release
# with the file attached, which is what the plugin's first-run screen links to
# (`ui/src/lib/links.ts` → /releases/latest).
- name: Attach the .pext to the Gitea release
if: startsWith(github.ref, 'refs/tags/v')
env:
# ⚠ Needs **write:repository** scope. The npm publish job below only needs write:package,
# so a token carrying just that leaves every other job green and 403s only here.
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eo pipefail
. scripts/ci/gitea-release.sh
TAG="$GITHUB_REF_NAME"
# mktemp, not a path under out/ — that directory only exists because an earlier step made
# it, and a release body has no business depending on the artifact staging dir.
BODY="$(mktemp)"
# The release body is the install instructions. This page is now the destination for
# every "where do I get the extension" link, so it has to answer that on its own.
cat > "$BODY" <<'BODY_MD'
## Punktfunk Sync — the Playnite half of the plugin
`punktfunk-sync.pext` below is the Playnite extension. It writes your Playnite library to
a JSON file whenever it changes; the `@punktfunk/plugin-playnite` plugin on your Punktfunk
host reads that file and syncs the games into your library.
**Install:** download `punktfunk-sync.pext`, double-click it (Playnite installs it like any
add-on), and restart Playnite. Your games appear in the Punktfunk console within seconds —
no configuration needed.
Verify the download with the `.sha256` sidecar:
```
sha256sum -c punktfunk-sync.pext.sha256
```
The host-side half installs from the plugin registry, not from here:
```
bun add @punktfunk/plugin-playnite
```
BODY_MD
RID=$(ensure_release "$TAG" "$TAG" "$BODY")
upsert_asset "$RID" "$GITHUB_WORKSPACE/out/punktfunk-sync.pext"
publish:
needs: [build, exporter]
if: startsWith(github.ref, 'refs/tags/v')
+6 -3
View File
@@ -43,9 +43,12 @@ Playnite until you do step 2.
### 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.
Download **`punktfunk-sync.pext`** from the [latest release](https://git.unom.io/unom/punktfunk-plugin-playnite/releases/latest)
and **double-click it** — Playnite installs it like any add-on. Restart Playnite once.
The console tells you this too: until the exporter has written anything, the plugin's **Playnite**
page shows the same download link, so you never have to come here for it. Every release attaches a
`punktfunk-sync.pext.sha256` beside the file if you want to verify it.
That's it. The console's **Playnite** page flips to "Exporter connected" and your games sync within
seconds of any library change. No configuration needed.
+1 -1
View File
@@ -24,7 +24,7 @@
},
"plugin": {
"name": "@punktfunk/plugin-playnite",
"version": "0.4.2",
"version": "0.4.3",
"bin": {
"punktfunk-plugin-playnite": "./dist/cli.js",
},
+6 -2
View File
@@ -48,8 +48,12 @@ 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).
Grab the `.pext` from the [latest release](https://git.unom.io/unom/punktfunk-plugin-playnite/releases/latest),
double-click it — Playnite installs it like any add-on — and restart Playnite.
Every `v*` tag attaches a freshly built `punktfunk-sync.pext` (plus a `.sha256` sidecar) to its
release. The CI **artifact** on a branch build still exists, but it is for testing an unreleased
exporter — it is not where a user should be sent.
## Build
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-playnite",
"version": "0.4.2",
"version": "0.4.3",
"private": false,
"type": "module",
"description": "Punktfunk plugin: syncs your Playnite library into the host game library as a provider — every store and emulator Playnite manages, launched back through Playnite, with a console-hosted web UI. Pairs with the bundled Playnite exporter extension. Built on @punktfunk/plugin-kit.",
+118
View File
@@ -0,0 +1,118 @@
# shellcheck shell=bash
# Gitea Release helpers for this repo's CI.
#
# A trimmed sibling of `scripts/ci/gitea-release.sh` in unom/punktfunk — same API shape
# (`ensure_release` + `upsert_asset`), same hard-won behaviours, minus the parts that only the
# multi-workflow monorepo needs (concurrent attach legs, canary re-points, docs/releases bodies).
# Kept as a copy rather than shared, because this repo is standalone and a CI helper that lives in
# another repository is a dependency nobody can see.
#
# The two behaviours worth carrying over verbatim:
#
# - **A plain asset POST 409s when an asset of that name already exists**, so re-running a tag's
# workflow would fail after publishing perfectly. `upsert_asset` deletes first.
# - **A `.sha256` sidecar per asset**, so a `.pext` pulled off a release page can be verified
# (`sha256sum -c punktfunk-sync.pext.sha256`) rather than trusted.
#
# Env (Gitea Actions sets the first two automatically):
# GITHUB_SERVER_URL e.g. https://git.unom.io
# GITHUB_REPOSITORY e.g. unom/punktfunk-plugin-playnite
# GITEA_TOKEN a PAT with **write:repository** scope. ⚠ The npm publish in this workflow
# only needs `write:package`; a token carrying just that will 403 on the
# release-asset POST while every other step stays green.
#
# Requires: curl + python3.
_gitea_api() { printf '%s/api/v1/repos/%s' "${GITHUB_SERVER_URL:?}" "${GITHUB_REPOSITORY:?}"; }
# Read the TOP-LEVEL "id" only — a string-grep would trip over nested author.id / assets[].id.
_json_id() { python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null; }
_json_asset_id() {
python3 -c 'import json,sys
want=sys.argv[1]
for a in json.load(sys.stdin):
if a.get("name")==want:
print(a.get("id",""));break' "$1" 2>/dev/null
}
_urlencode() { python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=""))' "$1"; }
# ensure_release TAG NAME BODY_FILE
# Idempotently create (or fetch) the release for TAG; prints its numeric id on stdout.
# A tag carrying a `-` suffix (v0.5.0-rc1) is marked prerelease, which keeps it out of
# Gitea's `/releases/latest` — the URL the plugin's console UI links people to.
ensure_release() {
local tag="${1:?tag}" name="${2:?name}" body_file="${3:-}"
local api payload id prerelease=false
case "$tag" in *-*) prerelease=true ;; esac
api="$(_gitea_api)"
# Built with python3 so a multi-line, quote-bearing body is JSON-escaped correctly.
payload=$(TAG="$tag" NAME="$name" PRERELEASE="$prerelease" BODY_FILE="$body_file" python3 - <<'PY'
import json, os
d = {"tag_name": os.environ["TAG"], "name": os.environ["NAME"],
"prerelease": os.environ["PRERELEASE"] == "true"}
bf = os.environ.get("BODY_FILE") or ""
if bf and os.path.exists(bf):
with open(bf, encoding="utf-8") as f:
d["body"] = f.read()
print(json.dumps(d))
PY
)
# Create, else fetch by tag. Either path MUST yield an id, so a 401/scope problem cannot
# masquerade as a successful no-op.
id=$(curl -fsS -X POST "$api/releases" \
-H "Authorization: token ${GITEA_TOKEN:?}" -H 'Content-Type: application/json' \
-d "$payload" 2>/dev/null | _json_id || true)
if [ -z "$id" ]; then
id=$(curl -fsS "$api/releases/tags/$tag" \
-H "Authorization: token ${GITEA_TOKEN:?}" 2>/dev/null | _json_id || true)
fi
if [ -z "$id" ]; then
echo "gitea-release: could not create or find a release for tag '$tag'" >&2
return 1
fi
printf '%s' "$id"
}
# _put_asset RELEASE_ID FILE NAME — delete any same-named asset, then POST. See the 409 note above.
_put_asset() {
local rid="$1" file="$2" name="$3"
local api existing
api="$(_gitea_api)"
existing=$(curl -fsS "$api/releases/$rid/assets" \
-H "Authorization: token ${GITEA_TOKEN:?}" 2>/dev/null \
| _json_asset_id "$name" || true)
if [ -n "$existing" ]; then
curl -fsS -o /dev/null -X DELETE "$api/releases/$rid/assets/$existing" \
-H "Authorization: token ${GITEA_TOKEN:?}" || true
fi
curl -fsS -o /dev/null -X POST "$api/releases/$rid/assets?name=$(_urlencode "$name")" \
-H "Authorization: token ${GITEA_TOKEN:?}" \
-F "attachment=@$file"
echo "gitea-release: uploaded '$name' -> release $rid"
}
# python3 rather than sha256sum — already a hard dependency here, and one spelling on every runner.
_sha256() {
python3 - "$1" <<'PY'
import hashlib, sys
h = hashlib.sha256()
with open(sys.argv[1], "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
print(h.hexdigest())
PY
}
# upsert_asset RELEASE_ID FILE [NAME] — attach FILE plus its `<NAME>.sha256` sidecar.
upsert_asset() {
local rid="${1:?release id}" file="${2:?file}" name="${3:-}"
local sums
[ -n "$name" ] || name="$(basename "$file")"
[ -f "$file" ] || { echo "gitea-release: asset file not found: $file" >&2; return 1; }
_put_asset "$rid" "$file" "$name"
case "$name" in *.sha256) return 0 ;; esac
# `sha256sum -c` wants "<digest> <filename>" with the filename as DOWNLOADED, i.e. the asset name.
sums="$(mktemp)"
printf '%s %s\n' "$(_sha256 "$file")" "$name" > "$sums"
if _put_asset "$rid" "$sums" "$name.sha256"; then rm -f "$sums"; else rm -f "$sums"; return 1; fi
}
+20
View File
@@ -0,0 +1,20 @@
// Where a user actually gets the other half of this plugin.
//
// Until now nothing in the console said this, on any screen: the first-run text told people to
// "double-click punktfunk-sync.pext" without ever saying where a .pext comes from, and the only
// copy that existed lived in the repo README — pointing at a CI *artifact*, which means signing in
// to Gitea, opening a workflow run and unzipping a build. That is not a download link.
/** The repo's releases, newest first. */
export const RELEASES_URL =
"https://git.unom.io/unom/punktfunk-plugin-playnite/releases";
/**
* The newest release's page, where `punktfunk-sync.pext` is attached.
*
* Gitea serves `/releases/latest` as a 303 to the current tag's page (verified against
* git.unom.io). It has no GitHub-style `/releases/latest/download/<file>` route that 404s so
* this lands on the page and the user picks the asset, rather than pretending to be a direct file
* link that would break the moment it is clicked.
*/
export const LATEST_RELEASE_URL = `${RELEASES_URL}/latest`;
+10 -1
View File
@@ -28,7 +28,7 @@ import {
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { Gamepad2, LibraryBig, PlugZap } from "lucide-react";
import { Download, Gamepad2, LibraryBig, PlugZap } from "lucide-react";
import { motion } from "motion/react";
import { useCallback, useState } from "react";
import { Gate } from "@/components/gate";
@@ -40,6 +40,7 @@ import {
} from "@/data/atoms";
import { prefix } from "@/data/client";
import { errorText } from "@/lib/errors";
import { LATEST_RELEASE_URL } from "@/lib/links";
import type { PageProps } from "@/routes";
type Entry = PreviewResult["entries"][number];
@@ -57,6 +58,14 @@ const NoExporter = () => (
icon={PlugZap}
title="No library yet"
description="Install the Punktfunk Sync extension in Playnite and restart it — the games it exports show up here."
action={
<Button asChild>
<a href={LATEST_RELEASE_URL} target="_blank" rel="noreferrer">
<Download className="size-4" />
Download punktfunk-sync.pext
</a>
</Button>
}
className="py-16"
/>
);
+22 -3
View File
@@ -12,7 +12,7 @@ import {
AccordionItem,
AccordionTrigger,
} from "@unom/ui/accordion";
import { AnimatedButton } from "@unom/ui/button";
import { AnimatedButton, Button } from "@unom/ui/button";
import Card from "@unom/ui/card";
import { CodeBlock } from "@unom/ui/code-block";
import { EmptyState } from "@unom/ui/empty-state";
@@ -22,6 +22,7 @@ import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import {
Download,
Image,
Library,
PlugZap,
@@ -38,6 +39,7 @@ import {
statusEventsAtom,
} from "@/data/atoms";
import { errorText } from "@/lib/errors";
import { LATEST_RELEASE_URL } from "@/lib/links";
import type { PageProps } from "@/routes";
const PageSkeleton = () => (
@@ -53,14 +55,31 @@ const PageSkeleton = () => (
</div>
);
/** The state a fresh install is in: this plugin runs, the Playnite half does not yet. */
/**
* The state a fresh install is in: this plugin runs, the Playnite half does not yet.
*
* The screen every new user meets, so it has to answer "what do I do now" without leaving the
* console. It used to name `punktfunk-sync.pext` and stop there no hint that the file is a
* download, let alone where from which left the only route to it as: guess it lives on the repo,
* find the CI page, open the newest run, unzip an artifact.
*/
const FirstRun = ({ status }: { status: EngineStatus }) => (
<div className="space-y-6">
<EmptyState
icon={PlugZap}
title="Waiting for Playnite"
description={
"This half of the plugin is running. The other half — the Punktfunk Sync extension — lives inside Playnite and writes your library out on every change. Install it in Playnite (double-click punktfunk-sync.pext) and restart Playnite; your games appear here within seconds, no configuration needed."
"This half of the plugin is running. The other half — the Punktfunk Sync extension — lives inside Playnite and writes your library out on every change. Download it below, double-click the .pext to install it, and restart Playnite; your games appear here within seconds, no configuration needed."
}
action={
<Button asChild>
{/* The console frames plugin UIs cross-origin with `allow-popups`, so a new tab is
the one navigation that works from in here. */}
<a href={LATEST_RELEASE_URL} target="_blank" rel="noreferrer">
<Download className="size-4" />
Download punktfunk-sync.pext
</a>
</Button>
}
className="py-16"
/>
+15
View File
@@ -149,6 +149,21 @@ export const SettingsPage = (_: PageProps) => {
addLabel="Exclude source"
/>
</SettingsRow>
{/* Was config-file-only until now, which made it a dead end: a host that cannot
open Playnite's Fullscreen app refuses this tile, and until the host learned
to drop just the tile that refusal cost the WHOLE library. The escape hatch
existed and no one could find it. */}
<SettingsRow
label="Playnite tile"
description="Publish a tile that opens Playnite's own Fullscreen UI alongside your games. Turn it off if your host can't find Playnite."
>
<Switch
checked={resolved?.launcher ?? true}
onCheckedChange={(checked) =>
draft.patch({ launcher: checked === true })
}
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup