import { allSettled } from "@core/polyfills/allSettled"; import { readFile, removeFile, writeFile } from "@lib/api/native/fs"; import { awaitStorage, createStorage, preloadStorageIfExists, updateStorageAsync } from "@lib/api/storage/new"; import { safeFetch } from "@lib/utils"; import { semver } from "@metro/common"; import { createBunnyPluginAPI } from "./api"; import * as t from "./types"; type PluginInstantiator = ( bunny: t.BunnyPluginObject, definePlugin?: (p: t.PluginInstance) => t.PluginInstanceInternal ) => t.PluginInstanceInternal; // Core plugins instances are stored both in this and pluginInstance // This exists because pluginInstances only stores running plugins while this one // stores the always existing core plugins instances (which can't be destroyed) export const corePluginInstances = new Map(); export const registeredPlugins = new Map(); export const pluginInstances = new Map(); export const apiObjects = new Map>(); export const pluginRepositories = createStorage("plugins/repositories.json"); export const pluginSettings = createStorage("plugins/settings.json"); const manifestToId = new WeakMap(); const _fetch = (repoUrl: string, path: string) => safeFetch(new URL(path, repoUrl), { cache: "no-store" }); const fetchJS = (repoUrl: string, path: string) => _fetch(repoUrl, path).then(r => r.text()); const fetchJSON = (repoUrl: string, path: string) => _fetch(repoUrl, path).then(r => r.json()); function assert(condition: T, id: string, attempt: string): asserts condition { if (!condition) throw new Error(`[${id}] Attempted to ${attempt}`); } /** * Checks if a version is newer than the other. However, this comes with an additional logic, * where if the version are equal, one with prerelease "tag" will be considered "newer" * @returns Whether the version is newer */ function newerThan(v1: string, v2: string) { if (semver.gt(v1, v2)) return true; const coerced = semver.coerce(v1); if (coerced == null) return false; return semver.prerelease(v1)?.includes("dev") && semver.eq(coerced, v2); } function isExternalPlugin(manifest: t.BunnyPluginManifest): manifest is t.BunnyPluginManifestInternal { return "parentRepository" in manifest; } export function getId(manifest: T): string { const id = manifestToId.get(manifest); assert(id, manifest?.name ?? "unknown", "getting ID from an unregistered/invalid manifest"); return id; } export function getPluginSettingsComponent(id: string): (() => React.Component) | null { const instance = pluginInstances.get(id); if (!instance) return null; if (instance.SettingsComponent) return instance.SettingsComponent; return null; } export function isPluginInstalled(id: string) { return pluginSettings[id] != null; } export function isPluginEnabled(id: string) { return Boolean(pluginSettings[id]?.enabled); } /** * Fetch and write the plugin to thier respective storage. This does not compare the version nor execute the plugin * @param repoUrl URL to the plugin repository * @param id The ID of the plugin * @returns The newly fetched plugin manifest */ export async function updateAndWritePlugin(repoUrl: string, id: string, fetchScript: boolean) { const manifest: t.BunnyPluginManifestInternal = await fetchJSON(repoUrl, `plugins/${id}/manifest.json`); // @ts-expect-error - Setting a readonly property manifest.parentRepository = repoUrl; if (fetchScript) { // @ts-expect-error - Setting a readonly property manifest.jsPath = `plugins/scripts/${id}.js`; const js: string = await fetchJS(repoUrl, `plugins/${id}/index.js`); await writeFile(manifest.jsPath, js); } await updateStorageAsync(`plugins/manifests/${id}.json`, manifest); if (registeredPlugins.has(id)) { const existingManifest = registeredPlugins.get(id)!; return Object.assign(existingManifest, manifest); } return manifest; } /** * Stops the plugin, fetches the update and restart the updated plugin * @param id The registered plugin's ID * @param repoUrl URL to the plugin repository. If unprovided, the repository url from the registered plugin will be used. */ export async function refreshPlugin(id: string, repoUrl?: string) { let manifest = registeredPlugins.get(id); assert(manifest, id, "refresh a non-registered plugin"); assert(pluginInstances.get(id), id, "refresh a non-started plugin"); stopPlugin(id); if (isExternalPlugin(manifest)) { manifest = await updateAndWritePlugin(repoUrl ?? manifest.parentRepository, id, true); } registeredPlugins.delete(id); registeredPlugins.set(id, manifest); manifestToId.set(manifest, id); await startPlugin(id); } /** * Check for any updates from the repository given, or add it. * Then, register all plugins within the repository. * @param repoUrl Registered plugin repository url * @returns Whether there was any changes made from the update */ export async function updateRepository(repoUrl: string) { const repo: t.PluginRepo = await fetchJSON(repoUrl, "repo.json"); const storedRepo = pluginRepositories[repoUrl]; let updated = false; // This repository never existed, update it! if (!storedRepo) { for (const id in repo) { if (corePluginInstances.has(id)) { throw new Error(`Plugins can't have the same ID as any of Bunny core plugin '${id}'`); } } updated = true; pluginRepositories[repoUrl] = repo; } else { // Remove plugins which no longer exists on the fetched repository for (const plugin in storedRepo) if (!repo[plugin]) { delete storedRepo[plugin]; } } await Promise.all(Object.keys(repo).map(async plugin => { if (!storedRepo || !storedRepo[plugin] || repo[plugin].alwaysFetch || newerThan(repo[plugin].version, storedRepo[plugin].version)) { updated = true; pluginRepositories[repoUrl][plugin] = repo[plugin]; await updateAndWritePlugin(repoUrl, plugin, Boolean(storedRepo && pluginSettings[plugin])); } else { await preloadStorageIfExists(`plugins/manifests/${plugin}.json`); } })); // Register plugins in this repository for (const id in repo) { const manifest = createStorage(`plugins/manifests/${id}.json`); const existing = registeredPlugins.get(id); // Skip if this version isn't any higher if (existing && !newerThan(manifest.version, existing.version)) { continue; } registeredPlugins.set(id, manifest); manifestToId.set(manifest, id); } return updated; } /** * Deletes a repository from registrations and uninstalls ALL plugins under this repository */ export async function deleteRepository(repoUrl: string) { // Once when we have our "official repository", make sure to make it non-deleteable assert(pluginRepositories[repoUrl], repoUrl, "delete a non-registered repository"); const promQueues = [] as Promise[]; for (const [id, manifest] of registeredPlugins) { if (!isExternalPlugin(manifest) || manifest.parentRepository !== repoUrl) continue; // Uninstall if (isPluginInstalled(id)) { promQueues.push(uninstallPlugin(id)); } // Deregister all plugins under this repository registeredPlugins.delete(id); } delete pluginRepositories[repoUrl]; await Promise.all(promQueues); } /** * Enablea a plugin. The plugin must have been declared as installed. * @param id The installed plugin ID * @param start Whether to start the plugin */ export async function enablePlugin(id: string, start: boolean) { assert(isPluginInstalled(id), id, "enable a non-installed plugin"); pluginSettings[id]!!.enabled = true; if (start) await startPlugin(id); } /** * Disables and stop the plugin. The plugin must have been declared as installed * @param id The installed plugin ID */ export function disablePlugin(id: string) { assert(isPluginInstalled(id), id, "disable a non-installed plugin"); pluginInstances.has(id) && stopPlugin(id); pluginSettings[id]!!.enabled = false; } /** * Installs a registered plugin, will throw when plugin was already installed * @param id The registered plugin ID * @param start Whether to start the plugin or not */ export async function installPlugin(id: string, start: boolean) { const manifest = registeredPlugins.get(id); assert(manifest, id, "install an non-registered plugin"); assert(!isPluginInstalled(id), id, "install an already installed plugin"); assert(isExternalPlugin(manifest), id, "install a core plugin"); // We only need to fetch the JS, but this is fine await updateAndWritePlugin(manifest.parentRepository, id, true); pluginSettings[id] = { enabled: true }; if (start) startPlugin(id); } /** * Uninstalls a plugin and remove it from the storage * @param id The installed plugin ID */ export async function uninstallPlugin(id: string) { const manifest = registeredPlugins.get(id); assert(manifest, id, "uninstall an unregistered plugin"); assert(isPluginInstalled(id), id, "uninstall a non-installed plugin"); assert(isExternalPlugin(manifest), id, "uninstall a core plugin"); pluginInstances.has(id) && stopPlugin(id); delete pluginSettings[id]; await removeFile(`plugins/scripts/${id}.js`); } /** * Starts a registered, installed, enabled and unstarted plugin. Otherwise, would throw. * @param id The enabled plugin ID */ export async function startPlugin(id: string) { const manifest = registeredPlugins.get(id); // horror assert(manifest, id, "start a non-registered plugin"); assert(isPluginInstalled(id), id, "start a non-installed plugin"); assert(pluginSettings[id]?.enabled, id, "start a disabled plugin"); assert(!pluginInstances.has(id), id, "start an already started plugin"); await preloadStorageIfExists(`plugins/storage/${id}.json`); let pluginInstance: t.PluginInstanceInternal; if (isExternalPlugin(manifest)) { // Stage one, "compile" the plugin try { // jsPath should always exists when the plugin is installed, unless the storage is corrupted const iife = await readFile(manifest.jsPath!!); var instantiator = globalEvalWithSourceUrl( `(bunny,definePlugin)=>{${iife};return plugin?.default ?? plugin;}`, `bunny-plugin/${id}-${manifest.version}` ) as PluginInstantiator; } catch (error) { throw new Error("An error occured while parsing plugin's code, possibly a syntax error?", { cause: error }); } // Stage two, load the plugin try { const api = createBunnyPluginAPI(id); pluginInstance = instantiator(api.object, p => { return Object.assign(p, { manifest }) as t.PluginInstanceInternal; }); if (!pluginInstance) throw new Error(`Plugin '${id}' does not export a valid plugin instance`); apiObjects.set(id, api); pluginInstances.set(id, pluginInstance); } catch (error) { throw new Error("An error occured while instantiating plugin's code", { cause: error }); } } else { pluginInstance = corePluginInstances.get(id)!; assert(pluginInstance, id, "start a non-existent core plugin"); pluginInstances.set(id, pluginInstance); } // Stage three (of external plugins), start the plugin try { pluginInstance.start?.(); } catch (error) { throw new Error("An error occured while starting the plugin", { cause: error }); // TODO: stopPlugin? disablePlugin? } } /** * Stops the plugin and disposes all usages of scoped plugin API * @param id The currently-running plugin's ID */ export function stopPlugin(id: string) { const instance = pluginInstances.get(id); assert(instance, id, "stop a non-started plugin"); instance.stop?.(); const obj = apiObjects.get(id); obj?.disposers.forEach((d: Function) => d()); pluginInstances.delete(id); } export async function checkAndRegisterUpdates() { await awaitStorage(pluginRepositories, pluginSettings); const corePlugins = {} as Record; // TODO: getCorePlugins(); for (const id in corePlugins) { const { default: instance, preenabled } = corePlugins[id]; // Core plugins are always installed pluginSettings[id] ??= { enabled: preenabled ?? true }; registeredPlugins.set(id, instance.manifest); manifestToId.set(instance.manifest, id); corePluginInstances.set(id, instance); } await allSettled(Object.keys(pluginRepositories).map(async repo => { await updateRepository(repo); })); } /** * @internal */ export async function initPlugins() { await awaitStorage(pluginRepositories, pluginSettings); // Now, start all enabled plugins... await allSettled([...registeredPlugins.keys()].map(async id => { if (isPluginEnabled(id)) { await startPlugin(id); } })); }