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 new file mode 100644 index 00000000..aad49025 --- /dev/null +++ b/.github/workflows/validateStores.yml @@ -0,0 +1,33 @@ +name: "Validate Stores" + +on: + pull_request: + paths: + - "store/**" + push: + 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 +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/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/package.json b/package.json index dfd004a9..402e7858 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "luna", - "version": "1.16.6-beta", + "version": "1.17.1-beta", "description": "A client mod for the Tidal music app for plugins", "author": { "name": "Inrixia", 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 5350330a..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 "."; - -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 0676fd63..1e1765a0 100644 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStore.tsx @@ -2,24 +2,40 @@ 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 { 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; } -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); - - 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); @@ -27,12 +43,13 @@ export const LunaStore = React.memo(({ url, onRemove, searchQuery }: { url: stri 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); } @@ -40,56 +57,117 @@ export const LunaStore = React.memo(({ url, onRemove, searchQuery }: { url: stri React.useEffect(() => { fetchPackage(); - }, [fetchPackage]); // Depend on the memoized fetch function - - const isLocalDevStore = url === "http://127.0.0.1:3000"; - - 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 + }, [fetchPackage]); - let name = pkg?.name ?? "Unknown Store"; - if (isLocalDevStore) name = `${name} [DEV]`; + const isLocalDevStore = url === DEV_STORE_URL; - const author = pkg?.author; - const desc = pkg?.description; + 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 + 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; - const query = searchQuery.toLowerCase(); - const filteredPlugins = query ? pkg?.plugins.filter((plugin) => plugin.toLowerCase().includes(query)) : pkg?.plugins; - if (query && (!filteredPlugins || filteredPlugins.length === 0)) 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.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 ( + + + +