Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ const externals = ["@luna/*", "oby", "react", "react-dom/client", "react/jsx-run
export const pluginBuildOptions = async (pluginPath: string, opts?: BuildOptions) => {
const pkgPath = path.join(pluginPath, "package.json");
const pluginPackage = await readFile(pkgPath, "utf8").then(JSON.parse);
// #region Luna Dependency Externals
const lunaDependencyModules = (pluginPackage.luna?.dependencies ?? [])
.map((dependency: { name?: string } | string) => (typeof dependency === "string" ? dependency : dependency?.name))
.filter((dependencyName: unknown): dependencyName is string => typeof dependencyName === "string" && dependencyName.length > 0);
// #endregion
// Sanitize pluginPackage.name, remove @, replace / with .
const pkgName = pluginPackage.name;
const safeName = pkgName.replace(/@/g, "").replace(/\//g, ".");
Expand All @@ -39,7 +44,9 @@ export const pluginBuildOptions = async (pluginPath: string, opts?: BuildOptions
outfile: `./dist/${safeName}.mjs`,
entryPoints: ["./" + entryPoint],
...opts,
external: [...(opts?.external ?? []), ...externals],
// #region Externals
external: [...new Set([...(opts?.external ?? []), ...externals, ...lunaDependencyModules])],
// #endregion
plugins: [
...(opts?.plugins ?? []),
dynamicExternalsPlugin({
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "luna",
"version": "1.12.4-beta",
"version": "1.12.5-beta",
"description": "A client mod for the Tidal music app for plugins",
"author": {
"name": "Inrixia",
Expand Down
3 changes: 3 additions & 0 deletions plugins/dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"url": "https://github.com/Inrixia",
"avatarUrl": "https://2.gravatar.com/avatar/eeaffef9eb9b436dccc58c6c44c9fe8c3528e83e3bf64e1c736a68dbe8c097d3"
},
"luna": {
"type": "library"
},
"exports": "./src/index.ts",
"dependencies": {
"@mui/material": "^7.3.2"
Expand Down
3 changes: 3 additions & 0 deletions plugins/lib.native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"url": "https://github.com/Inrixia",
"avatarUrl": "https://2.gravatar.com/avatar/eeaffef9eb9b436dccc58c6c44c9fe8c3528e83e3bf64e1c736a68dbe8c097d3"
},
"luna": {
"type": "library"
},
"exports": "./src/index.native.ts",
"dependencies": {
"flac-stream-tagger": "^1.0.10"
Expand Down
3 changes: 3 additions & 0 deletions plugins/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"url": "https://github.com/Inrixia",
"avatarUrl": "https://2.gravatar.com/avatar/eeaffef9eb9b436dccc58c6c44c9fe8c3528e83e3bf64e1c736a68dbe8c097d3"
},
"luna": {
"type": "library"
},
"exports": "./src/index.ts",
"dependencies": {
"dasha": "3.1.9",
Expand Down
3 changes: 3 additions & 0 deletions plugins/linux/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
"url": "https://github.com/Inrixia",
"avatarUrl": "https://2.gravatar.com/avatar/eeaffef9eb9b436dccc58c6c44c9fe8c3528e83e3bf64e1c736a68dbe8c097d3"
},
"luna": {
"type": "library"
},
"exports": "./src/index.ts"
}
3 changes: 3 additions & 0 deletions plugins/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"url": "https://github.com/Inrixia",
"avatarUrl": "https://2.gravatar.com/avatar/eeaffef9eb9b436dccc58c6c44c9fe8c3528e83e3bf64e1c736a68dbe8c097d3"
},
"luna": {
"type": "library"
},
"exports": {
".": {
"types": "./src/index.tsx",
Expand Down
6 changes: 4 additions & 2 deletions plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { debounce } from "@inrixia/helpers";
import { LunaPlugin } from "@luna/core";

import { Messager } from "@luna/core";
import { addToStores } from ".";
import { addToStores } from "./storeState";
import { installPluginWithLibraries } from "./pluginInstall";

const successSx = {
"& .MuiOutlinedInput-root:hover:not(.Mui-focused) .MuiOutlinedInput-notchedOutline": {
Expand Down Expand Up @@ -43,7 +44,8 @@ export const InstallFromUrl = React.memo(() => {
successMessage = `Added store ${url}!`;
} else {
const plugin = await LunaPlugin.fromStorage({ url });
successMessage = `Loaded plugin ${plugin.name}!`;
if (!(await installPluginWithLibraries(plugin))) return;
successMessage = `Installed plugin ${plugin.name}!`;
}
setValue(""); // Reset input on success
setErr(null);
Expand Down
12 changes: 11 additions & 1 deletion plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LunaPluginHeader } from "../PluginsTab/LunaPluginHeader";

import ButtonBase from "@mui/material/ButtonBase";
import Typography from "@mui/material/Typography";
import { installPluginWithLibraries, uninstallPluginWithDependenciesCheck } from "./pluginInstall";

export const LunaStorePlugin = React.memo(({ url }: { url: string }) => {
const [plugin, setPlugin] = useState<LunaPlugin | undefined>(undefined);
Expand All @@ -18,6 +19,13 @@ export const LunaStorePlugin = React.memo(({ url }: { url: string }) => {

if (!plugin) return null;

const dependsOn = plugin.dependencyRequirements.map((dependency) => dependency.name);

const onInstallToggle = async () => {
if (plugin.installed) await uninstallPluginWithDependenciesCheck(plugin);
else await installPluginWithLibraries(plugin);
};

const version = url.startsWith("http://127.0.0.1") ? `${plugin.package?.version ?? ""} [DEV]` : plugin.package?.version;

return (
Expand All @@ -38,7 +46,7 @@ export const LunaStorePlugin = React.memo(({ url }: { url: string }) => {
width: "100%",
}}
style={{ textAlign: "left" }}
onClick={() => (plugin.installed ? plugin.uninstall() : plugin.install())}
onClick={onInstallToggle}
>
<LunaPluginHeader
sx={{ transition: "opacity 0.3s ease-in-out", opacity: isHovered ? 0.2 : 1, width: "100%" }}
Expand All @@ -47,6 +55,8 @@ export const LunaStorePlugin = React.memo(({ url }: { url: string }) => {
loadError={loadError}
author={plugin.package?.author}
desc={plugin.package?.description}
isLibrary={plugin.isLibrary}
dependsOn={dependsOn}
/>
<Typography
sx={{
Expand Down
11 changes: 2 additions & 9 deletions plugins/ui/src/SettingsPage/PluginStoreTab/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,14 @@ import React, { useCallback, useEffect, useState } from "react";

import { store as obyStore } from "oby";

import { ReactiveStore } from "@luna/core";

import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";

import { InstallFromUrl } from "./InstallFromUrl";
import { LunaStore } from "./LunaStore";
import { addToStores, storeUrls } from "./storeState";

const pluginStores = ReactiveStore.getStore("@luna/pluginStores");
export const storeUrls = await pluginStores.getReactive<string[]>("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 { addToStores, storeUrls } from "./storeState";

// Devs! Add your stores here <3
// TODO: Abstract this to a git repo
Expand Down
199 changes: 199 additions & 0 deletions plugins/ui/src/SettingsPage/PluginStoreTab/pluginInstall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { LunaPlugin, Tracer, type LunaPackageDependency } from "@luna/core";

import { confirm } from "../../helpers/confirm";
import { addToStores, storeUrls } from "./storeState";

type StorePackage = {
plugins: string[];
};

// #region Trace
const trace = Tracer("[@luna/ui][PluginInstall]", null).trace;
// #endregion

// #region URL Helpers
const normalizeStoreUrl = (storeUrl: string) => storeUrl.replace(/\/store\.json$/i, "");
const normalizePluginUrl = (pluginUrl: string) => pluginUrl.replace(/(\.mjs|\.json|\.mjs\.map)$/i, "");
const isStoreAdded = (storeUrl: string) => storeUrls.includes(normalizeStoreUrl(storeUrl));
const isBuiltInDevStore = (storeUrl: string) => /^https?:\/\/(127\.0\.0\.1|localhost):3000$/i.test(normalizeStoreUrl(storeUrl));
const isFromDevStore = (plugin: LunaPlugin) => /^https?:\/\/(127\.0\.0\.1|localhost):3000\//i.test(plugin.url);
const getDependencyStoreUrl = (dependency: LunaPackageDependency, dependantPlugin: LunaPlugin) => {
if (isFromDevStore(dependantPlugin) && dependency.devStoreUrl) return dependency.devStoreUrl;
return dependency.storeUrl;
};
// #endregion

// #region Candidate Resolution
const toPluginIdCandidates = (value: string): string[] => {
const trimmed = value.trim();
const withoutScopeAt = trimmed.replace(/^@/, "");
const safeName = trimmed.replace(/@/g, "").replace(/\//g, ".");
const dottedScope = withoutScopeAt.replace(/\//g, ".");
const atWithDots = trimmed.replace(/\//g, ".");
const spaced = trimmed.replace(/\s+/g, ".");

return [...new Set([trimmed, withoutScopeAt, safeName, dottedScope, atWithDots, spaced].filter(Boolean))];
};

const toPluginUrlCandidates = (storeUrl: string, pluginId: string): string[] => {
const normalizedStoreUrl = normalizeStoreUrl(storeUrl);
const isLocalDevStore = isBuiltInDevStore(normalizedStoreUrl);
if (/^https?:\/\//i.test(pluginId)) return [normalizePluginUrl(pluginId)];
const candidates = toPluginIdCandidates(pluginId);

if (isLocalDevStore) return candidates.map((candidate) => normalizePluginUrl(`${normalizedStoreUrl}/${candidate}`));
return candidates.map((candidate) => normalizePluginUrl(`${normalizedStoreUrl}/${candidate.replace(/\s+/g, ".")}`));
};

const resolvedPluginUrlCache = new Map<string, string>();
// #endregion

// #region Store Lookup
const getStorePluginUrls = async (storeUrl: string): Promise<string[]> => {
const normalizedStoreUrl = normalizeStoreUrl(storeUrl);
const response = await fetch(`${normalizedStoreUrl}/store.json`);
if (!response.ok) {
trace.msg.err(`Failed to fetch plugin store '${normalizedStoreUrl}': ${response.statusText}`);
return [];
}

const storePackage = (await response.json()) as StorePackage;

return [...new Set((storePackage.plugins ?? []).flatMap((pluginId) => toPluginUrlCandidates(normalizedStoreUrl, pluginId)))];
};

const tryResolveFromUrls = async (pluginUrls: string[], pluginName: string, cacheKey: string): Promise<string | undefined> => {
for (const pluginUrl of pluginUrls) {
try {
const normalizedPluginUrl = normalizePluginUrl(pluginUrl);
const pkg = await LunaPlugin.fetchPackage(normalizedPluginUrl);
if (pkg.name !== pluginName) continue;
resolvedPluginUrlCache.set(cacheKey, normalizedPluginUrl);
return normalizedPluginUrl;
} catch {
// Ignore invalid entries and continue resolving
}
}
};

const resolvePluginUrlByName = async (storeUrl: string, pluginName: string): Promise<string | undefined> => {
const normalizedStoreUrl = normalizeStoreUrl(storeUrl);
const cacheKey = `${normalizedStoreUrl}::${pluginName}`;
const cached = resolvedPluginUrlCache.get(cacheKey);
if (cached) return cached;

const pluginUrls = await getStorePluginUrls(normalizedStoreUrl);
const resolvedFromStore = await tryResolveFromUrls(pluginUrls, pluginName, cacheKey);
if (resolvedFromStore) return resolvedFromStore;

const directCandidates = toPluginUrlCandidates(normalizedStoreUrl, pluginName);
const resolvedFromDirect = await tryResolveFromUrls(directCandidates, pluginName, cacheKey);
if (resolvedFromDirect) return resolvedFromDirect;

trace.msg.err(`Dependency resolution candidates for '${pluginName}':`, [...new Set([...pluginUrls, ...directCandidates])]);
trace.msg.err(`Failed to resolve dependency '${pluginName}' from store '${normalizedStoreUrl}'.`);
return;
};
// #endregion

// #region User Prompts
const ensureStoreForDependency = async ({ name, storeUrl }: LunaPackageDependency, dependantName: string) => {
const normalizedStoreUrl = normalizeStoreUrl(storeUrl);
if (isBuiltInDevStore(normalizedStoreUrl)) return true;
if (isStoreAdded(normalizedStoreUrl)) return true;

try {
await confirm({
title: "Add dependency store?",
description: `Plugin '${dependantName}' requires library '${name}' from '${normalizedStoreUrl}'. Add this store now?`,
confirmationText: "Add store",
});
} catch {
trace.msg.err(`Install cancelled: '${dependantName}' needs '${name}', but its plugin store was not added.`);
return false;
}

addToStores(normalizedStoreUrl);
return true;
};

const confirmInstallDependency = async ({ name }: LunaPackageDependency, dependantName: string) => {
try {
await confirm({
title: "Install required library?",
description: `Plugin '${dependantName}' depends on library plugin '${name}'. Install it now?`,
confirmationText: "Install library",
});
} catch {
trace.msg.err(`Install cancelled: '${dependantName}' requires library '${name}'.`);
return false;
}
return true;
};
// #endregion

// #region Install Helpers
const installDependency = async (dependency: LunaPackageDependency, dependantPlugin: LunaPlugin, visited: Set<string>) => {
const dependencyStoreUrl = getDependencyStoreUrl(dependency, dependantPlugin);
const dependencyWithStore = { ...dependency, storeUrl: dependencyStoreUrl };
const existingDependency = LunaPlugin.getByName(dependency.name);
if (existingDependency?.installed) return existingDependency;
if (existingDependency !== undefined) {
if (!(await confirmInstallDependency(dependencyWithStore, dependantPlugin.name))) return;
const didInstallExisting = await installPluginWithLibraries(existingDependency, visited);
if (didInstallExisting && existingDependency.installed) return existingDependency;
}

if (!(await ensureStoreForDependency(dependencyWithStore, dependantPlugin.name))) return;
if (!(await confirmInstallDependency(dependencyWithStore, dependantPlugin.name))) return;

const dependencyUrl = await resolvePluginUrlByName(dependencyWithStore.storeUrl, dependencyWithStore.name);
if (dependencyUrl === undefined) {
trace.msg.err(`Could not find dependency '${dependency.name}' in store '${normalizeStoreUrl(dependencyWithStore.storeUrl)}'.`);
return;
}

const dependencyPlugin = await LunaPlugin.fromStorage({ url: dependencyUrl });
const didInstall = await installPluginWithLibraries(dependencyPlugin, visited);
if (!didInstall) return;
return dependencyPlugin;
};
// #endregion

// #region Public API
export const installPluginWithLibraries = async (plugin: LunaPlugin, visited = new Set<string>()) => {
if (visited.has(plugin.name)) return true;
visited.add(plugin.name);

try {
const dependencyRequirements = plugin.dependencyRequirements;
for (const dependency of dependencyRequirements) {
const dependencyPlugin = await installDependency(dependency, plugin, visited);
if (dependencyPlugin === undefined || !dependencyPlugin.installed) {
trace.msg.err(`Skipping install of '${plugin.name}' because dependency '${dependency.name}' is unavailable.`);
return false;
}
}

if (!plugin.installed) {
await plugin.install();
if (plugin.isLibrary) trace.msg.log(`Installed library plugin ${plugin.name}.`);
}

return plugin.installed;
} catch (err) {
trace.msg.err.withContext(`Failed to install plugin '${plugin.name}'`)(err);
return false;
}
};

export const uninstallPluginWithDependenciesCheck = async (plugin: LunaPlugin) => {
try {
await plugin.uninstall();
return !plugin.installed;
} catch (err) {
trace.msg.err.withContext(`Failed to uninstall plugin '${plugin.name}'`)(err);
return false;
}
};
// #endregion
10 changes: 10 additions & 0 deletions plugins/ui/src/SettingsPage/PluginStoreTab/storeState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ReactiveStore } from "@luna/core";

const pluginStores = ReactiveStore.getStore("@luna/pluginStores");
export const storeUrls = await pluginStores.getReactive<string[]>("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);
};
10 changes: 9 additions & 1 deletion plugins/ui/src/SettingsPage/PluginsTab/LunaPluginHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ export interface LunaPluginComponentProps extends PropsWithChildren {
loadError?: string;
author?: LunaAuthor | string;
desc?: ReactNode;
isLibrary?: boolean;
dependsOn?: string[];
sx?: BoxProps["sx"];
}
export const LunaPluginHeader = React.memo(({ name, version, loadError, author, desc, children, sx, link }: LunaPluginComponentProps) => (
export const LunaPluginHeader = React.memo(({ name, version, loadError, author, desc, isLibrary, dependsOn, children, sx, link }: LunaPluginComponentProps) => (
<Box sx={sx}>
<Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="h6">
Expand All @@ -43,5 +45,11 @@ export const LunaPluginHeader = React.memo(({ name, version, loadError, author,
{author && <LunaAuthorDisplay author={author} />}
</Stack>
{desc && <Typography variant="subtitle2" gutterBottom dangerouslySetInnerHTML={{ __html: desc }} />}
{(isLibrary || (dependsOn?.length ?? 0) > 0) && (
<Stack direction="row" spacing={1} alignItems="center" sx={{ opacity: 0.8 }}>
{isLibrary && <Typography variant="caption">Library</Typography>}
{(dependsOn?.length ?? 0) > 0 && <Typography variant="caption">Depends on: {dependsOn?.join(", ")}</Typography>}
</Stack>
)}
</Box>
));
Loading