From 7409b21494f2b3ecb6122795da0629dd873d6998 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 15:39:54 +0200 Subject: [PATCH] feat: tell people where the .pext comes from, and cut real releases so there is somewhere to send them (0.4.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-run screen said "double-click punktfunk-sync.pext" and stopped there — no hint the file is a download, let alone where from. Nothing in the console linked to it on any screen. The only copy that existed was in the README, and it pointed at the repo's Actions tab: sign in to Gitea, find the newest run, download a zip artifact, unzip it. That is not a download link, and it is not something a user discovers. (The README of the very first commit did promise the console "can also deploy the exporter for you", dropping the extension into %APPDATA%\Playnite\ Extensions\. No commit ever implemented it — `git grep -i deploy` finds that sentence and nothing else — and the claim was later deleted. So there is no lost capability here to restore; the discoverability gap has been there since day one.) Two halves to the fix, because a link needs somewhere to point: - **Real releases.** A `v*` tag now creates a Gitea release with the freshly built `punktfunk-sync.pext` attached, plus a `.sha256` sidecar so the download can be verified, and a body that carries the install instructions — that page is now the destination for every "where do I get this" link, so it has to answer on its own. `scripts/ci/gitea-release.sh` is a trimmed sibling of the monorepo helper, carrying over its two hard-won behaviours: a plain asset POST 409s on a duplicate name (so re-running a tag fails after publishing fine), and every asset gets a checksum sidecar. The branch-build artifact stays, for testing an unreleased exporter. It is just no longer where users are sent. - **Links in the console.** Overview's first-run state and the Library empty state both grow a "Download punktfunk-sync.pext" button pointing at /releases/latest. Gitea serves that as a 303 to the current tag; it has no GitHub-style /releases/latest/download/ route (that 404s), so this lands on the page rather than faking a direct file link that would break the moment it was clicked. The console frames plugin UIs cross-origin with `allow-popups`, so a new tab is the one navigation that works from in there. Also surfaces the `launcher` toggle in Settings. It was config-file-only, which made it a dead end: a host that cannot open Playnite's Fullscreen app refuses that tile, and until the host learned to drop just the tile, that refusal cost the entire library. The escape hatch existed and nobody could find it. --- .gitea/workflows/ci.yml | 60 +++++++++++++++++- README.md | 9 ++- bun.lock | 2 +- exporter/README.md | 8 ++- plugin/package.json | 2 +- scripts/ci/gitea-release.sh | 118 ++++++++++++++++++++++++++++++++++++ ui/src/lib/links.ts | 20 ++++++ ui/src/pages/library.tsx | 11 +++- ui/src/pages/overview.tsx | 25 +++++++- ui/src/pages/settings.tsx | 15 +++++ 10 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 scripts/ci/gitea-release.sh create mode 100644 ui/src/lib/links.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5ffa146..11cd4d8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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') diff --git a/README.md b/README.md index 46e7940..9d90d7f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bun.lock b/bun.lock index 636e550..056495d 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ }, "plugin": { "name": "@punktfunk/plugin-playnite", - "version": "0.4.2", + "version": "0.4.3", "bin": { "punktfunk-plugin-playnite": "./dist/cli.js", }, diff --git a/exporter/README.md b/exporter/README.md index e982540..25a9c0a 100644 --- a/exporter/README.md +++ b/exporter/README.md @@ -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 diff --git a/plugin/package.json b/plugin/package.json index 02bda87..e6d4a96 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -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.", diff --git a/scripts/ci/gitea-release.sh b/scripts/ci/gitea-release.sh new file mode 100644 index 0000000..66b4969 --- /dev/null +++ b/scripts/ci/gitea-release.sh @@ -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 `.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 " " 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 +} diff --git a/ui/src/lib/links.ts b/ui/src/lib/links.ts new file mode 100644 index 0000000..3b10a98 --- /dev/null +++ b/ui/src/lib/links.ts @@ -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/` 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`; diff --git a/ui/src/pages/library.tsx b/ui/src/pages/library.tsx index 0f2f989..583169f 100644 --- a/ui/src/pages/library.tsx +++ b/ui/src/pages/library.tsx @@ -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={ + + } className="py-16" /> ); diff --git a/ui/src/pages/overview.tsx b/ui/src/pages/overview.tsx index 72f6ec0..919d680 100644 --- a/ui/src/pages/overview.tsx +++ b/ui/src/pages/overview.tsx @@ -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 = () => ( ); -/** 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 }) => (
+ {/* The console frames plugin UIs cross-origin with `allow-popups`, so a new tab is + the one navigation that works from in here. */} + + + Download punktfunk-sync.pext + + } className="py-16" /> diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index f135f95..d85a065 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -149,6 +149,21 @@ export const SettingsPage = (_: PageProps) => { addLabel="Exclude source" /> + {/* 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. */} + + + draft.patch({ launcher: checked === true }) + } + /> +