From 631cda5689b61dfdb8600d8a8a5efa60b5392c02 Mon Sep 17 00:00:00 2001 From: Victor Boiting Date: Wed, 2 Sep 2026 22:08:44 +0200 Subject: [PATCH 1/7] Add central store registry with CI validation The plugin store list lives in plugins/ui/src/SettingsPage/PluginStoreTab/index.tsx today, so adding a store needs a code change plus a client release, and removing one never reaches users who already have it persisted. store/stores.json is the list instead. Seeded with the 22 stores currently hardcoded and 7 tombstones recovered from the git history of index.tsx, three of which are hard 404s that have been showing up as error cards for months. store/validate.sh checks schema, unknown keys, url and repo consistency, duplicates and that every active store.json actually resolves with a non empty plugins array. Needs only jq and curl, runs in about 9 seconds. --- .github/workflows/validateStores.yml | 30 ++++ store/blocklist.json | 4 + store/stores.json | 201 +++++++++++++++++++++++++++ store/stores.schema.json | 53 +++++++ store/validate.sh | 108 ++++++++++++++ 5 files changed, 396 insertions(+) create mode 100644 .github/workflows/validateStores.yml create mode 100644 store/blocklist.json create mode 100644 store/stores.json create mode 100644 store/stores.schema.json create mode 100755 store/validate.sh diff --git a/.github/workflows/validateStores.yml b/.github/workflows/validateStores.yml new file mode 100644 index 00000000..b24a98e5 --- /dev/null +++ b/.github/workflows/validateStores.yml @@ -0,0 +1,30 @@ +name: "Validate Stores" + +on: + pull_request: + paths: + - "store/**" + push: + branches: [master, dev] + paths: + - "store/**" + workflow_dispatch: + +# Read only on purpose, this runs on pull requests from forks +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + Validate: + name: Validate + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Validate store registry 🔍 + run: ./store/validate.sh diff --git a/store/blocklist.json b/store/blocklist.json new file mode 100644 index 00000000..bffe2b4c --- /dev/null +++ b/store/blocklist.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "patterns": [] +} diff --git a/store/stores.json b/store/stores.json new file mode 100644 index 00000000..9f48d34d --- /dev/null +++ b/store/stores.json @@ -0,0 +1,201 @@ +{ + "$schema": "https://raw.githubusercontent.com/Inrixia/TidaLuna/master/store/stores.schema.json", + "version": 1, + "stores": [ + { + "name": "@superslowjelly/clearcoat", + "repo": "SuperslowJelly/TIDAL-Clear-Coat", + "url": "https://github.com/SuperslowJelly/TIDAL-Clear-Coat/releases/download/latest/store.json", + "added": "2025-05-31" + }, + { + "name": "@meowarex/TidalLuna-Plugins", + "repo": "meowarex/TidaLuna-Plugins", + "url": "https://github.com/meowarex/TidaLuna-Plugins/releases/download/latest/store.json", + "added": "2025-06-02" + }, + { + "name": "@seth/lunar", + "repo": "wont-stream/lunar", + "url": "https://github.com/wont-stream/lunar/releases/download/dev/store.json", + "added": "2025-05-25" + }, + { + "name": "@jxnxsdev/luna-plugins", + "repo": "jxnxsdev/luna-plugins", + "url": "https://github.com/jxnxsdev/luna-plugins/releases/download/latest/store.json", + "added": "2025-05-25" + }, + { + "name": "@espeon/luna-plugins", + "repo": "espeon/luna-plugins", + "url": "https://github.com/espeon/luna-plugins/releases/download/latest/store.json", + "added": "2025-05-31" + }, + { + "name": "@inrixia/luna-plugins", + "repo": "Inrixia/luna-plugins", + "url": "https://github.com/Inrixia/luna-plugins/releases/download/dev/store.json", + "added": "2025-05-31" + }, + { + "name": "@aztup/luna-plugins", + "repo": "Aztup/luna-plugins", + "url": "https://github.com/Aztup/luna-plugins/releases/download/latest/store.json", + "added": "2025-06-11" + }, + { + "name": "@vmohammad/plugins", + "repo": "vMohammad24/luna-plugins", + "url": "https://github.com/vMohammad24/luna-plugins/releases/download/latest/store.json", + "added": "2025-06-11" + }, + { + "name": "@MathDesigns/luna-plugins", + "repo": "MathDesigns/luna-plugins", + "url": "https://github.com/MathDesigns/luna-plugins/releases/download/latest/store.json", + "added": "2025-06-12" + }, + { + "name": "@otomir23/luna-plugins", + "repo": "otomir23/luna-plugins", + "url": "https://github.com/otomir23/luna-plugins/releases/download/latest/store.json", + "added": "2025-07-02" + }, + { + "name": "@dantraynor/lunaplugins", + "repo": "dantraynor/lunaplugins", + "url": "https://github.com/dantraynor/lunaplugins/releases/download/latest/store.json", + "added": "2025-07-10" + }, + { + "name": "@Akasiek/tidaluna-plugins", + "repo": "Akasiek/tidaluna-plugins", + "url": "https://github.com/Akasiek/tidaluna-plugins/releases/download/latest/store.json", + "added": "2025-07-31" + }, + { + "name": "@foukapik/TidaLuna-Plugins", + "repo": "Foukapik/TidaLuna-Plugins", + "url": "https://github.com/Foukapik/TidaLuna-Plugins/releases/download/latest/store.json", + "added": "2025-08-08" + }, + { + "name": "@Renskursa/tidaluna-plugins", + "repo": "Renskursa/tidaluna-plugins", + "url": "https://github.com/Renskursa/tidaluna-plugins/releases/download/latest/store.json", + "added": "2025-08-11" + }, + { + "name": "@Henr1ES/TidalLuna-Plugins", + "repo": "Henr1ES/TidalLunaPlugins", + "url": "https://github.com/Henr1ES/TidalLunaPlugins/releases/download/latest/store.json", + "added": "2025-09-01" + }, + { + "name": "@sinnerk0n/tidaluna-plugins", + "repo": "SinnerK0N/tidaluna-plugins", + "url": "https://github.com/SinnerK0N/tidaluna-plugins/releases/download/latest/store.json", + "added": "2026-01-11" + }, + { + "name": "@squadgazzz/luna-plugins", + "repo": "squadgazzz/luna-plugins", + "url": "https://github.com/squadgazzz/luna-plugins/releases/download/latest/store.json", + "added": "2026-02-20" + }, + { + "name": "@minseokk7/korean-translation-store", + "repo": "minseokk7/luna-plugins", + "url": "https://github.com/minseokk7/luna-plugins/releases/download/latest/store.json", + "added": "2026-03-28" + }, + { + "name": "@FireWall/TidaLuna-Plugins", + "repo": "FireWall-code/TidaLuna-Plugins", + "url": "https://github.com/FireWall-code/TidaLuna-Plugins/releases/download/latest/store.json", + "added": "2026-06-02" + }, + { + "name": "Flaze's TidaLuna Plugins", + "repo": "FlazeIGuess/tidaluna-plugins", + "url": "https://github.com/FlazeIGuess/tidaluna-plugins/releases/download/latest/store.json", + "added": "2026-07-18" + }, + { + "name": "@seomin0610/luna-plugin", + "repo": "seomin0610/luna-plugins", + "url": "https://github.com/seomin0610/luna-plugins/releases/download/latest/store.json", + "added": "2026-08-18" + }, + { + "name": "Vis's Luna Plugins", + "repo": "visiuun/tidaluna-plugins", + "url": "https://github.com/visiuun/tidaluna-plugins/releases/download/latest/store.json", + "added": "2026-09-02" + }, + { + "name": "Inrixia Neptune Plugins", + "repo": "Inrixia/neptune-plugins", + "url": "https://github.com/Inrixia/neptune-plugins/releases/download/dev/store.json", + "added": "2025-05-16", + "status": "removed", + "removed": "2025-05-31", + "reason": "Neptune predecessor, release asset no longer published" + }, + { + "name": "DevonCasey Tidal Luna Plugins", + "repo": "DevonCasey/tidal-luna-plugins", + "url": "https://github.com/DevonCasey/tidal-luna-plugins/releases/download/latest/store.json", + "added": "2025-11-04", + "status": "removed", + "removed": "2025-11-08", + "reason": "Repo deleted, returns 404" + }, + { + "name": "DevonCasey TidaLuna Plugins", + "repo": "DevonCasey/tidaluna-plugins", + "url": "https://github.com/DevonCasey/tidaluna-plugins/releases/download/latest/store.json", + "added": "2025-11-08", + "status": "removed", + "removed": "2026-07-19", + "reason": "Repo deleted, returns 404" + }, + { + "name": "minseokk7 Korean Translation v1.0.0", + "repo": "minseokk7/luna-plugins", + "url": "https://github.com/minseokk7/luna-plugins/releases/download/v1.0.0/store.json", + "added": "2026-03-28", + "status": "removed", + "removed": "2026-03-28", + "reason": "Superseded by the latest tag of the same repo" + }, + { + "name": "minseokk7 Korean Translation v1.1.0", + "repo": "minseokk7/luna-plugins", + "url": "https://github.com/minseokk7/luna-plugins/releases/download/v1.1.0/store.json", + "added": "2026-03-28", + "status": "removed", + "removed": "2026-03-28", + "reason": "Superseded by the latest tag of the same repo" + }, + { + "name": "FireWall SMTC Shuffle Repeat", + "repo": "FireWall-code/tidaluna-smtc-shuffle-repeat", + "url": "https://github.com/FireWall-code/tidaluna-smtc-shuffle-repeat/releases/download/latest/store.json", + "added": "2026-06-02", + "status": "removed", + "removed": "2026-06-02", + "reason": "Superseded by FireWall-code/TidaLuna-Plugins" + }, + { + "name": "meowarex Plugins (old repo name)", + "repo": "meowarex/TidalLuna-Plugins", + "url": "https://github.com/meowarex/TidalLuna-Plugins/releases/download/latest/store.json", + "added": "2025-06-02", + "status": "removed", + "removed": "2026-09-02", + "reason": "Repo renamed to meowarex/TidaLuna-Plugins, listed under the new url" + } + ] +} diff --git a/store/stores.schema.json b/store/stores.schema.json new file mode 100644 index 00000000..be96ad9f --- /dev/null +++ b/store/stores.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TidaLuna plugin store registry", + "type": "object", + "additionalProperties": false, + "required": ["version", "stores"], + "properties": { + "$schema": { "type": "string" }, + "version": { "const": 1 }, + "stores": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "repo", "url", "added"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Display name, usually the name field of the stores store.json" + }, + "repo": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", + "description": "GitHub owner/repo the store is published from, used by the registry generator to fetch stars and downloads" + }, + "url": { + "type": "string", + "pattern": "^https://github\\.com/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+/releases/download/[^/]+/store\\.json$", + "description": "Direct link to the store.json release asset" + }, + "added": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "status": { + "enum": ["active", "removed"], + "description": "Defaults to active. removed is a tombstone, it drops the store from clients that already have it" + }, + "removed": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "reason": { + "type": "string", + "minLength": 1, + "description": "Why the store was removed, shown to nobody but read by everyone in the diff" + } + } + } + } + } +} diff --git a/store/validate.sh b/store/validate.sh new file mode 100755 index 00000000..048786b2 --- /dev/null +++ b/store/validate.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Validates store/stores.json and store/blocklist.json. +# Run it locally with ./store/validate.sh before opening a pull request. +# Only needs jq and curl, both are preinstalled on ubuntu-latest. +set -uo pipefail + +cd "$(dirname "$0")/.." + +# Overridable so the checks can be run against a fixture +STORES="${STORES_FILE:-store/stores.json}" +BLOCKLIST="${BLOCKLIST_FILE:-store/blocklist.json}" +FAILED=0 + +# GitHub picks these up as inline annotations on the pull request diff +err() { + local line="${2:-1}" + echo "::error file=$STORES,line=$line::$1" + FAILED=1 +} + +# Line number of the nth entry in the stores array, so annotations land near the right place +lineOf() { + grep -n "^$(printf '\t\t'){" "$STORES" | sed -n "$(($1 + 1))p" | cut -d: -f1 +} + +for file in "$STORES" "$BLOCKLIST"; do + if [[ ! -f $file ]]; then + echo "::error::$file is missing" + exit 1 + fi + if ! jq empty "$file" 2>/dev/null; then + echo "::error file=$file::Not valid JSON" + exit 1 + fi +done + +[[ $(jq -r '.version' "$STORES") == "1" ]] || err "version must be 1" +[[ $(jq -r '.stores | type' "$STORES") == "array" ]] || { err "stores must be an array"; exit 1; } + +KNOWN_KEYS='["name","repo","url","added","status","removed","reason"]' +COUNT=$(jq '.stores | length' "$STORES") +echo "Checking $COUNT entries in $STORES" + +for ((i = 0; i < COUNT; i++)); do + LINE=$(lineOf "$i") + entry=$(jq -c ".stores[$i]" "$STORES") + name=$(jq -r '.name // ""' <<<"$entry") + repo=$(jq -r '.repo // ""' <<<"$entry") + url=$(jq -r '.url // ""' <<<"$entry") + added=$(jq -r '.added // ""' <<<"$entry") + status=$(jq -r '.status // "active"' <<<"$entry") + label="[$i] ${name:-}" + + unknown=$(jq -r --argjson known "$KNOWN_KEYS" 'keys - $known | join(", ")' <<<"$entry") + [[ -z $unknown ]] || err "$label unknown key: $unknown" "$LINE" + + [[ -n $name ]] || err "$label name is required and must not be empty" "$LINE" + [[ $repo =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || err "$label repo must be owner/repo, got '$repo'" "$LINE" + [[ $url =~ ^https://github\.com/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+/releases/download/[^/]+/store\.json$ ]] || + err "$label url must be a store.json release asset link, got '$url'" "$LINE" + [[ $added =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || err "$label added must be YYYY-MM-DD, got '$added'" "$LINE" + [[ $status == "active" || $status == "removed" ]] || err "$label status must be active or removed, got '$status'" "$LINE" + + # The repo field drives the metrics generator, a mismatch means it would query the wrong repo + urlRepo=$(sed -E 's#^https://github\.com/([^/]+/[^/]+)/releases/.*$#\1#' <<<"$url") + [[ $urlRepo == "$repo" ]] || err "$label url points at $urlRepo but repo says $repo" "$LINE" + + if [[ $status == "removed" ]]; then + [[ $(jq -r '.reason // ""' <<<"$entry") != "" ]] || err "$label removed entries need a reason" "$LINE" + continue + fi + + # Only active entries have to resolve, tombstones are expected to be dead + body=$(curl -sL --max-time 30 -w '\n%{http_code}' "$url") + code=$(tail -1 <<<"$body") + if [[ $code != "200" ]]; then + err "$label store.json returned HTTP $code" "$LINE" + continue + fi + json=$(sed '$d' <<<"$body") + if ! jq empty <<<"$json" 2>/dev/null; then + err "$label store.json is not valid JSON" "$LINE" + continue + fi + [[ $(jq -r '.name // ""' <<<"$json") != "" ]] || err "$label store.json has no name field" "$LINE" + plugins=$(jq -r '(.plugins // []) | length' <<<"$json") + [[ $plugins -gt 0 ]] || err "$label store.json lists no plugins" "$LINE" + [[ $(jq -r '(.plugins // []) | map(select(type != "string")) | length' <<<"$json") == "0" ]] || + err "$label store.json plugins must be an array of file names" "$LINE" +done + +# Duplicate urls are always wrong, duplicate repos are fine on tombstones (retagged releases) +dupUrls=$(jq -r '[.stores[].url] | group_by(.) | map(select(length > 1) | .[0]) | join(", ")' "$STORES") +[[ -z $dupUrls ]] || err "duplicate url: $dupUrls" +dupRepos=$(jq -r '[.stores[] | select((.status // "active") == "active") | .repo] | group_by(.) | map(select(length > 1) | .[0]) | join(", ")' "$STORES") +[[ -z $dupRepos ]] || err "duplicate active repo: $dupRepos" + +[[ $(jq -r '.version' "$BLOCKLIST") == "1" ]] || { echo "::error file=$BLOCKLIST::version must be 1"; FAILED=1; } +[[ $(jq -r '.patterns | type' "$BLOCKLIST") == "array" ]] || { echo "::error file=$BLOCKLIST::patterns must be an array"; FAILED=1; } +badPattern=$(jq -r '[.patterns[] | select((.pattern // "") == "" or (.reason // "") == "")] | length' "$BLOCKLIST") +[[ $badPattern == "0" ]] || { echo "::error file=$BLOCKLIST::every pattern needs a pattern and a reason"; FAILED=1; } + +if [[ $FAILED == 0 ]]; then + echo "All $COUNT entries look good" +else + echo "Validation failed" +fi +exit $FAILED From 77315635fc38bb1d8b0051e9ff5e58176733c0ac Mon Sep 17 00:00:00 2001 From: Victor Boiting Date: Wed, 2 Sep 2026 22:19:42 +0200 Subject: [PATCH 2/7] Load the plugin store list from store/stores.json instead of hardcoding it Replaces the 22 addToStores calls with a fetch of store/stores.json from raw githubusercontent, pinned to master so a merged store reaches clients without a release. The old storeUrls key mixed defaults and user additions in one list, so removing a default brought it back on the next start and removing one from the code never reached anyone who already had it. Split into three: userStoreUrls for what the user added, hiddenStoreUrls for defaults they dismissed, and the fetched list, which is persisted and doubles as the offline cache. Tombstoned entries drop dead stores from clients that already carry them. The decision logic lives in registry.logic.ts, separate from the reactive state and the fetching in registry.ts. Three things found on the way are fixed here because the wiring touched them: - the deeplink handler lowercased the whole url and pushed it without stripping /store.json, persisting a url that always 404s - Storage.tsx had a second addToStores writing to a different idb store, so adding a store from the themes tab silently went nowhere - LunaStore mapped only the first space in a plugin name and called .filter on unvalidated third party json, which takes down the whole settings page --- .github/workflows/validateStores.yml | 1 + .../PluginStoreTab/InstallFromUrl.tsx | 2 +- .../SettingsPage/PluginStoreTab/LunaStore.tsx | 20 ++- .../src/SettingsPage/PluginStoreTab/index.tsx | 65 +++----- .../SettingsPage/PluginStoreTab/registry.ts | 146 ++++++++++++++++++ plugins/ui/src/SettingsPage/Storage.tsx | 8 +- plugins/ui/src/index.tsx | 18 ++- store/README.md | 68 ++++++++ 8 files changed, 270 insertions(+), 58 deletions(-) create mode 100644 plugins/ui/src/SettingsPage/PluginStoreTab/registry.ts create mode 100644 store/README.md diff --git a/.github/workflows/validateStores.yml b/.github/workflows/validateStores.yml index b24a98e5..952cbde0 100644 --- a/.github/workflows/validateStores.yml +++ b/.github/workflows/validateStores.yml @@ -28,3 +28,4 @@ jobs: - name: Validate store registry 🔍 run: ./store/validate.sh + diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx b/plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx index 5350330a..a90e2ef8 100644 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx @@ -6,7 +6,7 @@ import { debounce } from "@inrixia/helpers"; import { LunaPlugin } from "@luna/core"; import { Messager } from "@luna/core"; -import { addToStores } from "."; +import { addToStores } from "./registry"; const successSx = { "& .MuiOutlinedInput-root:hover:not(.Mui-focused) .MuiOutlinedInput-notchedOutline": { diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx index 0676fd63..03221da1 100644 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx @@ -9,12 +9,21 @@ import Typography from "@mui/material/Typography"; import { LunaTrashButton, SpinningButton } from "../../components"; import { LunaPluginHeader } from "../PluginsTab/LunaPluginHeader"; import { LunaStorePlugin } from "./LunaStorePlugin"; +import type { RegistryStore } from "./registry"; interface StorePackage extends PluginPackage { plugins: string[]; } -export const LunaStore = React.memo(({ url, onRemove, searchQuery }: { url: string; onRemove: () => void; searchQuery: string }) => { +interface LunaStoreProps { + url: string; + onRemove: () => void; + searchQuery: string; + /** Registry metadata, undefined for stores the user added themselves */ + entry?: RegistryStore; +} + +export const LunaStore = React.memo(({ url, onRemove, searchQuery, entry }: LunaStoreProps) => { const [loading, setLoading] = React.useState(false); const [loadError, setLoadError] = React.useState(undefined); const [pkg, setPackage] = React.useState(undefined); @@ -47,7 +56,8 @@ export const LunaStore = React.memo(({ url, onRemove, searchQuery }: { url: stri if (pkg === undefined && !loading && !loadError) return null; // Don't render anything until initial load attempt if (!isLocalDevStore && loading && !pkg) return Loading store {url}...; // Show loading indicator if still loading initially - let name = pkg?.name ?? "Unknown Store"; + // The registry knows the name even when the store itself is unreachable + let name = pkg?.name ?? entry?.name ?? "Unknown Store"; if (isLocalDevStore) name = `${name} [DEV]`; const author = pkg?.author; @@ -58,8 +68,10 @@ export const LunaStore = React.memo(({ url, onRemove, searchQuery }: { url: stri // Don't show error for local dev store if (loadError && isLocalDevStore) return null; + // Stores are third party json, a malformed plugins array must not take the whole settings page down + const plugins = Array.isArray(pkg?.plugins) ? pkg.plugins.filter((plugin) => typeof plugin === "string") : undefined; const query = searchQuery.toLowerCase(); - const filteredPlugins = query ? pkg?.plugins.filter((plugin) => plugin.toLowerCase().includes(query)) : pkg?.plugins; + const filteredPlugins = query ? plugins?.filter((plugin) => plugin.toLowerCase().includes(query)) : plugins; if (query && (!filteredPlugins || filteredPlugins.length === 0)) return null; return ( @@ -87,7 +99,7 @@ export const LunaStore = React.memo(({ url, onRemove, searchQuery }: { url: stri /> {filteredPlugins?.map((plugin) => ( - } /> + } /> ))} diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/index.tsx b/plugins/ui/src/SettingsPage/PluginStoreTab/index.tsx index 30e2b128..258cab2f 100644 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/index.tsx +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/index.tsx @@ -2,57 +2,37 @@ import React, { useCallback, useEffect, useState } from "react"; import { store as obyStore } from "oby"; -import { ReactiveStore } from "@luna/core"; +import { unloadSet } from "@luna/core"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; import { InstallFromUrl } from "./InstallFromUrl"; import { LunaStore } from "./LunaStore"; +import { hiddenStoreUrls, refreshRegistry, registryStores, removeStore, userStoreUrls, visibleStores, type StoreEntry } from "./registry"; -const pluginStores = ReactiveStore.getStore("@luna/pluginStores"); -export const storeUrls = await pluginStores.getReactive("storeUrls", []); -export const addToStores = (url: string) => { - if (url.endsWith("/store.json")) url = url.slice(0, -11); - if (storeUrls.includes(url)) return false; - return storeUrls.push(url); -}; +export * from "./registry"; -// Devs! Add your stores here <3 -// TODO: Abstract this to a git repo -addToStores("https://github.com/SuperslowJelly/TIDAL-Clear-Coat/releases/download/latest/store.json"); -addToStores("https://github.com/meowarex/TidalLuna-Plugins/releases/download/latest/store.json"); -addToStores("https://github.com/wont-stream/lunar/releases/download/dev/store.json"); -addToStores("https://github.com/jxnxsdev/luna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/espeon/luna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/Inrixia/luna-plugins/releases/download/dev/store.json"); -addToStores("https://github.com/Aztup/luna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/vMohammad24/luna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/MathDesigns/luna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/otomir23/luna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/dantraynor/lunaplugins/releases/download/latest/store.json"); -addToStores("https://github.com/Akasiek/tidaluna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/Foukapik/TidaLuna-Plugins/releases/download/latest/store.json"); -addToStores("https://github.com/Renskursa/tidaluna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/Henr1ES/TidalLunaPlugins/releases/download/latest/store.json"); -addToStores("https://github.com/SinnerK0N/tidaluna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/squadgazzz/luna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/minseokk7/luna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/FireWall-code/TidaLuna-Plugins/releases/download/latest/store.json"); -addToStores("https://github.com/FlazeIGuess/tidaluna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/seomin0610/luna-plugins/releases/download/latest/store.json"); -addToStores("https://github.com/visiuun/tidaluna-plugins/releases/download/latest/store.json"); +const DEV_STORE_URL = "http://127.0.0.1:3000"; export const PluginStoreTab = React.memo(() => { - const [_storeUrls, setPluginStores] = useState(obyStore.unwrap(storeUrls)); + const [stores, setStores] = useState(visibleStores); const [searchQuery, setSearchQuery] = useState(""); - useEffect(() => obyStore.on(storeUrls, () => setPluginStores([...obyStore.unwrap(storeUrls)])), []); - const onRemove = useCallback((storeUrl: string) => { - const index = storeUrls.indexOf(storeUrl); - if (index > -1) storeUrls.splice(index, 1); + useEffect(() => { + const update = () => setStores(visibleStores()); + // Any of the three can change the visible list, the registry from a fetch and the other two from the user + const unloads = new Set([obyStore.on(registryStores, update), obyStore.on(userStoreUrls, update), obyStore.on(hiddenStoreUrls, update)]); + refreshRegistry().catch((err) => console.error("[PluginStore] Failed to refresh registry:", err)); + // Block body on purpose, unloadSet is async and React rejects a Promise as cleanup + return () => { + unloadSet(unloads); + }; }, []); + const onRemove = useCallback((storeUrl: string) => removeStore(storeUrl), []); + return ( @@ -65,10 +45,15 @@ export const PluginStoreTab = React.memo(() => { onChange={(e) => setSearchQuery(e.target.value)} /> - {}} searchQuery={searchQuery} /> - {_storeUrls.map((store) => ( - onRemove(store)} searchQuery={searchQuery} /> + {}} searchQuery={searchQuery} /> + {stores.map((store) => ( + onRemove(store.url)} searchQuery={searchQuery} /> ))} + {stores.length === 0 && ( + + No plugin stores yet. They load from the registry, check your connection or add one above. + + )} ); }); diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/registry.ts b/plugins/ui/src/SettingsPage/PluginStoreTab/registry.ts new file mode 100644 index 00000000..dd5351ec --- /dev/null +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/registry.ts @@ -0,0 +1,146 @@ +import { ftch, ReactiveStore } from "@luna/core"; + +const RAW = "https://raw.githubusercontent.com/Inrixia/TidaLuna/master/store"; +export const STORES_URL = `${RAW}/stores.json`; +export const BLOCKLIST_URL = `${RAW}/blocklist.json`; + +// The settings page unmounts tabs on switch, without this every visit refetches +const REFRESH_INTERVAL = 15 * 60 * 1000; + +export type RegistryStore = { + name: string; + repo: string; + url: string; + added?: string; + status?: "active" | "removed"; + reason?: string; +}; +type BlocklistPattern = { pattern: string; reason: string }; +type Registry = { version: number; stores: RegistryStore[] }; +type Blocklist = { version: number; patterns: BlocklistPattern[] }; + +export type StoreEntry = { + url: string; + /** Undefined for a store the user added themselves */ + entry?: RegistryStore; + /** User stores can be deleted outright, registry defaults can only be hidden */ + userAdded: boolean; +}; + +const pluginStores = ReactiveStore.getStore("@luna/pluginStores"); + +// The registry as last seen. Persisted, so it doubles as the offline fallback +export const registryStores = await pluginStores.getReactive("registry", { version: 1, stores: [] }); +const blocklist = await pluginStores.getReactive("blocklist", { version: 1, patterns: [] }); +// Only ever what the user added themselves, never a registry default +export const userStoreUrls = await pluginStores.getReactive("userStoreUrls", []); +// Registry defaults the user removed, or they would come back on every start +export const hiddenStoreUrls = await pluginStores.getReactive("hiddenStoreUrls", []); +// The pre registry client kept one flat list. Read once, used only to keep the tab populated +// until a registry lands, then migrated into userStoreUrls and deleted (migrateLegacy). +const legacyStoreUrls = (await pluginStores.get("storeUrls")) ?? []; + +// Users paste the store.json link itself, everything downstream wants the base url +export const normalizeStoreUrl = (url: string) => (url.endsWith("/store.json") ? url.slice(0, -11) : url); + +const globToRegex = (pattern: string) => new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*")}$`); +export const isBlocked = (url: string) => blocklist.patterns.some(({ pattern }) => globToRegex(pattern).test(url)); + +/** + * Registry stores minus the hidden ones, plus whatever the user added, minus anything blocked. + * While no registry has ever been fetched the legacy list keeps the tab populated, so someone + * offline right after updating still sees stores. + */ +export const visibleStores = (): StoreEntry[] => { + const out: StoreEntry[] = []; + const seen = new Set(); + const push = (url: string, entry?: RegistryStore, userAdded = false) => { + if (seen.has(url) || isBlocked(url)) return; + seen.add(url); + out.push({ url, entry, userAdded }); + }; + if (registryStores.stores.length === 0) for (const url of legacyStoreUrls) push(normalizeStoreUrl(url)); + for (const entry of registryStores.stores) { + const url = normalizeStoreUrl(entry.url); + if (entry.status !== "removed" && !hiddenStoreUrls.includes(url)) push(url, entry); + } + for (const url of userStoreUrls) push(url, undefined, true); + return out; +}; + +export const addToStores = (rawUrl: string) => { + const url = normalizeStoreUrl(rawUrl); + if (isBlocked(url)) return false; + // A hidden registry default is just unhidden + const hidden = hiddenStoreUrls.indexOf(url); + if (hidden > -1) { + hiddenStoreUrls.splice(hidden, 1); + return true; + } + if (visibleStores().some((store) => store.url === url)) return false; + userStoreUrls.push(url); + return true; +}; + +export const removeStore = (rawUrl: string) => { + const url = normalizeStoreUrl(rawUrl); + const user = userStoreUrls.indexOf(url); + if (user > -1) { + userStoreUrls.splice(user, 1); + return; + } + // A registry default cannot be deleted, only hidden, or it returns on the next fetch + const isDefault = registryStores.stores.some((e) => e.status !== "removed" && normalizeStoreUrl(e.url) === url); + if (isDefault && !hiddenStoreUrls.includes(url)) hiddenStoreUrls.push(url); +}; + +// Move the old flat storeUrls list into userStoreUrls, dropping everything the registry now owns. +// Runs once a registry has landed, otherwise a failed fetch would turn every default into a user store. +const migrateLegacy = async () => { + if ((await pluginStores.get("registryMigration")) === true) return; + const known = new Set(registryStores.stores.map((e) => normalizeStoreUrl(e.url))); + for (const raw of legacyStoreUrls) { + const url = normalizeStoreUrl(raw); + if (!known.has(url) && !isBlocked(url) && !userStoreUrls.includes(url)) userStoreUrls.push(url); + } + await pluginStores.set("registryMigration", true); + await pluginStores.del("storeUrls"); +}; + +const isRegistry = (data: unknown): data is Registry => + typeof data === "object" && data !== null && Array.isArray((data).stores) && (data).version === 1; + +const fetchRegistry = async () => { + // Blocklist is a kill switch, a failure here must not stop the registry from loading + await ftch + .json(BLOCKLIST_URL) + .then((data) => { + if (data?.version === 1 && Array.isArray(data.patterns)) return pluginStores.set("blocklist", data); + }) + .catch(() => {}); + + let fetched: Registry | undefined; + try { + const data = await ftch.json(STORES_URL); + if (isRegistry(data)) fetched = data; + } catch {} + // Nothing reachable, keep whatever the last fetch left behind + if (fetched !== undefined) await pluginStores.set("registry", fetched); + if (registryStores.stores.length === 0) return false; + + await migrateLegacy(); + return fetched !== undefined; +}; + +let lastFetch = 0; +let inFlight: Promise | undefined; +// At most once per REFRESH_INTERVAL unless forced, and never twice at the same time +export const refreshRegistry = (force = false) => { + if (inFlight !== undefined) return inFlight; + if (!force && lastFetch !== 0 && Date.now() - lastFetch < REFRESH_INTERVAL) return Promise.resolve(false); + inFlight = fetchRegistry().finally(() => { + lastFetch = Date.now(); + inFlight = undefined; + }); + return inFlight; +}; diff --git a/plugins/ui/src/SettingsPage/Storage.tsx b/plugins/ui/src/SettingsPage/Storage.tsx index d212cbde..e1c6669d 100644 --- a/plugins/ui/src/SettingsPage/Storage.tsx +++ b/plugins/ui/src/SettingsPage/Storage.tsx @@ -9,14 +9,10 @@ import { StyleTag } from "@luna/lib"; import { Messager } from "@luna/core"; import type { LunaThemeStorage } from "./ThemesTab/LunaTheme"; import { unloads } from "../index.safe"; +// Shared with the plugin store tab, this used to have its own copy writing to a different idb store +import { addToStores } from "./PluginStoreTab/registry"; const lunaStorage = ReactiveStore.getStore("@luna/storage"); -export const storeUrls = await lunaStorage.getReactive("storeUrls", []); -export const addToStores = (url: string) => { - if (url.endsWith("/store.json")) url = url.slice(0, -11); - if (storeUrls.includes(url)) return false; - return storeUrls.push(url); -}; export const themes = await lunaStorage.getReactive>("themes", {}); export const addToThemes = async (url: string) => { diff --git a/plugins/ui/src/index.tsx b/plugins/ui/src/index.tsx index a86e088c..6a67aeb4 100644 --- a/plugins/ui/src/index.tsx +++ b/plugins/ui/src/index.tsx @@ -11,7 +11,7 @@ import { Page } from "./classes/Page"; import { confirm } from "./helpers/confirm"; import { lunaMuiTheme } from "./lunaTheme"; import { currentSettingsTab, LunaPage } from "./SettingsPage"; -import { storeUrls } from "./SettingsPage/PluginStoreTab"; +import { addToStores } from "./SettingsPage/PluginStoreTab"; import { fetchReleases } from "./SettingsPage/SettingsTab/LunaClientUpdate"; import { pkg } from "plugins/lib.native/src/index.native"; @@ -52,19 +52,23 @@ ContextMenu.onOpen(unloads, async ({ event, contextMenu }) => { }); ipcRenderer.onOpenUrl(unloads, (reqUrl) => { - const url = URL.parse(reqUrl.toLowerCase()); + const url = URL.parse(reqUrl); if (url?.protocol !== "tidaluna:") return; - switch (url.pathname) { - case "//settings/store": + // Only the route is case insensitive, lowercasing the whole url would mangle the store link in the query + const pathname = url.pathname.toLowerCase(); + switch (pathname) { + case "//settings/store": { currentSettingsTab._ = "Plugin Store"; - const newStoreUrl = url.searchParams.get("installfromurl"); - if (newStoreUrl !== null && !storeUrls.includes(newStoreUrl)) storeUrls.push(newStoreUrl); + const newStoreUrl = url.searchParams.get("installFromUrl") ?? url.searchParams.get("installfromurl"); + // addToStores normalizes the trailing /store.json, pushing raw persisted a url that always 404s + if (newStoreUrl !== null) addToStores(newStoreUrl); break; + } case "//settings/plugins": currentSettingsTab._ = "Plugins"; break; } - if (url.pathname.startsWith("//settings")) settingsPage.open(); + if (pathname.startsWith("//settings")) settingsPage.open(); }); setTimeout(async () => { diff --git a/store/README.md b/store/README.md new file mode 100644 index 00000000..6e51cc44 --- /dev/null +++ b/store/README.md @@ -0,0 +1,68 @@ +# Plugin store registry + +This is the list of plugin stores the Luna client shows in Settings > Plugin Store. +It used to live in the client source, which meant a new store needed a code change +and a client release before anyone saw it. Now the client fetches this folder from +`raw.githubusercontent.com`, so a merged pull request reaches everyone within about +five minutes with no update needed. + +## Adding your store + +1. Publish a release with a `store.json` asset. The Luna build does this for you, + see the Developers section of the root README. +2. Open [store/stores.json in the GitHub editor](https://github.com/Inrixia/TidaLuna/edit/master/store/stores.json). + GitHub creates the fork and the pull request for you, no clone needed. +3. Add your entry to the `stores` array: + ```json + { + "name": "@you/luna-plugins", + "repo": "you/luna-plugins", + "url": "https://github.com/you/luna-plugins/releases/download/latest/store.json", + "added": "2026-09-02" + } + ``` + `name` is what shows up when your store cannot be reached, use the `name` from + your own `store.json`. `repo` is what the metrics generator queries, it has to + match the repo in `url`. +4. Open the pull request. CI checks the entry and that your `store.json` actually + resolves with a non empty `plugins` array, and annotates any problem right in the + diff. Run the same checks locally with `./store/validate.sh`. + +A maintainer only has to confirm that the repo is yours, then merge. + +## Removing a store + +Do not delete the entry. Set `"status": "removed"` with a `removed` date and a +`reason` instead: + +```json +{ + "name": "Someones Plugins", + "repo": "someone/luna-plugins", + "url": "https://github.com/someone/luna-plugins/releases/download/latest/store.json", + "added": "2025-11-08", + "status": "removed", + "removed": "2026-07-19", + "reason": "Repo deleted, returns 404" +} +``` + +Deleting the line only stops new clients from picking the store up. The tombstone is +what removes it from everyone who already has it, which is why several dead stores +kept showing up as red error cards for months. + +## Files + +| File | Written by | What it is | +| -------------------- | ---------- | -------------------------------------------------------------------------------- | +| `stores.json` | humans | The list. Source of truth, reviewed in pull requests. | +| `stores.schema.json` | humans | Shape of `stores.json`, for editor completion. CI checks the same rules itself. | +| `blocklist.json` | maintainers| Kill switch. Glob patterns, matching stores disappear from every client. | + +## What this is not + +Being listed here is not a security review. The pull request confirms once that the +repo belongs to the person submitting it. After that the same person can add any +plugin to their own `store.json` without anyone looking at it, and plugins can +request full system access. What the registry buys is accountability and a kill +switch that takes effect in minutes, not a guarantee that the code is safe. From 65e478f5fb52131689edaf2623dd22c0fe6f3c27 Mon Sep 17 00:00:00 2001 From: Victor Boiting Date: Fri, 4 Sep 2026 15:52:42 +0200 Subject: [PATCH 3/7] Bump version to 1.17.0-beta The [master] Release workflow only runs on pushes to master that touch package.json, so without this the merge would not produce a release build. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dfd004a9..beb43a50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "luna", - "version": "1.16.6-beta", + "version": "1.17.0-beta", "description": "A client mod for the Tidal music app for plugins", "author": { "name": "Inrixia", From 0f3c5944ba53d3ff9240fefe322ab53a1c25b525 Mon Sep 17 00:00:00 2001 From: Victor Boiting Date: Fri, 4 Sep 2026 16:04:27 +0200 Subject: [PATCH 4/7] Generate store metrics from a scheduled action Turns store/stores.json into store/registry.json nightly with stars, repo health and per plugin download counts, so the client makes one raw fetch instead of hitting the GitHub API once per store. The client tries registry.json and falls back to stores.json, which satisfies the same shape, so it works before the first run has happened. Metrics older than seven days are dropped rather than shown stale, since the numbers go wrong silently when the generator stops running. --- .github/workflows/registry.yml | 41 ++++++++ .github/workflows/validateStores.yml | 2 + .../SettingsPage/PluginStoreTab/registry.ts | 28 +++++- store/README.md | 51 +++++++++- store/generate.mjs | 96 +++++++++++++++++++ 5 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/registry.yml create mode 100644 store/generate.mjs diff --git a/.github/workflows/registry.yml b/.github/workflows/registry.yml new file mode 100644 index 00000000..9a72ae9d --- /dev/null +++ b/.github/workflows/registry.yml @@ -0,0 +1,41 @@ +name: "Build Store Registry" + +on: + push: + branches: [master] + paths: + - "store/stores.json" + schedule: + # Daily, stars and download counts go stale otherwise + - cron: "17 4 * * *" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + Generate: + name: Generate registry.json + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Node.js 📥 + uses: actions/setup-node@v4 + with: + node-version: latest + + - name: Generate registry 🛠️ + run: node store/generate.mjs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "store: refresh registry metrics" + file_pattern: store/registry.json diff --git a/.github/workflows/validateStores.yml b/.github/workflows/validateStores.yml index 952cbde0..aad49025 100644 --- a/.github/workflows/validateStores.yml +++ b/.github/workflows/validateStores.yml @@ -8,6 +8,8 @@ on: branches: [master, dev] paths: - "store/**" + # registry.json is generated, validating it on every bot commit is noise + - "!store/registry.json" workflow_dispatch: # Read only on purpose, this runs on pull requests from forks diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/registry.ts b/plugins/ui/src/SettingsPage/PluginStoreTab/registry.ts index dd5351ec..d74f9f5c 100644 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/registry.ts +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/registry.ts @@ -1,11 +1,14 @@ import { ftch, ReactiveStore } from "@luna/core"; const RAW = "https://raw.githubusercontent.com/Inrixia/TidaLuna/master/store"; +export const REGISTRY_URL = `${RAW}/registry.json`; export const STORES_URL = `${RAW}/stores.json`; export const BLOCKLIST_URL = `${RAW}/blocklist.json`; // The settings page unmounts tabs on switch, without this every visit refetches const REFRESH_INTERVAL = 15 * 60 * 1000; +// Metrics go stale silently if the generator stops running, so drop them rather than show a wrong number +const MAX_METRIC_AGE = 7 * 24 * 60 * 60 * 1000; export type RegistryStore = { name: string; @@ -14,9 +17,12 @@ export type RegistryStore = { added?: string; status?: "active" | "removed"; reason?: string; + health?: "ok" | "archived" | "unreachable"; + stars?: number; + downloads?: Record; }; type BlocklistPattern = { pattern: string; reason: string }; -type Registry = { version: number; stores: RegistryStore[] }; +type Registry = { version: number; generatedAt?: string; stores: RegistryStore[] }; type Blocklist = { version: number; patterns: BlocklistPattern[] }; export type StoreEntry = { @@ -44,6 +50,12 @@ const legacyStoreUrls = (await pluginStores.get("storeUrls")) ?? []; export const normalizeStoreUrl = (url: string) => (url.endsWith("/store.json") ? url.slice(0, -11) : url); const globToRegex = (pattern: string) => new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*")}$`); +export const metricsAreFresh = () => { + if (registryStores.generatedAt === undefined) return false; + const age = Date.now() - Date.parse(registryStores.generatedAt); + return Number.isFinite(age) && age >= 0 && age < MAX_METRIC_AGE; +}; + export const isBlocked = (url: string) => blocklist.patterns.some(({ pattern }) => globToRegex(pattern).test(url)); /** @@ -119,11 +131,17 @@ const fetchRegistry = async () => { }) .catch(() => {}); + // registry.json carries the metrics, stores.json is the same shape without them let fetched: Registry | undefined; - try { - const data = await ftch.json(STORES_URL); - if (isRegistry(data)) fetched = data; - } catch {} + for (const url of [REGISTRY_URL, STORES_URL]) { + try { + const data = await ftch.json(url); + if (isRegistry(data)) { + fetched = data; + break; + } + } catch {} + } // Nothing reachable, keep whatever the last fetch left behind if (fetched !== undefined) await pluginStores.set("registry", fetched); if (registryStores.stores.length === 0) return false; diff --git a/store/README.md b/store/README.md index 6e51cc44..f70c7dce 100644 --- a/store/README.md +++ b/store/README.md @@ -24,7 +24,7 @@ five minutes with no update needed. `name` is what shows up when your store cannot be reached, use the `name` from your own `store.json`. `repo` is what the metrics generator queries, it has to match the repo in `url`. -4. Open the pull request. CI checks the entry and that your `store.json` actually +4. Open the pull request. CI checks the schema and that your `store.json` actually resolves with a non empty `plugins` array, and annotates any problem right in the diff. Run the same checks locally with `./store/validate.sh`. @@ -51,13 +51,60 @@ Deleting the line only stops new clients from picking the store up. The tombston what removes it from everyone who already has it, which is why several dead stores kept showing up as red error cards for months. +## Optional fields for plugin authors + +Two fields are read from a plugin's own `package.json` and end up in its published +`.json`. Both are optional and everything keeps working without them. + +### `image` - one preview picture + +```json +{ + "name": "@you/your-plugin", + "image": "https://raw.githubusercontent.com/you/luna-plugins/master/assets/preview.png" +} +``` + +The store card then shows it above the text, cropped to 16:9. A card without an +`image` looks exactly as it does today. + +The url must be `https` and point at one of `github.com`, +`raw.githubusercontent.com`, `user-images.githubusercontent.com` or +`objects.githubusercontent.com`. Anything else is ignored and the card falls back to +its plain form. That is not about trusting GitHub more than other hosts: the store +loads the preview of *every* listed plugin, so a free choice of host would let any +listed author collect the IP address of everyone who opens the store. + +Keep the file small, a few hundred KB is plenty. It is fetched on every store view. + +### `showDownloads` - opt in download counter + +```json +{ "showDownloads": true } +``` + +Off unless you switch it on. Set it in a plugin's `package.json` for that plugin, or +once in your store repo's root `package.json` to cover every plugin in the store. A +plugin's own value wins over the store's, in both directions, so a store can enable +the counter for everything and a single plugin can still opt back out with +`"showDownloads": false`. + +The number itself comes from the GitHub release asset download count that the +registry collects daily, not from anything the client reports. Nothing is sent from +a user's machine either way. + ## Files | File | Written by | What it is | | -------------------- | ---------- | -------------------------------------------------------------------------------- | | `stores.json` | humans | The list. Source of truth, reviewed in pull requests. | -| `stores.schema.json` | humans | Shape of `stores.json`, for editor completion. CI checks the same rules itself. | +| `stores.schema.json` | humans | Schema for `stores.json`, enforced in CI. | | `blocklist.json` | maintainers| Kill switch. Glob patterns, matching stores disappear from every client. | +| `registry.json` | CI | `stores.json` plus stars, repo health and per plugin downloads. This is what the client reads. | + +`registry.json` is regenerated daily and on every change to `stores.json` by +`.github/workflows/registry.yml`. Do not edit it by hand, it will be overwritten. +Run it locally with `GITHUB_TOKEN=... node store/generate.mjs`. ## What this is not diff --git a/store/generate.mjs b/store/generate.mjs new file mode 100644 index 00000000..6c1fa770 --- /dev/null +++ b/store/generate.mjs @@ -0,0 +1,96 @@ +// Builds store/registry.json from store/stores.json. +// Adds stars, per plugin download counts and repo health so the client gets all of it +// in a single raw.githubusercontent.com fetch instead of hammering the GitHub API itself. +// Run by .github/workflows/registry.yml, or locally with GITHUB_TOKEN set. +import { readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +const root = join(import.meta.dirname, ".."); +const token = process.env.GITHUB_TOKEN; +if (!token) console.warn("No GITHUB_TOKEN set, running against the 60 per hour anonymous limit"); + +const api = async (path) => { + const res = await fetch(`https://api.github.com${path}`, { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}`); + return res.json(); +}; + +// store.json lists "Song Downloader.mjs", the release asset is called "Song.Downloader.mjs" +const assetName = (plugin) => plugin.replaceAll(" ", "."); +const tagOf = (url) => url.split("/releases/download/")[1]?.split("/")[0]; + +const stores = JSON.parse(readFileSync(join(root, "store/stores.json"), "utf8")); +const out = []; +let warnings = 0; + +for (const store of stores.stores) { + if ((store.status ?? "active") === "removed") { + out.push({ name: store.name, repo: store.repo, url: store.url, status: "removed", reason: store.reason }); + continue; + } + + const entry = { ...store, status: "active", health: "ok" }; + + try { + // Follows renames, so a repo that moved still resolves and reports its current slug + const repo = await api(`/repos/${store.repo}`); + entry.stars = repo.stargazers_count; + entry.pushedAt = repo.pushed_at; + if (repo.archived) entry.health = "archived"; + if (repo.full_name !== store.repo) { + console.warn(`${store.repo} is now ${repo.full_name}, update store/stores.json`); + entry.repo = repo.full_name; + warnings++; + } + } catch (err) { + console.warn(`${store.repo} repo lookup failed: ${err.message}`); + entry.health = "unreachable"; + warnings++; + } + + try { + const storeJson = await fetch(store.url).then((res) => { + if (!res.ok) throw new Error(`store.json returned HTTP ${res.status}`); + return res.json(); + }); + const plugins = storeJson.plugins ?? []; + entry.pluginCount = plugins.length; + + const release = await api(`/repos/${entry.repo}/releases/tags/${tagOf(store.url)}`); + const counts = new Map(release.assets.map((asset) => [asset.name, asset.download_count])); + // Oldest asset creation date, the counters reset whenever a release is recreated + const created = release.assets.map((asset) => asset.created_at).sort(); + if (created.length > 0) entry.assetsSince = created[0]; + + const downloads = {}; + for (const plugin of plugins) { + const count = counts.get(assetName(plugin)); + if (count !== undefined) downloads[plugin] = count; + } + if (Object.keys(downloads).length > 0) entry.downloads = downloads; + } catch (err) { + console.warn(`${store.repo} release lookup failed: ${err.message}`); + if (entry.health === "ok") entry.health = "unreachable"; + warnings++; + } + + out.push(entry); +} + +const registry = { + version: 1, + generatedAt: new Date().toISOString().replace(/\.\d+Z$/, "Z"), + stores: out, +}; +writeFileSync(join(root, "store/registry.json"), JSON.stringify(registry) + "\n"); + +const active = out.filter((store) => store.status === "active"); +console.log( + `Wrote ${out.length} stores (${active.length} active, ${active.reduce((n, store) => n + (store.pluginCount ?? 0), 0)} plugins), ${warnings} warnings`, +); From 5252771f3c1f9df72e302411d9c9414dacbd5a8b Mon Sep 17 00:00:00 2001 From: Victor Boiting Date: Fri, 4 Sep 2026 16:04:27 +0200 Subject: [PATCH 5/7] Rebuild the settings page on Tidal's own design tokens Reads Tidal's wave custom properties rather than approximating them, so the page follows along when Tidal retunes its palette. One module defines every surface, border, radius and shadow. Icons come from Tidal's own sprite. It ships no icon font and no third party pack, it mounts a sprite of ~108 symbols under body and draws its interface from it with use href, so Luna draws from the same one and @mui/icons-material leaves the tree. Six icons the sprite lacks are drawn at the weight the sprite itself uses. The symbols are not drawn to a common margin, ink runs from 50% to 100% of the box, so each carries a crop normalising it to the sprite's own 90% median. Installed plugins are grouped into errors, enabled and disabled and move between groups as they are toggled. Adding a store moved from a section at the bottom of the store tab onto a plus beside the search. A plugin can set image in its package.json and the store card shows it, click to enlarge. Urls are limited to GitHub hosts: the store renders a card for every plugin of every listed store, so an unrestricted host would let any listed author log the IP of everyone who opens the tab. An author avatar goes through the same check for the same reason. LunaPlugin.refreshPackage comes with it, since fromStorage returns an instance created earlier in the session without refetching, so a card pinned whatever the package looked like when it was first rendered. PluginStoreTab/InstallFromUrl.tsx is removed as a near duplicate of the one in Storage.tsx, which is the copy that has the .css branch this one's own label promised. LiveReloadToggle.tsx and LunaPluginHeader.tsx go too, both orphaned by the rewrite. --- README.md | 2 + .../lib/src/classes/MediaItem/MediaItem.ts | 8 +- plugins/lib/src/helpers/index.ts | 1 + plugins/lib/src/helpers/listenerError.ts | 22 ++ .../PluginStoreTab/InstallFromUrl.tsx | 85 ------ .../SettingsPage/PluginStoreTab/LunaStore.tsx | 170 ++++++++---- .../PluginStoreTab/LunaStorePlugin.tsx | 201 ++++++++++---- .../src/SettingsPage/PluginStoreTab/index.tsx | 76 ++++-- .../PluginStoreTab/previewImage.ts | 21 ++ .../PluginsTab/LiveReloadToggle.tsx | 61 ----- .../PluginsTab/LunaPluginHeader.tsx | 47 ---- .../PluginsTab/LunaPluginSettings.tsx | 224 +++++++++------- .../ui/src/SettingsPage/PluginsTab/index.tsx | 156 ++++++++++- .../SettingsTab/LunaClientUpdate.tsx | 69 +++-- .../SettingsTab/LunaFeatureFlags.tsx | 55 ++-- .../SettingsTab/LunaSettingsTransfer.tsx | 48 ++-- .../SettingsTab/LunaVersionInfo.tsx | 57 ++-- .../ui/src/SettingsPage/SettingsTab/index.tsx | 32 ++- plugins/ui/src/SettingsPage/Storage.tsx | 5 +- .../SettingsPage/SupportersTab/UserList.tsx | 54 ++-- .../src/SettingsPage/ThemesTab/LunaTheme.tsx | 77 +++--- .../ui/src/SettingsPage/ThemesTab/index.tsx | 54 ++-- plugins/ui/src/SettingsPage/index.tsx | 201 ++++++++++++-- plugins/ui/src/components/LunaButton.tsx | 2 +- plugins/ui/src/components/LunaDrawnIcons.tsx | 109 ++++++++ plugins/ui/src/components/LunaIcon.tsx | 78 ++++++ plugins/ui/src/components/LunaList.tsx | 245 ++++++++++++++++++ plugins/ui/src/components/LunaNumber.tsx | 60 +++-- .../ui/src/components/LunaPreviewImage.tsx | 182 +++++++++++++ plugins/ui/src/components/LunaSearch.tsx | 68 +++++ plugins/ui/src/components/LunaSecureText.tsx | 4 +- plugins/ui/src/components/LunaTrashButton.tsx | 5 +- plugins/ui/src/components/SpinningButton.tsx | 7 +- plugins/ui/src/components/index.ts | 5 + .../components/settings/LunaSelectSetting.tsx | 5 +- .../src/components/settings/LunaSetting.tsx | 52 +++- .../src/components/settings/LunaSettings.tsx | 15 +- .../components/settings/LunaSwitchSetting.tsx | 4 +- .../components/settings/LunaTextSetting.tsx | 22 +- plugins/ui/src/lunaTheme.ts | 9 +- plugins/ui/src/tidalTokens.ts | 181 +++++++++++++ render/src/LunaPlugin.ts | 54 ++++ 42 files changed, 2161 insertions(+), 672 deletions(-) create mode 100644 plugins/lib/src/helpers/listenerError.ts delete mode 100644 plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx create mode 100644 plugins/ui/src/SettingsPage/PluginStoreTab/previewImage.ts delete mode 100644 plugins/ui/src/SettingsPage/PluginsTab/LiveReloadToggle.tsx delete mode 100644 plugins/ui/src/SettingsPage/PluginsTab/LunaPluginHeader.tsx create mode 100644 plugins/ui/src/components/LunaDrawnIcons.tsx create mode 100644 plugins/ui/src/components/LunaIcon.tsx create mode 100644 plugins/ui/src/components/LunaList.tsx create mode 100644 plugins/ui/src/components/LunaPreviewImage.tsx create mode 100644 plugins/ui/src/components/LunaSearch.tsx create mode 100644 plugins/ui/src/tidalTokens.ts diff --git a/README.md b/README.md index 19a752dd..69b7762e 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ environment.systemPackages = with pkgs; [ Proper developer documentation etc is planned after the inital beta release of Luna. If you are a developer or want to try making your own plugin, please hop in discord and ask we are more than happy to assist with getting started. +Got a plugin store to share? See [store/README.md](./store/README.md), it is a one entry pull request and no client release. + ### Client Dev To develop for the luna client follow these steps: diff --git a/plugins/lib/src/classes/MediaItem/MediaItem.ts b/plugins/lib/src/classes/MediaItem/MediaItem.ts index 65aefee0..2c9c914e 100644 --- a/plugins/lib/src/classes/MediaItem/MediaItem.ts +++ b/plugins/lib/src/classes/MediaItem/MediaItem.ts @@ -1,4 +1,6 @@ import { asyncDebounce, memoize, memoizeArgless, registerEmitter, type AddReceiver, type Emit } from "@inrixia/helpers"; + +import { listenerError } from "../../helpers/listenerError"; import type { IRecording, ITrack } from "musicbrainz-api"; import { ftch, ReactiveStore, type LunaUnload, type LunaUnloads, type Tracer } from "@luna/core"; @@ -117,7 +119,7 @@ export class MediaItem extends ContentBase { if (item?.productId === undefined) return MediaItem.trace.warn("player/PRELOAD_ITEM intercepted without productId!", item); const mediaItem = await this.fromId(item.productId, item.productType); if (mediaItem === undefined) return; - emit(mediaItem, mediaItem.trace.err.withContext("preloadItem.runListeners")); + emit(mediaItem, listenerError(mediaItem.trace, "preloadItem.runListeners")); }), ); /** Triggered on "playbackControls/MEDIA_PRODUCT_TRANSITION"*/ @@ -134,7 +136,7 @@ export class MediaItem extends ContentBase { this._supportsSpatialAudio = playbackContext.actualAudioMode !== "STEREO"; } - await emit(mediaItem, mediaItem.trace.err.withContext("mediaProductTransition.runListeners")); + await emit(mediaItem, listenerError(mediaItem.trace, "mediaProductTransition.runListeners")); }), ), ); @@ -150,7 +152,7 @@ export class MediaItem extends ContentBase { async ({ mediaProduct: { productId, productType } }: redux.InterceptPayload<"playbackControls/PREFILL_MEDIA_PRODUCT_TRANSITION">) => { const mediaItem = await this.fromId(productId, productType); if (mediaItem === undefined) return; - await emit(mediaItem, mediaItem.trace.err.withContext("prefillMPT.runListeners")); + await emit(mediaItem, listenerError(mediaItem.trace, "prefillMPT.runListeners")); }, ), ), diff --git a/plugins/lib/src/helpers/index.ts b/plugins/lib/src/helpers/index.ts index e6e2642a..c2f294d9 100644 --- a/plugins/lib/src/helpers/index.ts +++ b/plugins/lib/src/helpers/index.ts @@ -2,6 +2,7 @@ export * from "./downloadObject"; export * from "./getCredentials"; export * from "./getPlaybackInfo"; export * from "./getPlaybackInfo.dasha.native"; +export * from "./listenerError"; export * from "./observable"; export * from "./parseDate"; export * from "./safeTimeout"; diff --git a/plugins/lib/src/helpers/listenerError.ts b/plugins/lib/src/helpers/listenerError.ts new file mode 100644 index 00000000..46c84d9d --- /dev/null +++ b/plugins/lib/src/helpers/listenerError.ts @@ -0,0 +1,22 @@ +import { LunaPlugin, type Tracer } from "@luna/core"; + +/** + * Build an onError handler for an event emitter. + * + * registerEmitter hands the same onError to every listener and never says which one threw, so a + * plugin's broken listener was recorded against whichever module owns the emitter. The stack still + * names the bundle that threw, so attribute by that and fall back to the emitter when it does not. + */ +export const listenerError = + (trace: Tracer, context: string) => + (err: unknown): void => { + const message = (err)?.message ?? String(err); + const culprit = LunaPlugin.fromStack((err)?.stack); + if (culprit !== undefined) { + // A runtime fault in someone's listener, not a failure to load + culprit.reportRuntimeError(`${context}: ${message}`); + console.error(`[${culprit.name}] ${context}:`, err); + return; + } + trace.err.withContext(context)(err); + }; diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx b/plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx deleted file mode 100644 index a90e2ef8..00000000 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from "react"; - -import TextField from "@mui/material/TextField"; - -import { debounce } from "@inrixia/helpers"; -import { LunaPlugin } from "@luna/core"; - -import { Messager } from "@luna/core"; -import { addToStores } from "./registry"; - -const successSx = { - "& .MuiOutlinedInput-root:hover:not(.Mui-focused) .MuiOutlinedInput-notchedOutline": { - borderColor: "success.main", // Or a slightly different shade if desired - }, - "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": { - borderColor: "success.main", - }, - "& .MuiInputLabel-root.Mui-focused": { - color: "success.main", - }, - "& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": { - borderColor: "success.main", - }, - "& .MuiInputLabel-root": { - color: "success.main", - }, -}; - -export const InstallFromUrl = React.memo(() => { - const [success, setSuccess] = React.useState(null); - const [err, setErr] = React.useState(null); - const [value, setValue] = React.useState(""); - - // Define the core logic for loading the plugin - const loadPlugin = React.useCallback(async (urlValue: string) => { - if (urlValue === "") return; - try { - // Sanity check url - const url = new URL(urlValue).href; - let successMessage; - if (url.endsWith("/store.json")) { - if (addToStores(url) === false) return setErr("Store already added"); - successMessage = `Added store ${url}!`; - } else { - const plugin = await LunaPlugin.fromStorage({ url }); - successMessage = `Loaded plugin ${plugin.name}!`; - } - setValue(""); // Reset input on success - setErr(null); - setSuccess(successMessage); - // TODO: Clean up this mess - Messager.Info(successMessage); - // Clear success message after a delay - setTimeout(() => setSuccess(null), 2000); - } catch (e: any) { - setErr(e.message ?? "Invalid URL or failed to load plugin"); - setSuccess(null); // Clear success on error - } - }, []); // Dependencies: none, as it uses the passed urlValue - - // Create a stable debounced function using useMemo - const debouncedLoad = React.useMemo( - () => debounce(loadPlugin, 500), - [loadPlugin], // Recreate debounce if loadPlugin changes (it shouldn't due to useCallback) - ); - - const onInput = React.useCallback((event: React.ChangeEvent) => { - if (event.target.value === "") setErr(null); - setValue(event.target.value); - debouncedLoad(event.target.value); - }, []); - - return ( - - ); -}); diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx index 03221da1..1e1765a0 100644 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx @@ -2,17 +2,24 @@ import React from "react"; import { type PluginPackage } from "@luna/core"; -import Grid from "@mui/material/Grid"; +import Box from "@mui/material/Box"; +import IconButton from "@mui/material/IconButton"; import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; -import { LunaTrashButton, SpinningButton } from "../../components"; -import { LunaPluginHeader } from "../PluginsTab/LunaPluginHeader"; +import { RefreshIcon } from "../../components/LunaDrawnIcons"; +import { LunaIcon, icons } from "../../components/LunaIcon"; +import { LunaBadge, LunaGroup, LunaRow, LunaSection } from "../../components/LunaList"; +import { iconBtnSx, metaSx, oneLineSx, sectionSx, wave } from "../../tidalTokens"; import { LunaStorePlugin } from "./LunaStorePlugin"; -import type { RegistryStore } from "./registry"; +import { DEV_STORE_URL } from "./index"; +import { metricsAreFresh, type RegistryStore } from "./registry"; interface StorePackage extends PluginPackage { plugins: string[]; + /** Store wide download opt in, set once in the store repo's package.json */ + showDownloads?: boolean; } interface LunaStoreProps { @@ -27,8 +34,8 @@ export const LunaStore = React.memo(({ url, onRemove, searchQuery, entry }: Luna const [loading, setLoading] = React.useState(false); const [loadError, setLoadError] = React.useState(undefined); const [pkg, setPackage] = React.useState(undefined); - - const disabled = loading; // Disable controls while loading + // Incremented on an explicit reload so the cards refetch their own metadata too + const [refreshToken, setRefreshToken] = React.useState(0); const fetchPackage = React.useCallback(async () => { setLoading(true); @@ -36,12 +43,13 @@ export const LunaStore = React.memo(({ url, onRemove, searchQuery, entry }: Luna try { const response = await fetch(`${url}/store.json`); if (!response.ok) throw new Error(`Failed to fetch package: ${response.statusText}`); - const data = await response.json(); - setPackage(data); + setPackage(await response.json()); } catch (error: any) { - console.error("Error fetching package:", error); + // The local dev store is absent unless someone is building a plugin, so its failure is + // expected and must not spam the console on every start + if (url !== DEV_STORE_URL) console.error(`[PluginStore] Could not load ${url}:`, error); setLoadError(error.message || "Unknown error occurred"); - setPackage(undefined); // Clear package on error + setPackage(undefined); } finally { setLoading(false); } @@ -49,59 +57,117 @@ export const LunaStore = React.memo(({ url, onRemove, searchQuery, entry }: Luna React.useEffect(() => { fetchPackage(); - }, [fetchPackage]); // Depend on the memoized fetch function + }, [fetchPackage]); - const isLocalDevStore = url === "http://127.0.0.1:3000"; + const isLocalDevStore = url === DEV_STORE_URL; - if (pkg === undefined && !loading && !loadError) return null; // Don't render anything until initial load attempt - if (!isLocalDevStore && loading && !pkg) return Loading store {url}...; // Show loading indicator if still loading initially + if (pkg === undefined && !loading && !loadError) return null; + // The local dev store is absent for everyone who is not building a plugin, never shout about it + if (isLocalDevStore && (loadError || (!pkg && !loading))) return null; // The registry knows the name even when the store itself is unreachable - let name = pkg?.name ?? entry?.name ?? "Unknown Store"; - if (isLocalDevStore) name = `${name} [DEV]`; - - const author = pkg?.author; - const desc = pkg?.description; - + const name = `${pkg?.name ?? entry?.name ?? "Unknown Store"}${isLocalDevStore ? " [DEV]" : ""}`; const link = pkg?.homepage ?? pkg?.repository?.url ?? url; - // Don't show error for local dev store - if (loadError && isLocalDevStore) return null; + // A stale registry means wrong numbers, and a wrong number is worse than none + const metrics = metricsAreFresh() ? entry : undefined; // Stores are third party json, a malformed plugins array must not take the whole settings page down const plugins = Array.isArray(pkg?.plugins) ? pkg.plugins.filter((plugin) => typeof plugin === "string") : undefined; - const query = searchQuery.toLowerCase(); - const filteredPlugins = query ? plugins?.filter((plugin) => plugin.toLowerCase().includes(query)) : plugins; - if (query && (!filteredPlugins || filteredPlugins.length === 0)) return null; + const query = searchQuery.trim().toLowerCase(); + const filtered = query ? plugins?.filter((plugin) => plugin.toLowerCase().includes(query)) : plugins; + if (query && !filtered?.length) return null; return ( - + + + + {metrics?.stars ? ( + + ) : null} + {metrics?.health === "archived" && } + {metrics?.health === "unreachable" && } + {filtered !== undefined && ( + + )} + + } + desc={pkg?.description ?? undefined} + trailing={ + + + + { + setRefreshToken((n) => n + 1); + fetchPackage(); + }} + sx={iconBtnSx} + children={} + /> + + + + + } + /> + + + + } > - - - - - } - /> - - {filteredPlugins?.map((plugin) => ( - } /> - ))} - - + {loadError ? ( + + } + title="This store could not be loaded" + desc={} + /> + + ) : ( + + {filtered?.map((plugin) => ( + + ))} + + )} + ); }); diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx index 5a63e770..94e730d6 100644 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx @@ -1,69 +1,180 @@ import { LunaPlugin } from "@luna/core"; +import { store as obyStore } from "oby"; import React, { useEffect, useState } from "react"; -import { LunaPluginHeader } from "../PluginsTab/LunaPluginHeader"; - -import ButtonBase from "@mui/material/ButtonBase"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; -export const LunaStorePlugin = React.memo(({ url }: { url: string }) => { +import { LunaIcon, flipVertical, icons } from "../../components/LunaIcon"; +import { LunaPreviewImage } from "../../components/LunaPreviewImage"; +import { buttonSx, clampSx, descSx, glassHoverSx, glassSx, metaSx, titleSx, wave } from "../../tidalTokens"; +import { previewImageUrl, showDownloadsFor } from "./previewImage"; + +const authorName = (author: unknown): string | undefined => + typeof author === "string" ? author : ((author as { name?: string } | undefined)?.name ?? undefined); +const authorAvatar = (author: unknown): string | undefined => + typeof author === "string" ? undefined : ((author as { avatarUrl?: string } | undefined)?.avatarUrl ?? undefined); + +// One plugin in the store, as a card +export interface LunaStorePluginProps { + url: string; + downloads?: number; + /** Store wide download opt in, overridden by the plugin's own flag */ + storeShowDownloads?: boolean; + /** Bumped by the store's reload button to pull fresh metadata for this plugin */ + refreshToken?: number; +} + +export const LunaStorePlugin = React.memo(({ url, downloads, storeShowDownloads, refreshToken }: LunaStorePluginProps) => { const [plugin, setPlugin] = useState(undefined); const [loadError, setLoadError] = useState(undefined); - const [isHovered, setIsHovered] = useState(false); + const [installed, setInstalled] = useState(false); + const [busy, setBusy] = useState(false); + const [hovered, setHovered] = useState(false); + const [btnHover, setBtnHover] = useState(false); + // A broken or blocked image must leave the card exactly as it was without one + const [imageBroken, setImageBroken] = useState(false); useEffect(() => { - LunaPlugin.fromStorage({ url }).then(setPlugin).catch(setLoadError); + LunaPlugin.fromStorage({ url }) + .then(setPlugin) + .catch((err) => setLoadError(String(err?.message ?? err))); }, [url]); + // Reloading the store pulls fresh metadata, otherwise a card keeps whatever the package looked + // like when it was first rendered this session + useEffect(() => { + if (plugin === undefined || !refreshToken) return; + // Optional call: refreshPackage lives in core, which only updates on a full client restart + plugin.refreshPackage?.().then(() => setImageBroken(false)); + }, [plugin, refreshToken]); + + // Without this the card keeps offering Install after the plugin is already installed + useEffect(() => { + if (plugin === undefined) return; + setInstalled(plugin.installed); + return obyStore.on( + () => plugin.installed, + () => setInstalled(obyStore.unwrap(plugin.store.installed)), + ); + }, [plugin]); + if (!plugin) return null; - const version = url.startsWith("http://127.0.0.1") ? `${plugin.package?.version ?? ""} [DEV]` : plugin.package?.version; + const version = plugin.package?.version; + const author = authorName(plugin.package?.author); + // Same host allowlist as the preview image, an avatar url is third party metadata too + const avatar = previewImageUrl(authorAvatar(plugin.package?.author)); + const preview = imageBroken ? undefined : previewImageUrl(plugin.package?.image); + const showDownloads = showDownloadsFor(plugin.package?.showDownloads, storeShowDownloads); + + const toggleInstall = async () => { + setBusy(true); + try { + await (installed ? plugin.uninstall() : plugin.install()); + } finally { + setBusy(false); + } + }; return ( - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} + setHovered(true)} + onMouseLeave={() => setHovered(false)} sx={{ - position: "relative", // Needed for absolute positioning of children - borderRadius: 2, - backgroundColor: `rgba(0, 0, 0, ${!isHovered ? 0.2 : 0.5})`, - padding: 2, - paddingBottom: 1, - paddingTop: 1, - boxShadow: 5, - transition: "opacity 0.3s ease-in-out", - opacity: plugin.installed && !isHovered ? 0.5 : 1, // Keep installed opacity logic + fontFamily: wave.font, + display: "flex", + flexDirection: "column", + gap: 1, + height: "100%", + // No padding on the card itself: a preview has to reach the edges. The padding moves + // onto the content wrapper below. + padding: 0, overflow: "hidden", - width: "100%", + borderRadius: wave.radius, + ...glassSx, + ...(hovered ? glassHoverSx : null), + // Installed gets two cues of two kinds: an accent bloom off the left edge, and a button + // that names the action. No badge or coloured border stacked on top of those. + backgroundImage: installed + ? `linear-gradient(100deg, color-mix(in srgb, ${wave.accent} 16%, transparent) 0%, transparent 58%)` + : "none", + transition: "background-color .15s ease", }} - style={{ textAlign: "left" }} - onClick={() => (plugin.installed ? plugin.uninstall() : plugin.install())} > - + {preview !== undefined && setImageBroken(true)} />} + + + {loadError !== undefined && } + + + + {version && } + + - + + + {author && ( + + {avatar && } + + + )} + + {showDownloads && downloads !== undefined && downloads > 0 && ( + + + + + + + )} + - - ); - } - return this.props.children; + if (this.state.error === undefined) return this.props.children; + return ( + + + +