feat: port pyoncord's plugin system

This commit is contained in:
amsyarasyiq 2024-07-22 00:17:17 +08:00
parent ff72221810
commit ffc04ee9c3
8 changed files with 676 additions and 0 deletions

5
src/global.d.ts vendored
View file

@ -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;

View file

@ -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
View 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]));
}

View file

@ -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
View 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
View 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
View 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;
};

View file

@ -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");