diff --git a/lib/plugins/package.json b/lib/plugins/package.json new file mode 100644 index 0000000..c69e668 --- /dev/null +++ b/lib/plugins/package.json @@ -0,0 +1,16 @@ +{ + "name": "@revenge-mod/plugins", + "private": true, + "type": "module", + "exports": { + ".": { + "default": "./src/index.ts" + }, + "./init": { + "default": "./src/init.ts" + }, + "./_": { + "default": "./src/_internal.ts" + } + } +} diff --git a/lib/plugins/src/_internal.ts b/lib/plugins/src/_internal.ts new file mode 100644 index 0000000..e517383 --- /dev/null +++ b/lib/plugins/src/_internal.ts @@ -0,0 +1,137 @@ +import * as DiscordLibrary from '@revenge-mod/discord' +import * as ModulesLibrary from '@revenge-mod/modules' +import * as PatcherLibrary from '@revenge-mod/patcher' +import * as PluginsLibrary from '@revenge-mod/plugins' +import * as ReactLibrary from '@revenge-mod/react' + +const { PluginFlags } = PluginsLibrary + +export const _uapi: PluginsLibrary.UnscopedInitPluginApi | PluginsLibrary.UnscopedPluginApi = { + discord: DiscordLibrary, + modules: ModulesLibrary, + plugins: PluginsLibrary, + react: ReactLibrary, + patcher: PatcherLibrary, + // utils: UtilsLibrary, +} + +const _plugins = new Map() + +export function registerPlugin( + manifest: PluginsLibrary.PluginManifest, + lifecycles: PluginsLibrary.PluginLifecycles, + flags: PluginsLibrary.PluginFlags = 0, +) { + // TODO(plugins): verify plugin manifest + if (_plugins.has(manifest.id)) throw new Error(`Plugin with ID "${manifest.id}" already registered`) + + const handleError = (e: unknown) => { + plugin.errors.push(e) + plugin.flags |= PluginFlags.Errored + return plugin.disable() + } + + const plugin: InternalPlugin = { + cleanups: [], + errors: [], + manifest, + lifecycles, + flags, + prepareStart: () => { + // const sapi = api as PluginsLibrary.PluginApi + // sapi.settings ??= ... + }, + init: async () => { + plugin.flags |= PluginFlags.StatusInit + plugin.flags |= PluginFlags.StatusActioning + + try { + await lifecycles.init?.(api) + } catch (e) { + handleError(e) + } + + // Not in finally block because plugin.disable() already handles + plugin.flags &= ~PluginFlags.StatusInit + plugin.flags &= ~PluginFlags.StatusActioning + }, + start: async () => { + plugin.flags |= PluginFlags.StatusStart + plugin.flags |= PluginFlags.StatusActioning + + plugin.prepareStart() + + try { + await lifecycles.start?.(api as PluginsLibrary.PluginApi) + } catch (e) { + handleError(e) + } + + // Not in finally block because plugin.disable() already handles + plugin.flags &= ~PluginFlags.StatusStart + plugin.flags &= ~PluginFlags.StatusActioning + }, + stop: async () => { + plugin.flags &= ~PluginFlags.StatusInit + plugin.flags &= ~PluginFlags.StatusStart + plugin.flags |= PluginFlags.StatusActioning + + // In case the plugin only has init() and stop() + plugin.prepareStart() + + try { + await lifecycles.stop?.(api as PluginsLibrary.PluginApi) + } catch (e) { + handleError(e) + } finally { + // Run cleanups + const results = await Promise.allSettled(plugin.cleanups.map(cleanup => cleanup())) + for (const result of results) if (result.status === 'rejected') plugin.errors.push(result.reason) + + plugin.flags &= ~PluginFlags.StatusActioning + } + }, + enable: async () => { + plugin.flags |= PluginFlags.Enabled + if (lifecycles.init) plugin.flags |= PluginFlags.ReloadRequired + }, + disable: async () => { + plugin.flags &= ~PluginFlags.Enabled + // If plugin is initialized/started, we need to stop it + if (plugin.flags & (PluginFlags.StatusInit | PluginFlags.StatusStart)) await plugin.stop() + }, + } + + const api: PluginsLibrary.InitPluginApi | PluginsLibrary.PluginApi = { + cleanup: (...items) => { + plugin.cleanups.push(...items) + }, + logger: new DiscordLibrary.Logger(`Revenge > ${plugin.manifest.name}`), + plugin, + unscoped: _uapi, + }! + + _plugins.set(manifest.id, plugin) +} + +export function initPlugins() { + const promises: Promise[] = [] + for (const plugin of _plugins.values()) if (plugin.flags & PluginFlags.Enabled) promises.push(plugin.init()) + // These Promises never throw, so it is fine + return Promise.all(promises) +} + +export function startPlugins() { + const promises: Promise[] = [] + for (const plugin of _plugins.values()) if (plugin.flags & PluginFlags.Enabled) promises.push(plugin.start()) + // These Promises never throw, so it is fine + return Promise.all(promises) +} + +export interface InternalPlugin extends PluginsLibrary.Plugin { + cleanups: PluginsLibrary.PluginCleanup[] + prepareStart(): void + init(): Promise + start(): Promise + stop(): Promise +} diff --git a/lib/plugins/src/constants.ts b/lib/plugins/src/constants.ts new file mode 100644 index 0000000..c01a96a --- /dev/null +++ b/lib/plugins/src/constants.ts @@ -0,0 +1,32 @@ +export const PluginFlags = { + /** + * The plugin is enabled. + */ + Enabled: 1 << 0, + /** + * The plugin is an internal plugin. + */ + Internal: 1 << 1, + /** + * The plugin requires a reload to apply changes. + */ + ReloadRequired: 1 << 2, + /** + * The plugin is initialized or initializing. + */ + StatusInit: 1 << 3, + /** + * The plugin is started or starting. + */ + StatusStart: 1 << 4, + /** + * The plugin is actioning something, such as initializing or starting. + */ + StatusActioning: 1 << 5, + /** + * The plugin has errors. + */ + Errored: 1 << 6, +} + +export type PluginFlags = (typeof PluginFlags)[keyof typeof PluginFlags] diff --git a/lib/plugins/src/index.ts b/lib/plugins/src/index.ts new file mode 100644 index 0000000..cd93a2c --- /dev/null +++ b/lib/plugins/src/index.ts @@ -0,0 +1,2 @@ +export * from './constants' +export * from './types' diff --git a/lib/plugins/src/init.ts b/lib/plugins/src/init.ts new file mode 100644 index 0000000..53a954e --- /dev/null +++ b/lib/plugins/src/init.ts @@ -0,0 +1,23 @@ +import * as AssetsLibrary from '@revenge-mod/assets' +// import * as UiLibrary from '@revenge-mod/ui' + +import { after } from '@revenge-mod/patcher' +import { ReactNative } from '@revenge-mod/react' + +import { _uapi, initPlugins, startPlugins } from './_internal' + +import type { UnscopedPluginApi } from './types' + +// Setup non-limited APIs +const uapi = _uapi as UnscopedPluginApi +uapi.assets = AssetsLibrary +// uapi.ui = UiLibrary + +initPlugins() + +// TODO(plugins/init): move to @revenge-mod/app onAppInitialized +const unpatch = after(ReactNative.AppRegistry, 'runApplication', ret => { + unpatch() + setImmediate(startPlugins) + return ret +}) diff --git a/lib/plugins/src/types.ts b/lib/plugins/src/types.ts new file mode 100644 index 0000000..6c31fa9 --- /dev/null +++ b/lib/plugins/src/types.ts @@ -0,0 +1,138 @@ +import type { DiscordModules } from '@revenge-mod/discord/types' + +import type { PluginFlags } from './constants' + +/** + * The unscoped plugin API (limited). This API is available as a global for plugins. + * Available in the `init` phase. + */ +export interface UnscopedInitPluginApi { + discord: typeof import('@revenge-mod/discord') + plugins: typeof import('@revenge-mod/plugins') + modules: typeof import('@revenge-mod/modules') + patcher: typeof import('@revenge-mod/patcher') + react: typeof import('@revenge-mod/react') +} + +/** + * The unscoped plugin API. This API is available as a global for plugins. + * Available in the `start` and `stop` phase. + */ +export interface UnscopedPluginApi extends UnscopedInitPluginApi { + assets: typeof import('@revenge-mod/assets') + // ui: typeof import('@revenge-mod/ui') + // utils: typeof import('@revenge-mod/utils') +} + +export type PluginCleanup = () => any +export type PluginCleanupApi = (...fns: PluginCleanup[]) => void + +/** + * The plugin API (limited). + * Available in the `init` phase. + */ +export interface InitPluginApi { + plugin: Plugin + logger: InstanceType + unscoped: UnscopedInitPluginApi + cleanup: PluginCleanupApi +} + +/** + * The plugin API. + * Available in the `start` and `stop` phase. + */ +export interface PluginApi extends InitPluginApi { + // settings: typeof import('@revenge-mod/settings') + unscoped: UnscopedPluginApi +} + +// TODO(plugins): support plugin bundles +// export interface PluginBundle { +// /** +// * The unique identifier for the plugin bundle. +// */ +// id: string +// /** +// * The author of the plugin bundle. +// */ +// author: string +// } + +export interface PluginManifest { + /** + * The unique identifier for the plugin. + */ + id: string + /** + * The name of the plugin. + */ + name: string + /** + * The description of the plugin. + */ + description: string + /** + * The icon of the plugin. + */ + icon?: string +} + +export interface PluginLifecycles { + /** + * Runs immediately as soon as possible with limited APIs. + * + * @param api Plugin API (limited). + * @param context Plugin context. + */ + init?: (api: InitPluginApi) => any + /** + * Runs when the plugin can be started with all APIs available. + * + * @param api Plugin API. + * @param context Plugin context. + */ + start?: (api: PluginApi) => any + /** + * Runs when the plugin is stopped. + * + * @param api Plugin API. + * @param context Plugin context. + */ + stop?: (api: PluginApi) => any +} + +export interface Plugin { + // TODO(plugins): support plugin bundles + // /** + // * The plugin bundle this plugin belongs to. + // */ + // bundle: PluginBundle + /** + * The plugin manifest. + */ + manifest: PluginManifest + /** + * The plugin lifecycles. + */ + lifecycles: PluginLifecycles + + /** + * The plugin flags. + */ + flags: PluginFlags + /** + * Errors encountered during the plugin lifecycles. + */ + errors: unknown[] + + /** + * Disable the plugin. This will also stop the plugin if it is running. + */ + disable(): Promise + /** + * Enable the plugin. + * @internal + */ + enable(): Promise +}