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 }) + } + /> +