GoonCord/src/lib/plugins/index.ts
2024-07-23 22:24:26 +08:00

381 lines
13 KiB
TypeScript

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<string, t.PluginInstanceInternal>();
export const registeredPlugins = new Map<string, t.BunnyPluginManifest>();
export const pluginInstances = new Map<string, t.PluginInstanceInternal>();
export const apiObjects = new Map<string, ReturnType<typeof createBunnyPluginAPI>>();
export const pluginRepositories = createStorage<t.PluginRepoStorage>("plugins/repositories.json");
export const pluginSettings = createStorage<t.PluginSettingsStorage>("plugins/settings.json");
const manifestToId = new WeakMap<t.BunnyPluginManifest, string>();
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<T>(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<T extends t.BunnyPluginManifest>(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<any>) | 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<t.BunnyPluginManifest>(`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<unknown>[];
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<string, any>; // 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);
}
}));
}