Tell people where the .pext comes from, and cut real releases so there is somewhere to send them (0.4.3) #5
+57
-3
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`;
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user