From 2a2852d673af7baaf11cca52f244c2fa8d94ce31 Mon Sep 17 00:00:00 2001 From: Jonas W Date: Sun, 1 Mar 2026 18:58:37 +0100 Subject: [PATCH 1/2] Add library support The Plugin Manager now supports depending on librarys. Library plugins get automatically installed when a plugin depends on them. Plugins now have the (optional) field added to the package.json: luna: { type: "plugin OR library", dependencies: [ { "name":"" // package name of the library plugin "storeUrl":"" // url of the store the plugin can be downloaded from, in case the store is not present already "devStoreUrl":"" // Store the library plugin is downloaded when from loading a plugin via the localhost dev store. ] } Library plugins only need the type field. Normal plugins need the rest. If a plugin does not need to depend on others, adding the luna field is optional. If a package name is added under the luna dependencies, the compiler will now automatically treat it as external and won't compile it into the plugin. --- build/index.ts | 9 +- plugins/dev/package.json | 3 + plugins/lib.native/package.json | 3 + plugins/lib/package.json | 3 + plugins/linux/package.json | 3 + plugins/ui/package.json | 3 + .../PluginStoreTab/InstallFromUrl.tsx | 6 +- .../PluginStoreTab/LunaStorePlugin.tsx | 12 +- .../src/SettingsPage/PluginStoreTab/index.tsx | 11 +- .../PluginStoreTab/pluginInstall.ts | 199 ++++++++++++++++++ .../SettingsPage/PluginStoreTab/storeState.ts | 10 + .../PluginsTab/LunaPluginHeader.tsx | 10 +- .../PluginsTab/LunaPluginSettings.tsx | 6 +- pnpm-lock.yaml | 2 + render/src/LunaPlugin.ts | 74 ++++++- 15 files changed, 338 insertions(+), 16 deletions(-) create mode 100644 plugins/ui/src/SettingsPage/PluginStoreTab/pluginInstall.ts create mode 100644 plugins/ui/src/SettingsPage/PluginStoreTab/storeState.ts diff --git a/build/index.ts b/build/index.ts index dc8f85c..ff40ca9 100644 --- a/build/index.ts +++ b/build/index.ts @@ -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, "."); @@ -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({ diff --git a/plugins/dev/package.json b/plugins/dev/package.json index 7c64d40..4938a09 100644 --- a/plugins/dev/package.json +++ b/plugins/dev/package.json @@ -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" diff --git a/plugins/lib.native/package.json b/plugins/lib.native/package.json index 2e35ead..e9a2d0d 100644 --- a/plugins/lib.native/package.json +++ b/plugins/lib.native/package.json @@ -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" diff --git a/plugins/lib/package.json b/plugins/lib/package.json index 63a3921..0f5c6c5 100644 --- a/plugins/lib/package.json +++ b/plugins/lib/package.json @@ -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", diff --git a/plugins/linux/package.json b/plugins/linux/package.json index 112b761..f62a936 100644 --- a/plugins/linux/package.json +++ b/plugins/linux/package.json @@ -6,5 +6,8 @@ "url": "https://github.com/Inrixia", "avatarUrl": "https://2.gravatar.com/avatar/eeaffef9eb9b436dccc58c6c44c9fe8c3528e83e3bf64e1c736a68dbe8c097d3" }, + "luna": { + "type": "library" + }, "exports": "./src/index.ts" } diff --git a/plugins/ui/package.json b/plugins/ui/package.json index 788ebba..8f60625 100644 --- a/plugins/ui/package.json +++ b/plugins/ui/package.json @@ -6,6 +6,9 @@ "url": "https://github.com/Inrixia", "avatarUrl": "https://2.gravatar.com/avatar/eeaffef9eb9b436dccc58c6c44c9fe8c3528e83e3bf64e1c736a68dbe8c097d3" }, + "luna": { + "type": "library" + }, "exports": { ".": { "types": "./src/index.tsx", diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx b/plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx index 5350330..b946fce 100644 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/InstallFromUrl.tsx @@ -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": { @@ -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); diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx index 5a63e77..c4ba0cd 100644 --- a/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/LunaStorePlugin.tsx @@ -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(undefined); @@ -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 ( @@ -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} > { loadError={loadError} author={plugin.package?.author} desc={plugin.package?.description} + isLibrary={plugin.isLibrary} + dependsOn={dependsOn} /> ("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 diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/pluginInstall.ts b/plugins/ui/src/SettingsPage/PluginStoreTab/pluginInstall.ts new file mode 100644 index 0000000..8e53797 --- /dev/null +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/pluginInstall.ts @@ -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(); +// #endregion + +// #region Store Lookup +const getStorePluginUrls = async (storeUrl: string): Promise => { + 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 => { + 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 => { + 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) => { + 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()) => { + 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 diff --git a/plugins/ui/src/SettingsPage/PluginStoreTab/storeState.ts b/plugins/ui/src/SettingsPage/PluginStoreTab/storeState.ts new file mode 100644 index 0000000..f06a5b5 --- /dev/null +++ b/plugins/ui/src/SettingsPage/PluginStoreTab/storeState.ts @@ -0,0 +1,10 @@ +import { ReactiveStore } from "@luna/core"; + +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); +}; diff --git a/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginHeader.tsx b/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginHeader.tsx index fa1b61d..d0952e6 100644 --- a/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginHeader.tsx +++ b/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginHeader.tsx @@ -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) => ( @@ -43,5 +45,11 @@ export const LunaPluginHeader = React.memo(({ name, version, loadError, author, {author && } {desc && } + {(isLibrary || (dependsOn?.length ?? 0) > 0) && ( + + {isLibrary && Library} + {(dependsOn?.length ?? 0) > 0 && Depends on: {dependsOn?.join(", ")}} + + )} )); diff --git a/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginSettings.tsx b/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginSettings.tsx index ee3917d..e803d5f 100644 --- a/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginSettings.tsx +++ b/plugins/ui/src/SettingsPage/PluginsTab/LunaPluginSettings.tsx @@ -12,6 +12,7 @@ import { LunaPluginHeader } from "./LunaPluginHeader"; import SettingsIcon from "@mui/icons-material/Settings"; import { grey } from "@mui/material/colors"; +import { uninstallPluginWithDependenciesCheck } from "../PluginStoreTab/pluginInstall"; export const LunaPluginSettings = React.memo(({ plugin }: { plugin: LunaPlugin }) => { // Have to wrap in function call as Settings is a functional component @@ -45,7 +46,7 @@ export const LunaPluginSettings = React.memo(({ plugin }: { plugin: LunaPlugin } // Memoize callbacks const handleReload = React.useCallback(plugin.reload.bind(plugin), [plugin]); const toggleEnabled = React.useCallback((_: unknown, checked: boolean) => (checked ? plugin.enable() : plugin.disable()), [plugin]); - const uninstall = React.useCallback(plugin.uninstall.bind(plugin), [plugin]); + const uninstall = React.useCallback(() => uninstallPluginWithDependenciesCheck(plugin), [plugin]); if (!installed) return null; @@ -58,6 +59,7 @@ export const LunaPluginSettings = React.memo(({ plugin }: { plugin: LunaPlugin } const name = pkg.name; const link = pkg.homepage ?? pkg.repository?.url; let version = isDev ? `${pkg.version ?? ""} [DEV]` : pkg.version; + const dependsOn = plugin.dependencyRequirements.map((dependency) => dependency.name); // Dont allow disabling core plugins const isCore = LunaPlugin.corePlugins.has(name); @@ -84,6 +86,8 @@ export const LunaPluginSettings = React.memo(({ plugin }: { plugin: LunaPlugin } loadError={loadError} author={author} desc={desc} + isLibrary={plugin.isLibrary} + dependsOn={dependsOn} children={ <> {!isCore && ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 710575e..795e4f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,8 @@ importers: specifier: ^1.0.10 version: 1.0.10 + plugins/linux: {} + plugins/ui: dependencies: '@emotion/react': diff --git a/render/src/LunaPlugin.ts b/render/src/LunaPlugin.ts index 918af4c..ca64dc8 100644 --- a/render/src/LunaPlugin.ts +++ b/render/src/LunaPlugin.ts @@ -16,11 +16,21 @@ type ModuleExports = { errSignal?: Signal; }; +// #region Package Types export type LunaAuthor = { name: string; url: string; avatarUrl?: string; }; +export type LunaPackageDependency = { + name: string; + storeUrl: string; + devStoreUrl?: string; +}; +export type LunaPackageMeta = { + type?: "plugin" | "library"; + dependencies?: LunaPackageDependency[]; +}; export type PluginPackage = { name: string; hash: string; @@ -35,7 +45,9 @@ export type PluginPackage = { dependencies?: string[]; devDependencies?: string[]; code?: string; + luna?: LunaPackageMeta; }; +// #endregion // If adding to this make sure that the values are initalized in LunaPlugin.fromStorage export type LunaPluginStorage = { @@ -82,6 +94,16 @@ export class LunaPlugin { return Object.values(this.plugins).filter((p) => p.name === name); } + // #region Dependency Lookup + public static getInstalled(): LunaPlugin[] { + return Object.values(this.plugins).filter((plugin) => plugin.installed); + } + + public static findInstalledDependantsOf(pluginName: string): LunaPlugin[] { + return this.getInstalled().filter((plugin) => plugin.dependsOn(pluginName)); + } + // #endregion + // Static list of Luna plugins that should be seperate from user plugins public static readonly corePlugins: Set = new Set(["@luna/lib", "@luna/lib.native", "@luna/ui", "@luna/dev", "@luna/linux"]); @@ -96,6 +118,8 @@ export class LunaPlugin { console.error(errMsg, err); }); } + + // #region Dependency Helpers }); __ipcRenderer.on("__Luna.LunaPlugin.addDependant", (pluginName, dependantName) => { const plugin = LunaPlugin.getByName(pluginName); @@ -287,6 +311,15 @@ export class LunaPlugin { public get installed(): boolean { return this.store.installed; } + public get isLibrary(): boolean { + return this.package?.luna?.type === "library"; + } + public get dependencyRequirements(): LunaPackageDependency[] { + return this.package?.luna?.dependencies ?? []; + } + public dependsOn(pluginName: string): boolean { + return this.dependencyRequirements.some((dependency) => dependency.name === pluginName); + } public get isDev(): boolean { return this.url.startsWith("http://127.0.0.1"); } @@ -327,6 +360,7 @@ export class LunaPlugin { public async enable() { try { this.loading._ = true; + if (!(await this.ensureDependenciesEnabled())) return; await this.loadExports(); this._enabled._ = true; this.store.installed = true; @@ -353,6 +387,18 @@ export class LunaPlugin { public async install() { this.loading._ = true; + const pluginType = this.package?.luna?.type ?? "plugin"; + if (!LunaPlugin.corePlugins.has(this.name) && pluginType !== "plugin" && pluginType !== "library") { + this.loading._ = false; + return this.trace.msg.err(`Refusing to install '${this.name}': invalid luna.type '${pluginType}'.`); + } + + const pluginPackage = this.package; + if (pluginPackage === undefined) { + this.loading._ = false; + return this.trace.msg.err(`Refusing to install '${this.name}': missing package metadata.`); + } + // Uninstall other versions of the same plugin (e.g., switching from GitHub to DEV) const otherVersions = LunaPlugin.getAllByName(this.name).filter((p) => p !== this && p.installed); for (const other of otherVersions) { @@ -363,7 +409,7 @@ export class LunaPlugin { // Update persisted store with this plugin's URL so it loads on restart const persistedStore = await LunaPlugin.pluginStorage.getReactive(this.name); persistedStore.url = this.url; - persistedStore.package = this.package; + persistedStore.package = pluginPackage; persistedStore.installed = true; persistedStore.enabled = true; @@ -372,6 +418,16 @@ export class LunaPlugin { coreTrace.msg.log(`Installed plugin ${this.name}${this.isDev ? " [DEV]" : ""}`); } public async uninstall() { + if (this.isLibrary) { + const blockingDependants = LunaPlugin.findInstalledDependantsOf(this.name).filter((plugin) => plugin !== this); + if (blockingDependants.length > 0) { + const dependantNames = blockingDependants.map((plugin) => plugin.name).join(", "); + return this.trace.msg.warn( + `Cannot uninstall library plugin '${this.name}' while dependant plugins are installed: ${dependantNames}. Uninstall those first.`, + ); + } + } + await this.disable(); this.loading._ = true; // Remove this plugin from all dependants lists @@ -384,6 +440,22 @@ export class LunaPlugin { this.loading._ = false; coreTrace.msg.log(`Uninstalled plugin ${this.name}${this.isDev ? " [DEV]" : ""}`); } + + private async ensureDependenciesEnabled(): Promise { + for (const dependency of this.dependencyRequirements) { + const dependencyPlugin = await LunaPlugin.fromName(dependency.name); + if (dependencyPlugin === undefined || !dependencyPlugin.installed) { + this.trace.msg.err(`Missing required library plugin '${dependency.name}' for '${this.name}'.`); + return false; + } + + dependencyPlugin.addDependant(this); + + if (!dependencyPlugin.enabled) await dependencyPlugin.enable(); + } + + return true; + } // #endregion // #region Fetch From 52929fcede274f6364a17c3aa658a36c4d432ef4 Mon Sep 17 00:00:00 2001 From: Jonas W Date: Sun, 1 Mar 2026 19:30:05 +0100 Subject: [PATCH 2/2] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 87ef304..3f0ca52 100644 --- a/package.json +++ b/package.json @@ -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",