feat: port pyoncord's plugin system
This commit is contained in:
parent
ff72221810
commit
ffc04ee9c3
8 changed files with 676 additions and 0 deletions
5
src/global.d.ts
vendored
5
src/global.d.ts
vendored
|
|
@ -2,6 +2,11 @@ declare global {
|
|||
type React = typeof import("react");
|
||||
var SkiaApi: typeof import("@shopify/react-native-skia").Skia;
|
||||
|
||||
// ReactNative/Hermes globals
|
||||
var globalEvalWithSourceUrl: (script: string, sourceURL: string) => any;
|
||||
var nativePerformanceNow: typeof performance.now;
|
||||
var nativeModuleProxy: Record<string, Record<string, any> | null>;
|
||||
|
||||
interface Window {
|
||||
[key: string]: any;
|
||||
vendetta: any;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { FileManager } from "@lib/api/native/modules";
|
|||
import { patchLogHook } from "@lib/debug";
|
||||
import { updateFonts } from "@lib/managers/fonts";
|
||||
import { initThemes, patchChatBackground } from "@lib/managers/themes";
|
||||
import { checkAndRegisterUpdates, initPlugins } from "@lib/plugins";
|
||||
import { logger } from "@lib/utils/logger";
|
||||
import initSafeMode from "@ui/safeMode";
|
||||
import { patchSettings } from "@ui/settings";
|
||||
|
|
@ -48,6 +49,7 @@ export default async () => {
|
|||
initFixes(),
|
||||
initSafeMode(),
|
||||
initCorePlugins(),
|
||||
checkAndRegisterUpdates()
|
||||
]).then(
|
||||
// Push them all to unloader
|
||||
u => u.forEach(f => f && lib.unload.push(f))
|
||||
|
|
@ -61,6 +63,8 @@ export default async () => {
|
|||
.then(u => lib.unload.push(u))
|
||||
.catch(() => alert("Failed to initialize Vendetta plugins"));
|
||||
|
||||
initPlugins();
|
||||
|
||||
// Update the fonts
|
||||
updateFonts();
|
||||
|
||||
|
|
|
|||
179
src/lib/api/storage/new.ts
Normal file
179
src/lib/api/storage/new.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
// New iteration of storage API, mostly yoinked from unreleased pyoncord (and sunrise?)
|
||||
import { fileExists, readFile, writeFile } from "@lib/api/native/fs";
|
||||
import { Emitter } from "@lib/utils/emitter";
|
||||
|
||||
interface StorageBackend<T = unknown> {
|
||||
get: () => Promise<T>;
|
||||
set: (data: T) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const emitterSymbol = Symbol.for("bunny.storage.emitter");
|
||||
const storageInitErrorSymbol = Symbol.for("bunny.storage.initError");
|
||||
const storagePromiseSymbol = Symbol.for("bunny.storage.promise");
|
||||
|
||||
const _loadedPath = {} as Record<string, any>;
|
||||
|
||||
function createFileBackend<T>(filePath: string, dflt = {} as T): StorageBackend<T> {
|
||||
return {
|
||||
get: async () => {
|
||||
try {
|
||||
const raw = await readFile(filePath, JSON.stringify(dflt));
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse storage '${filePath}: ${e}'`);
|
||||
}
|
||||
},
|
||||
set: async data => {
|
||||
if (!data || typeof data !== "object") throw new Error("data needs to be an object");
|
||||
await writeFile(filePath, JSON.stringify(data));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function _createProxy(target: any, path: string[], emitter: Emitter): any {
|
||||
return new Proxy(target, {
|
||||
get(target, prop: string) {
|
||||
if ((prop as unknown) === emitterSymbol) return emitter;
|
||||
|
||||
const newPath = [...path, prop];
|
||||
const value: any = target[prop];
|
||||
|
||||
if (value != null) {
|
||||
emitter.emit("GET", {
|
||||
path: newPath,
|
||||
value,
|
||||
});
|
||||
if (typeof value === "object") {
|
||||
return _createProxy(value, newPath, emitter);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
set(target, prop: string, value) {
|
||||
target[prop] = value;
|
||||
emitter.emit("SET", {
|
||||
path: [...path, prop],
|
||||
value,
|
||||
});
|
||||
|
||||
// we do not care about success, if this actually does fail we have other problems
|
||||
return true;
|
||||
},
|
||||
|
||||
deleteProperty(target, prop: string) {
|
||||
const success = delete target[prop];
|
||||
if (success)
|
||||
emitter.emit("DEL", {
|
||||
path: [...path, prop],
|
||||
});
|
||||
return success;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createProxy(target: any = {}): { proxy: any; emitter: Emitter; } {
|
||||
const emitter = new Emitter();
|
||||
|
||||
return {
|
||||
proxy: _createProxy(target, [], emitter),
|
||||
emitter,
|
||||
};
|
||||
}
|
||||
|
||||
export function useProxy<T>(storage: T & { [key in typeof storageInitErrorSymbol | typeof emitterSymbol]: any; }) {
|
||||
if (storage[storageInitErrorSymbol]) throw new Error(
|
||||
"An error occured while initializing the storage",
|
||||
{ cause: storage[storageInitErrorSymbol] }
|
||||
);
|
||||
|
||||
const emitter = storage[emitterSymbol] as Emitter;
|
||||
|
||||
if (emitter == null) {
|
||||
throw new Error(`InvalidArgumentException - storage[emitterSymbol] is ${typeof emitter}`);
|
||||
}
|
||||
|
||||
const [, forceUpdate] = React.useReducer(n => ~n, 0);
|
||||
|
||||
React.useEffect(() => {
|
||||
// TODO
|
||||
const listener = () => forceUpdate();
|
||||
|
||||
emitter.on("SET", listener);
|
||||
emitter.on("DEL", listener);
|
||||
|
||||
return () => {
|
||||
emitter.off("SET", listener);
|
||||
emitter.off("DEL", listener);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
export async function updateStorageAsync<T>(path: string, value: T): Promise<void> {
|
||||
_loadedPath[path] = value;
|
||||
await createFileBackend<T>(path).set(value);
|
||||
}
|
||||
|
||||
export function createStorageAndCallback<T>(path: string, dflt = {} as T, cb: (proxy: T) => void) {
|
||||
const callback = (data: any) => {
|
||||
const { proxy, emitter } = createProxy(data);
|
||||
|
||||
const handler = () => backend.set(proxy);
|
||||
emitter.on("SET", handler);
|
||||
emitter.on("DEL", handler);
|
||||
|
||||
cb(proxy);
|
||||
};
|
||||
|
||||
const backend = createFileBackend<T>(path, dflt);
|
||||
if (_loadedPath[path]) callback(_loadedPath[path]);
|
||||
else backend.get().then(d => callback(d));
|
||||
}
|
||||
|
||||
export async function createStorageAsync<T>(path: string, dflt = {} as T): Promise<T> {
|
||||
return new Promise(r => createStorageAndCallback(path, dflt, r));
|
||||
}
|
||||
|
||||
export const createStorage = <T>(path: string, dflt = {} as T): T & { [key: symbol]: any; } => {
|
||||
const promise = new Promise(r => resolvePromise = r);
|
||||
let awaited: any, error: any, resolvePromise: (val?: unknown) => void;
|
||||
|
||||
createStorageAndCallback(path, dflt, proxy => {
|
||||
awaited = proxy;
|
||||
resolvePromise();
|
||||
});
|
||||
|
||||
const check = () => {
|
||||
if (awaited) return true;
|
||||
throw new Error("Attempted to access storage without initializing");
|
||||
};
|
||||
|
||||
return new Proxy({} as any, {
|
||||
...Object.fromEntries(
|
||||
Object.getOwnPropertyNames(Reflect)
|
||||
.map(k => [k, (t: T, ...a: any[]) => {
|
||||
// @ts-expect-error
|
||||
return check() && Reflect[k](awaited, ...a);
|
||||
}])
|
||||
),
|
||||
get(target, prop, recv) {
|
||||
if (prop === storageInitErrorSymbol) return error;
|
||||
if (prop === storagePromiseSymbol) return promise;
|
||||
return check() && Reflect.get(awaited ?? target, prop, recv);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export async function preloadStorageIfExists(path: string) {
|
||||
if (_loadedPath[path]) return;
|
||||
if (!await fileExists(path)) return;
|
||||
|
||||
const data = await createFileBackend<any>(path).get();
|
||||
_loadedPath[path] = data;
|
||||
}
|
||||
|
||||
export function awaitStorage(...proxies: any[]) {
|
||||
return Promise.all(proxies.map(proxy => proxy[storagePromiseSymbol]));
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import "../global.d.ts";
|
|||
export * as api from "./api";
|
||||
export * as debug from "./debug";
|
||||
export * as managers from "./managers";
|
||||
export * as plugins from "./plugins";
|
||||
export * as settings from "./settings";
|
||||
export * as ui from "./ui";
|
||||
export * as utils from "./utils";
|
||||
|
|
|
|||
52
src/lib/plugins/api.ts
Normal file
52
src/lib/plugins/api.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { patcher } from "@lib/api";
|
||||
import { registerCommand } from "@lib/api/commands";
|
||||
import { createStorage } from "@lib/api/storage/new";
|
||||
import { logger } from "@lib/utils/logger";
|
||||
|
||||
import { registeredPlugins } from ".";
|
||||
import { BunnyPluginObject } from "./types";
|
||||
|
||||
type DisposableFn = (...props: any[]) => () => unknown;
|
||||
function shimDisposableFn<F extends DisposableFn>(unpatches: (() => void)[], f: F): F {
|
||||
return ((...props: Parameters<F>) => {
|
||||
const up = f(...props);
|
||||
unpatches.push(up);
|
||||
return up;
|
||||
}) as F;
|
||||
}
|
||||
|
||||
export function createBunnyPluginAPI(id: string) {
|
||||
const disposers = new Array<DisposableFn>;
|
||||
|
||||
// proxying this would be a good idea
|
||||
const object = {
|
||||
...window.bunny,
|
||||
api: {
|
||||
...window.bunny.api,
|
||||
patcher: {
|
||||
before: shimDisposableFn(disposers, patcher.before),
|
||||
after: shimDisposableFn(disposers, patcher.after),
|
||||
instead: shimDisposableFn(disposers, patcher.instead)
|
||||
},
|
||||
commands: {
|
||||
...window.bunny.api.commands,
|
||||
registerCommand: shimDisposableFn(disposers, registerCommand)
|
||||
},
|
||||
flux: {
|
||||
...window.bunny.api.flux,
|
||||
intercept: shimDisposableFn(disposers, window.bunny.api.flux.intercept)
|
||||
}
|
||||
},
|
||||
// Added something in here? Make sure to also update BunnyPluginProperty in ./types
|
||||
plugin: {
|
||||
createStorage: () => createStorage(`plugins/storage/${id}.json`),
|
||||
manifest: registeredPlugins.get(id),
|
||||
logger
|
||||
}
|
||||
} as unknown as BunnyPluginObject;
|
||||
|
||||
return {
|
||||
object,
|
||||
disposers,
|
||||
};
|
||||
}
|
||||
381
src/lib/plugins/index.ts
Normal file
381
src/lib/plugins/index.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
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, any>();
|
||||
|
||||
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);
|
||||
}
|
||||
}));
|
||||
}
|
||||
53
src/lib/plugins/types.ts
Normal file
53
src/lib/plugins/types.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { createStorage } from "@lib/api/storage";
|
||||
import { Logger } from "@lib/utils/logger";
|
||||
import { Author } from "@lib/utils/types";
|
||||
|
||||
export interface PluginRepo {
|
||||
[id: string]: {
|
||||
name: string;
|
||||
version: string;
|
||||
|
||||
// For plugin developing convenience, plugins with this on will always gets fetched
|
||||
alwaysFetch?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginRepoStorage {
|
||||
[repoUrl: string]: PluginRepo;
|
||||
}
|
||||
|
||||
export interface PluginSettingsStorage {
|
||||
[pluginId: string]: any;
|
||||
}
|
||||
|
||||
export interface BunnyPluginManifest {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly version: string;
|
||||
readonly authors: Author[];
|
||||
}
|
||||
|
||||
export interface BunnyPluginManifestInternal extends BunnyPluginManifest {
|
||||
readonly parentRepository: string;
|
||||
readonly jsPath?: string;
|
||||
}
|
||||
|
||||
export interface PluginInstance {
|
||||
start?(): void;
|
||||
stop?(): void;
|
||||
SettingsComponent?(): React.Component<any>;
|
||||
}
|
||||
|
||||
export interface PluginInstanceInternal extends PluginInstance {
|
||||
readonly manifest: BunnyPluginManifest;
|
||||
}
|
||||
|
||||
export interface BunnyPluginProperty {
|
||||
readonly manifest: BunnyPluginManifestInternal;
|
||||
readonly logger: Logger;
|
||||
createStorage<T>(): ReturnType<typeof createStorage<T>>;
|
||||
}
|
||||
|
||||
export type BunnyPluginObject = typeof window.bunny & {
|
||||
plugin: BunnyPluginProperty;
|
||||
};
|
||||
|
|
@ -18,6 +18,7 @@ export const messageUtil = findByPropsLazy("sendBotMessage");
|
|||
export const navigationStack = findByPropsLazy("createStackNavigator");
|
||||
export const NavigationNative = findByPropsLazy("NavigationContainer");
|
||||
export const tokens = findByPropsLazy("colors", "unsafe_rawColors");
|
||||
export const semver = findByPropsLazy("parse", "clean");
|
||||
|
||||
// Flux
|
||||
export const Flux = findByPropsLazy("connectStores");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue