feat: add ability to decorate plugin APIs, move private properties to WeakMap stores

This commit is contained in:
PalmDevs 2025-07-20 23:06:56 +07:00
parent 78614c1cc9
commit 2e455cf2c5
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
39 changed files with 595 additions and 332 deletions

View file

@ -18,9 +18,6 @@
"./TableRowAssetIcon": {
"default": "./src/TableRowAssetIcon.tsx"
},
"./init": {
"default": "./src/init.ts"
},
"./types": {
"types": "./src/types.ts"
},
@ -28,4 +25,4 @@
"default": "./src/_internal.ts"
}
}
}
}

View file

@ -1,9 +0,0 @@
import { pUnscopedApi } from '@revenge-mod/plugins/_'
import { defineLazyProperty } from '@revenge-mod/utils/object'
import type { UnscopedInitPluginApi } from '@revenge-mod/plugins/types'
const uapi = pUnscopedApi as UnscopedInitPluginApi
defineLazyProperty(uapi, 'components', () => {
return require('.')
})

View file

@ -24,9 +24,6 @@
"./native": {
"default": "./src/native.ts"
},
"./init": {
"default": "./src/init.ts"
},
"./preinit": {
"default": "./src/preinit.ts"
},

View file

@ -1,44 +0,0 @@
import { pEmitter, pUnscopedApi } from '@revenge-mod/plugins/_'
import {
defineLazyProperties,
defineLazyProperty,
} from '@revenge-mod/utils/object'
import { Logger } from './common'
import type { UnscopedInitPluginApi } from '@revenge-mod/plugins/types'
import type { PluginApiDiscord } from './types/revenge'
defineLazyProperties(
((pUnscopedApi as UnscopedInitPluginApi).discord = {} as PluginApiDiscord),
{
actions: () => {
return require('@revenge-mod/discord/actions')
},
common: () => {
return require('@revenge-mod/discord/common')
},
design: () => {
return require('@revenge-mod/discord/design')
},
native: () => {
return require('@revenge-mod/discord/native')
},
modules: () =>
defineLazyProperties({} as PluginApiDiscord.Modules, {
mainTabsV2: () => {
return require('@revenge-mod/discord/modules/main_tabs_v2')
},
settings: () => ({
...require('@revenge-mod/discord/modules/settings'),
renderer: require('@revenge-mod/discord/modules/settings/renderer'),
}),
}),
},
)
pEmitter.on('init', ({ manifest: { id } }, api) =>
defineLazyProperty(
api,
'logger',
() => new Logger(`Revenge > Plugins (${id})`),
),
)

View file

@ -17,6 +17,7 @@ import type {
export * from './native'
export * from './polyfills'
export * from './revenge'
export namespace DiscordModules {
export namespace Flux {

View file

@ -6,9 +6,6 @@
"./browserify": {
"default": "./src/browserify.ts"
},
"./init": {
"default": "./src/init.ts"
},
"./react-native-clipboard": {
"default": "./src/react-native-clipboard.ts"
},
@ -22,4 +19,4 @@
"types": "./src/types.ts"
}
}
}
}

View file

@ -1,23 +0,0 @@
import { pUnscopedApi } from '@revenge-mod/plugins/_'
import { defineLazyProperties } from '@revenge-mod/utils/object'
import type { UnscopedInitPluginApi } from '@revenge-mod/plugins/types'
import type { PluginApiExternals } from './types'
defineLazyProperties(
((pUnscopedApi as UnscopedInitPluginApi).externals =
{} as PluginApiExternals),
{
Browserify: () => {
return require('@revenge-mod/externals/browserify')
},
ReactNativeClipboard: () => {
return require('@revenge-mod/externals/react-native-clipboard')
},
ReactNavigation: () => {
return require('@revenge-mod/externals/react-navigation')
},
Shopify: () => {
return require('@revenge-mod/externals/shopify')
},
},
)

View file

@ -19,7 +19,7 @@
"types": "./src/types.ts"
},
"./_": {
"default": "./src/_internal.ts"
"default": "./src/_internal/index.ts"
}
}
}

View file

@ -0,0 +1,36 @@
import type {
InitPluginApi,
Plugin,
PluginApi,
PreInitPluginApi,
} from '../types'
import type { InternalPluginMeta } from '.'
export type PluginApiDecoratorStore<
A extends PreInitPluginApi | InitPluginApi | PluginApi,
> = [decorator: Parameters<A['decorate']>[0], handleError: (e: unknown) => void]
export const pDecorators: {
preInit: PluginApiDecoratorStore<PreInitPluginApi>[]
init: PluginApiDecoratorStore<InitPluginApi>[]
start: PluginApiDecoratorStore<PluginApi>[]
} = {
preInit: [],
init: [],
start: [],
}
export function decoratePluginApi(
store: PluginApiDecoratorStore<
PreInitPluginApi | InitPluginApi | PluginApi
>[],
plugin: Plugin,
meta: InternalPluginMeta,
) {
for (const [decorator, handleError] of store)
try {
decorator(plugin as Plugin<any, any>, meta.options)
} catch (e) {
handleError(e)
}
}

View file

@ -0,0 +1,78 @@
import { pList, pMetadata } from '../_internal'
import { PluginFlags } from '../constants'
import type { AnyPlugin } from '../_internal'
/// PLUGIN DEPENDENCY GRAPHING
// We don't store the graph as a tree, but rather as a set of nodes.
// Root nodes are plugins that have dependencies, but no dependents. These plugins are the starting points of the dependency graph.
// Leaf nodes are plugins that have no dependencies, but may have dependents. These plugins are the end points of the dependency graph.
// Start order: Single nodes (no dependencies & dependents) -> Leaf nodes (no dependencies, maybe dependents) -> Root nodes (with dependencies, no dependents)
// This way we can ensure that all dependencies are started before the plugin itself.
export const pRootNodes = new Set<AnyPlugin>()
export const pLeafOrSingleNodes = new Set<AnyPlugin>()
// Ordered list of plugins to be started
export const pListOrdered = new Set<AnyPlugin>()
// Pending plugins to be computed
export const pPending = new Set<AnyPlugin>()
export function computePendingNodes() {
for (const plugin of pPending) resolvePluginGraph(plugin)
pPending.clear()
for (const plugin of pLeafOrSingleNodes) pListOrdered.add(plugin)
pLeafOrSingleNodes.clear()
const stack = [...pRootNodes]
pRootNodes.clear()
while (stack.length) {
const plugin = stack.pop()!
if (pListOrdered.has(plugin)) continue
if (plugin.manifest.dependencies?.length) {
for (const dep of plugin.manifest.dependencies) {
const depPlugin = pList.get(dep.id)
if (depPlugin && !pListOrdered.has(depPlugin))
stack.push(depPlugin)
}
} else pListOrdered.add(plugin)
}
}
export function resolvePluginGraph(plugin: AnyPlugin) {
const { manifest } = plugin
if (manifest.dependencies?.length) {
// Optimisitically add to root nodes (if there are dependents, it will be removed later)
pRootNodes.add(plugin)
for (const { id } of manifest.dependencies) {
const dep = pList.get(id)
if (!dep) {
// TODO: Once external plugins are implemented, we will have to check the external plugin registry here as well
// External plugin registry should ideally be Record<PluginManifest['id'], [PluginManifest, Flags: number, PluginCode: string]>
// Then we register the plugin here and do dep = pList.get(id) again
throw new Error(
`Plugin "${manifest.id}" depends on unregistered plugin "${id}"`,
)
}
if (!(dep.flags & PluginFlags.Enabled))
throw new Error(
`Plugin "${manifest.id}" depends on disabled plugin "${id}"`,
)
const depMeta = pMetadata.get(dep)!
depMeta.dependents.push(plugin)
// Not a root node if it has dependencies
if (dep.manifest.dependencies?.length) pRootNodes.delete(dep)
}
} else pLeafOrSingleNodes.add(plugin)
}

View file

@ -1,8 +1,14 @@
import { TypedEventEmitter } from '@revenge-mod/discord/common/utils'
import { getErrorStack } from '@revenge-mod/utils/error'
import { allSettled, sleepReject } from '@revenge-mod/utils/promise'
import { pUnscopedApi as uapi } from './apis'
import { PluginFlags as Flag, PluginStatus as Status } from './constants'
import { pUnscopedApi as uapi } from '../apis'
import {
PluginFlags as Flag,
PluginFlags,
PluginStatus as Status,
} from '../constants'
import { decoratePluginApi, pDecorators } from './decorators'
import { pLeafOrSingleNodes, pPending } from './dependency-graph'
import type {
InitPluginApi,
Plugin,
@ -12,37 +18,34 @@ import type {
PluginManifest,
PluginOptions,
PreInitPluginApi,
} from './types'
} from '../types'
export type AnyPlugin = Plugin<any, any>
export const pUnscopedApi = uapi
export const pEmitter = new TypedEventEmitter<{
register: [InternalPlugin, PluginOptions<any>]
disabled: [InternalPlugin]
enabled: [InternalPlugin]
// Add preInit API extensions
preInit: [InternalPlugin, PreInitPluginApi]
preInited: [InternalPlugin]
// Add init API extensions
init: [InternalPlugin, InitPluginApi]
inited: [InternalPlugin]
// Add start API extensions
start: [InternalPlugin, PluginApi]
started: [InternalPlugin]
stopped: [InternalPlugin]
errored: [InternalPlugin, unknown]
register: [AnyPlugin, PluginOptions<any>, update?: true]
disabled: [AnyPlugin]
enabled: [AnyPlugin]
preInited: [AnyPlugin]
inited: [AnyPlugin]
started: [AnyPlugin]
stopped: [AnyPlugin]
errored: [AnyPlugin, unknown]
}>()
export const pList = new Map<PluginManifest['id'], InternalPlugin>()
export const pMetadata = new Map<
PluginManifest['id'],
{
api: PreInitPluginApi | InitPluginApi | PluginApi | undefined
promises: Promise<void>[]
iflags: number
apiLevel: number
}
>()
export const pList = new Map<PluginManifest['id'], AnyPlugin>()
export const pMetadata = new WeakMap<AnyPlugin, InternalPluginMeta>()
export interface InternalPluginMeta {
handleError: (e: unknown) => Promise<void>
promises: Promise<void>[]
cleanups: PluginCleanup[]
iflags: number
apiLevel: number
dependents: AnyPlugin[]
options: PluginOptions<any>
}
export function registerPlugin<O extends PluginApiExtensionsOptions>(
manifest: PluginManifest,
@ -54,8 +57,7 @@ export function registerPlugin<O extends PluginApiExtensionsOptions>(
if (pList.has(manifest.id))
throw new Error(`Plugin with ID "${manifest.id}" already registered`)
const plugin: InternalPlugin = {
_c: [],
const plugin: AnyPlugin = {
errors: [],
manifest,
lifecycles: {
@ -69,25 +71,39 @@ export function registerPlugin<O extends PluginApiExtensionsOptions>(
flags,
disable: () => disablePlugin(plugin),
stop: () => stopPlugin(plugin),
api: undefined,
}
pMetadata.set(manifest.id, {
api: undefined,
const meta: InternalPluginMeta = {
cleanups: [],
promises: [],
iflags,
apiLevel: PluginApiLevel.None,
})
dependents: [],
handleError: e => handlePluginError(e, plugin),
options,
}
pMetadata.set(plugin, meta)
pList.set(manifest.id, plugin)
if (iflags & InternalPluginFlags.ImplicitDependency)
pLeafOrSingleNodes.add(plugin)
// Only add to pending if the plugin is enabled
else if (flags & PluginFlags.Enabled) pPending.add(plugin)
pEmitter.emit('register', plugin, options)
}
function handlePluginError(e: unknown, plugin: InternalPlugin) {
async function handlePluginError(e: unknown, plugin: AnyPlugin) {
plugin.errors.push(e)
plugin.flags |= Flag.Errored
const { api, iflags } = pMetadata.get(plugin.manifest.id)!
const { iflags } = pMetadata.get(plugin)!
;(api as InitPluginApi).logger.error('Plugin encountered an error', e)
;(plugin.api as InitPluginApi).logger.error(
'Plugin encountered an error',
e,
)
nativeLoggingHook(
`\u001b[31mPlugin "${plugin.manifest.id}" encountered an error: ${getErrorStack(e)}\u001b[0m`,
2,
@ -95,47 +111,61 @@ function handlePluginError(e: unknown, plugin: InternalPlugin) {
pEmitter.emit('errored', plugin, e)
if (!(iflags & InternalPluginFlags.Essential)) return plugin.disable()
if (!(iflags & InternalPluginFlags.Essential)) await plugin.disable()
}
function preparePluginPreInit(plugin: InternalPlugin) {
const meta = pMetadata.get(plugin.manifest.id)!
function preparePluginPreInit(plugin: AnyPlugin) {
const meta = pMetadata.get(plugin)!
// Clear errors from previous runs
plugin.errors = []
plugin.status &= ~Flag.Errored
const api = (meta.api = {
plugin.api = {
cleanup: (...items) => {
plugin._c.push(...items)
meta.cleanups.push(...items)
},
plugin,
unscoped: pUnscopedApi,
})
decorate: decorator => {
pDecorators.preInit.push([decorator, meta.handleError])
},
} satisfies PreInitPluginApi
decoratePluginApi(pDecorators.preInit, plugin, meta)
pEmitter.emit('preInit', plugin, api)
meta.apiLevel = PluginApiLevel.PreInit
}
function preparePluginInit(plugin: InternalPlugin) {
const meta = pMetadata.get(plugin.manifest.id)!
function preparePluginInit(plugin: AnyPlugin) {
const meta = pMetadata.get(plugin)!
const api = plugin.api as InitPluginApi
api.decorate = decorator => {
pDecorators.init.push([decorator, meta.handleError])
}
decoratePluginApi(pDecorators.init, plugin, meta)
pEmitter.emit('init', plugin, meta.api as InitPluginApi)
meta.apiLevel = PluginApiLevel.Init
}
function preparePluginStart(plugin: InternalPlugin) {
const meta = pMetadata.get(plugin.manifest.id)!
function preparePluginStart(plugin: AnyPlugin) {
const meta = pMetadata.get(plugin)!
const api = plugin.api as PluginApi
api.decorate = decorator => {
pDecorators.start.push([decorator, meta.handleError])
}
decoratePluginApi(pDecorators.start, plugin, meta)
pEmitter.emit('start', plugin, meta.api as PluginApi)
meta.apiLevel = PluginApiLevel.Start
}
async function disablePlugin(plugin: InternalPlugin) {
async function disablePlugin(plugin: AnyPlugin) {
if (!(plugin.flags & Flag.Enabled))
throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`)
const iflags = pMetadata.get(plugin.manifest.id)!.iflags ?? 0
const iflags = pMetadata.get(plugin)!.iflags ?? 0
if (iflags & InternalPluginFlags.Essential)
throw new Error(
`Plugin "${plugin.manifest.id}" is essential and cannot be disabled`,
@ -151,7 +181,7 @@ async function disablePlugin(plugin: InternalPlugin) {
pEmitter.emit('disabled', plugin)
}
export function enablePlugin(plugin: InternalPlugin, late: boolean) {
export function enablePlugin(plugin: AnyPlugin, late: boolean) {
if (plugin.flags & Flag.Enabled)
throw new Error(`Plugin "${plugin.manifest.id}" is already enabled`)
@ -162,7 +192,7 @@ export function enablePlugin(plugin: InternalPlugin, late: boolean) {
pEmitter.emit('enabled', plugin)
}
export async function preInitPlugin(plugin: InternalPlugin) {
export async function preInitPlugin(plugin: AnyPlugin) {
const {
manifest: { id },
lifecycles,
@ -170,8 +200,8 @@ export async function preInitPlugin(plugin: InternalPlugin) {
try {
if (!lifecycles.preInit) return
const meta = pMetadata.get(id)!
const { promises } = meta
const meta = pMetadata.get(plugin)!
const { promises, handleError } = meta
if (!(plugin.flags & Flag.Enabled))
throw new Error(`Plugin "${id}" is not enabled`)
@ -185,7 +215,7 @@ export async function preInitPlugin(plugin: InternalPlugin) {
plugin.status |= Status.PreIniting
try {
const prom = lifecycles.preInit(meta.api as PreInitPluginApi)
const prom = lifecycles.preInit(plugin.api as PreInitPluginApi)
promises.push(prom)
await prom
@ -193,14 +223,14 @@ export async function preInitPlugin(plugin: InternalPlugin) {
plugin.status |= Status.PreInited
plugin.status &= ~Status.PreIniting
} catch (e) {
await handlePluginError(e, plugin)
await handleError(e)
}
} finally {
pEmitter.emit('preInited', plugin)
}
}
export async function initPlugin(plugin: InternalPlugin) {
export async function initPlugin(plugin: AnyPlugin) {
const {
manifest: { id },
lifecycles,
@ -209,8 +239,8 @@ export async function initPlugin(plugin: InternalPlugin) {
try {
if (!lifecycles.init) return
const meta = pMetadata.get(id)!
const { promises, apiLevel } = meta
const meta = pMetadata.get(plugin)!
const { promises, apiLevel, handleError } = meta
if (!(plugin.flags & Flag.Enabled))
throw new Error(`Plugin "${id}" is not enabled`)
@ -225,7 +255,7 @@ export async function initPlugin(plugin: InternalPlugin) {
plugin.status |= Status.Initing
try {
const prom = lifecycles.init(meta.api as InitPluginApi)
const prom = lifecycles.init(plugin.api as InitPluginApi)
promises.push(prom)
await prom
@ -233,14 +263,14 @@ export async function initPlugin(plugin: InternalPlugin) {
plugin.status |= Status.Inited
plugin.status &= ~Status.Initing
} catch (e) {
await handlePluginError(e, plugin)
await handleError(e)
}
} finally {
pEmitter.emit('inited', plugin)
}
}
export async function startPlugin(plugin: InternalPlugin) {
export async function startPlugin(plugin: AnyPlugin) {
const {
manifest: { id },
lifecycles,
@ -249,8 +279,8 @@ export async function startPlugin(plugin: InternalPlugin) {
try {
if (!lifecycles.start) return
const meta = pMetadata.get(id)!
const { promises, apiLevel } = meta
const meta = pMetadata.get(plugin)!
const { promises, apiLevel, handleError } = meta
if (!(plugin.flags & Flag.Enabled))
throw new Error(`Plugin "${id}" is not enabled`)
@ -266,7 +296,7 @@ export async function startPlugin(plugin: InternalPlugin) {
plugin.status |= Status.Starting
try {
const prom = lifecycles.start(meta.api as PluginApi)
const prom = lifecycles.start(plugin.api as PluginApi)
promises.push(prom)
await prom
@ -274,7 +304,7 @@ export async function startPlugin(plugin: InternalPlugin) {
plugin.status |= Status.Started
plugin.status &= ~Status.Starting
} catch (e) {
await handlePluginError(e, plugin)
await handleError(e)
}
} finally {
pEmitter.emit('started', plugin)
@ -283,12 +313,12 @@ export async function startPlugin(plugin: InternalPlugin) {
const MaxWaitTime = 5000
export async function stopPlugin(plugin: InternalPlugin) {
export async function stopPlugin(plugin: AnyPlugin) {
const {
manifest: { id },
lifecycles,
} = plugin
const meta = pMetadata.get(id)!
const meta = pMetadata.get(plugin)!
const { promises, iflags, apiLevel } = meta
if (iflags & InternalPluginFlags.Essential)
@ -331,7 +361,7 @@ export async function stopPlugin(plugin: InternalPlugin) {
if (!lifecycles.stop) return
await Promise.race([
lifecycles.stop(meta.api as PluginApi),
lifecycles.stop(plugin.api as PluginApi),
sleepReject(
MaxWaitTime,
'Plugin stop lifecycle timed out, force stopping',
@ -350,7 +380,7 @@ export async function stopPlugin(plugin: InternalPlugin) {
try {
const results = await allSettled(
plugin._c.map(cleanup => cleanup()),
meta.cleanups.map(cleanup => cleanup()),
)
await Promise.all(
@ -365,12 +395,12 @@ export async function stopPlugin(plugin: InternalPlugin) {
}
// Reset APIs
meta.api = undefined
plugin.api = undefined
meta.apiLevel = PluginApiLevel.None
// Clear temp data
meta.promises = []
plugin._c = []
meta.cleanups = []
// Reset status
plugin.status = 0
@ -379,10 +409,6 @@ export async function stopPlugin(plugin: InternalPlugin) {
}
}
export interface InternalPlugin extends Plugin<any> {
_c: PluginCleanup[]
}
export const InternalPluginFlags = {
/**
* Marks the plugin as internal.
@ -392,6 +418,11 @@ export const InternalPluginFlags = {
* Marks the plugin as essential. This means it should not be removed, disabled, or stopped by normal means.
*/
Essential: 1 << 1,
/**
* Marks the plugin as a leaf node in the plugin graph, implicitly making it a dependency for every plugin (as they are executed first).
* This means it has no dependencies, but may have dependents, and it will be started first.
*/
ImplicitDependency: 1 << 2,
}
const PluginApiLevel = {

View file

@ -1,12 +1,8 @@
import * as AssetsLibrary from '@revenge-mod/assets'
import { initPlugin, pList } from './_internal'
import { pUnscopedApi } from './apis'
import { initPlugin } from './_internal'
import { computePendingNodes, pListOrdered } from './_internal/dependency-graph'
import { PluginFlags } from './constants'
import type { UnscopedInitPluginApi } from './types'
// Setup init plugin APIs
const uapi = pUnscopedApi as UnscopedInitPluginApi
uapi.assets = AssetsLibrary
computePendingNodes()
for (const plugin of pList.values())
for (const plugin of pListOrdered)
if (plugin.flags & PluginFlags.Enabled) initPlugin(plugin)

View file

@ -1,5 +1,8 @@
import { pList, preInitPlugin } from './_internal'
import { preInitPlugin } from './_internal'
import { computePendingNodes, pListOrdered } from './_internal/dependency-graph'
import { PluginFlags } from './constants'
for (const plugin of pList.values())
computePendingNodes()
for (const plugin of pListOrdered)
if (plugin.flags & PluginFlags.Enabled) preInitPlugin(plugin)

View file

@ -1,5 +1,8 @@
import { pList, startPlugin } from '@revenge-mod/plugins/_'
import { startPlugin } from './_internal'
import { computePendingNodes, pListOrdered } from './_internal/dependency-graph'
import { PluginFlags } from './constants'
for (const plugin of pList.values())
computePendingNodes()
for (const plugin of pListOrdered)
if (plugin.flags & PluginFlags.Enabled) startPlugin(plugin)

View file

@ -12,7 +12,7 @@ export interface PluginApiExtensionsOptions {}
* Available in the `preInit` phase.
*/
export interface UnscopedPreInitPluginApi<
// biome-ignore lint/correctness/noUnusedVariables: This is used for extensions
// biome-ignore lint/correctness/noUnusedVariables: This is for plugin API extensions
O extends PluginApiExtensionsOptions = PluginApiExtensionsOptions,
> {
modules: PluginApiModules
@ -48,9 +48,11 @@ export type PluginCleanupApi = (...fns: PluginCleanup[]) => void
* Available in the `preInit` phase.
*/
export interface PreInitPluginApi<
// biome-ignore lint/correctness/noUnusedVariables: This is used for extensions
O extends PluginApiExtensionsOptions = PluginApiExtensionsOptions,
> {
decorate(
decorator: (plugin: Plugin<O, 'PreInit'>, options: O) => void,
): void
unscoped: UnscopedPreInitPluginApi
cleanup: PluginCleanupApi
plugin: Plugin
@ -60,9 +62,11 @@ export interface PreInitPluginApi<
* The plugin API (limited).
* Available in the `init` phase.
*/
// @ts-expect-error
export interface InitPluginApi<
O extends PluginApiExtensionsOptions = PluginApiExtensionsOptions,
> extends PreInitPluginApi<O> {
decorate(decorator: (plugin: Plugin<O, 'Init'>, options: O) => void): void
unscoped: UnscopedInitPluginApi
}
@ -70,9 +74,11 @@ export interface InitPluginApi<
* The plugin API.
* Available in the `start` and `stop` phase.
*/
// @ts-expect-error
export interface PluginApi<
O extends PluginApiExtensionsOptions = PluginApiExtensionsOptions,
> extends InitPluginApi<O> {
decorate(decorator: (plugin: Plugin<O, 'Start'>, options: O) => void): void
unscoped: UnscopedPluginApi
}
@ -113,6 +119,22 @@ export interface PluginManifest {
* The icon of the plugin.
*/
icon?: string
/**
* The dependencies of the plugin.
*/
dependencies?: PluginDependency[]
}
export interface PluginDependency {
/**
* The ID of this dependency.
*/
id: string
// TODO(plugins): support plugin bundles
// /**
// * The bundle of this dependency.
// */
// bundle: PluginBundle
}
export interface PluginOptions<
@ -154,6 +176,7 @@ export interface PluginLifecycles<
export interface Plugin<
O extends PluginApiExtensionsOptions = PluginApiExtensionsOptions,
S extends keyof PluginApiInStage<O> = keyof PluginApiInStage<O>,
> {
// TODO(plugins): support plugin bundles
// /**
@ -197,6 +220,20 @@ export interface Plugin<
* Stop the plugin.
*/
stop(): Promise<void>
/**
* The plugin API.
*
* Not recommended to use this directly.
*/
api: PluginApiInStage<O>[S]
}
type PluginApiInStage<O extends PluginApiExtensionsOptions> = {
Register: undefined
PreInit: PreInitPluginApi<O>
Init: InitPluginApi<O>
Start: PluginApi<O>
}
export interface PluginSettingsComponent<

View file

@ -5,12 +5,6 @@
"exports": {
".": {
"default": "./src/index.ts"
},
"./preinit": {
"default": "./src/preinit.ts"
},
"./init": {
"default": "./src/init.ts"
}
}
}
}

View file

@ -0,0 +1,6 @@
import type { Storage, StorageSubscription } from '.'
export const instanceToSubscriptions = new WeakMap<
Storage<any>,
Set<StorageSubscription<any>>
>()

View file

@ -3,6 +3,7 @@
import { FileModule } from '@revenge-mod/discord/native'
import { getErrorStack } from '@revenge-mod/utils/error'
import { mergeDeep } from '@revenge-mod/utils/object'
import { instanceToSubscriptions } from './_internal'
import type { AnyObject, DeepPartial } from '@revenge-mod/utils/types'
export type StorageSubscription<T extends AnyObject = AnyObject> = (
@ -20,7 +21,8 @@ export function Storage<T extends AnyObject>(
const dirPath = directory === 'cache' ? CacheDirPath : DocumentsDirPath
const fullPath = `${dirPath}/${path}`
this._s = new Set<StorageSubscription<T>>()
const subscriptions = new Set<StorageSubscription<T>>()
instanceToSubscriptions.set(this, subscriptions)
this.loaded = false
this.cache = options?.default
@ -41,7 +43,7 @@ export function Storage<T extends AnyObject>(
this.loaded = true
try {
const cache = (this.cache = JSON.parse(contents))
for (const sub of this._s) sub(cache)
for (const sub of subscriptions) sub(cache)
return cache
} catch (e) {
nativeLoggingHook(
@ -60,7 +62,7 @@ export function Storage<T extends AnyObject>(
const contents = JSON.stringify(this.cache)
await FileModule.writeFile(directory, path, contents, 'utf8')
for (const sub of this._s) sub(value)
for (const sub of subscriptions) sub(value)
} catch (e) {
nativeLoggingHook(
`Failed to write storage (<${directory}>/${path}): ${getErrorStack(e)}`,
@ -114,10 +116,6 @@ export type UseStorageFilter<T extends AnyObject> = (
) => any
export interface Storage<T extends AnyObject> {
/**
* @internal
*/
_s: Set<StorageSubscription<T>>
/**
* Whether the storage has been loaded. If the storage is not loaded, `storage.cache` may be `undefined`.
* If you have `options.default` set, you can use this property to check if `storage.cache` is the default value or not.

View file

@ -1,40 +0,0 @@
import { pEmitter } from '@revenge-mod/plugins/_'
import { PluginsStorageDirectory } from '@revenge-mod/plugins/constants'
import { defineLazyProperty } from '@revenge-mod/utils/object'
import { useReRender } from '@revenge-mod/utils/react'
import { useEffect } from 'react'
import { getStorage, Storage } from '.'
import type { StorageSubscription } from '.'
// Actual implementation of Storage#use
const SP: Storage<any> = Storage.prototype
SP.use = function (filter) {
if (!this.cache) this.get()
const reRender = useReRender()
useEffect(() => {
const sub: StorageSubscription = filter
? v => {
if (filter(v)) reRender()
}
: reRender
this._s.add(sub)
return () => {
this._s.delete(sub)
}
}, [])
return this.cache
}
pEmitter.on('init', ({ _s: storageOptions, manifest: { id } }, api) =>
defineLazyProperty(api, 'storage', () =>
getStorage(`${PluginsStorageDirectory}/${id}.json`, {
...storageOptions,
directory: 'documents',
}),
),
)

View file

@ -1,5 +0,0 @@
import { pEmitter } from '@revenge-mod/plugins/_'
pEmitter.on('register', (plugin, options) => {
plugin._s = options.storage
})

View file

@ -1,18 +1,13 @@
import type {
PluginApiExtensionsOptions,
PluginManifest,
PluginOptions,
} from '@revenge-mod/plugins/types'
import type { AnyObject } from '@revenge-mod/utils/types'
import type { Storage, StorageOptions } from '.'
declare module '@revenge-mod/plugins/types' {
export interface PluginApiExtensionsOptions {
storage: AnyObject
storage?: AnyObject
}
export interface PluginOptions<O extends PluginApiExtensionsOptions> {
storage?: Omit<StorageOptions<O['storage']>, 'directory'>
storage?: Omit<StorageOptions<NonNullable<O['storage']>>, 'directory'>
}
export interface InitPluginApi<O extends PluginApiExtensionsOptions> {
@ -24,19 +19,6 @@ declare module '@revenge-mod/plugins/types' {
*
* To preload storage, simply call `api.storage.get()`.
*/
storage: Storage<O['storage']>
}
}
declare module '@revenge-mod/plugins/_' {
export function registerPlugin<E extends PluginApiExtensionsOptions>(
manifest: PluginManifest,
options: PluginOptions<E>,
flags: number,
iflags: number,
): void
export interface InternalPlugin {
_s?: StorageOptions<AnyObject>
storage: Storage<NonNullable<O['storage']>>
}
}

View file

@ -35,12 +35,6 @@
},
"./types": {
"types": "./src/types.ts"
},
"./preinit": {
"default": "./src/preinit.ts"
},
"./init": {
"default": "./src/init.ts"
}
}
}
}

View file

@ -1,7 +0,0 @@
import { pUnscopedApi } from '@revenge-mod/plugins/_'
import type { PluginApiUtils } from './preinit'
const utils = pUnscopedApi.utils as PluginApiUtils
utils.discord = require('@revenge-mod/utils/discord')
utils.react = require('@revenge-mod/utils/react')

View file

@ -1,34 +0,0 @@
import { pUnscopedApi } from '@revenge-mod/plugins/_'
pUnscopedApi.utils = {
callback: require('@revenge-mod/utils/callback'),
error: require('@revenge-mod/utils/error'),
object: require('@revenge-mod/utils/object'),
promise: require('@revenge-mod/utils/promise'),
proxy: require('@revenge-mod/utils/proxy'),
tree: require('@revenge-mod/utils/tree'),
}
export interface PreInitPluginApiUtils {
callback: typeof import('@revenge-mod/utils/callback')
error: typeof import('@revenge-mod/utils/error')
object: typeof import('@revenge-mod/utils/object')
promise: typeof import('@revenge-mod/utils/promise')
proxy: typeof import('@revenge-mod/utils/proxy')
tree: typeof import('@revenge-mod/utils/tree')
}
export interface PluginApiUtils extends PreInitPluginApiUtils {
react: typeof import('@revenge-mod/utils/react')
discord: typeof import('@revenge-mod/utils/discord')
}
declare module '@revenge-mod/plugins/types' {
export interface UnscopedPreInitPluginApi {
utils: PreInitPluginApiUtils
}
export interface UnscopedInitPluginApi {
utils: PluginApiUtils
}
}

View file

@ -12,3 +12,29 @@ export type DeepPartial<T> = {
export type ExtractPredicate<T> = T extends (arg: any) => arg is infer R
? R
: never
/// PLUGIN API EXTENSIONS
export interface PreInitPluginApiUtils {
callback: typeof import('@revenge-mod/utils/callback')
error: typeof import('@revenge-mod/utils/error')
object: typeof import('@revenge-mod/utils/object')
promise: typeof import('@revenge-mod/utils/promise')
proxy: typeof import('@revenge-mod/utils/proxy')
tree: typeof import('@revenge-mod/utils/tree')
}
export interface PluginApiUtils extends PreInitPluginApiUtils {
react: typeof import('@revenge-mod/utils/react')
discord: typeof import('@revenge-mod/utils/discord')
}
declare module '@revenge-mod/plugins/types' {
export interface UnscopedPreInitPluginApi {
utils: PreInitPluginApiUtils
}
export interface UnscopedInitPluginApi {
utils: PluginApiUtils
}
}

View file

@ -1,10 +1,5 @@
// Initialize init libraries
import '@revenge-mod/utils/init'
import '@revenge-mod/react/init'
import '@revenge-mod/storage/init'
import '@revenge-mod/externals/init'
import '@revenge-mod/discord/init'
import '@revenge-mod/components/init'
// Run all init plugins
import '~/plugins/init'

View file

@ -0,0 +1,23 @@
import * as assets from '@revenge-mod/assets'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
registerPlugin(
{
id: 'revenge.api.assets',
name: 'Assets API',
description: '@revenge-mod/assets API for plugins.',
author: 'Revenge',
icon: 'PollsIcon',
},
{
init({ unscoped }) {
unscoped.assets = assets
},
},
PluginFlags.Enabled,
// biome-ignore format: Don't format this
InternalPluginFlags.Internal |
InternalPluginFlags.Essential |
InternalPluginFlags.ImplicitDependency,
)

View file

@ -0,0 +1,27 @@
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { defineLazyProperties } from '@revenge-mod/utils/object'
registerPlugin(
{
id: 'revenge.api.components',
name: 'Components API',
description: '@revenge-mod/components API for plugins.',
author: 'Revenge',
icon: 'PollsIcon',
},
{
init({ unscoped }) {
defineLazyProperties(unscoped, {
components: () => {
return require('@revenge-mod/components')
},
})
},
},
PluginFlags.Enabled,
// biome-ignore format: Don't format this
InternalPluginFlags.Internal |
InternalPluginFlags.Essential |
InternalPluginFlags.ImplicitDependency,
)

View file

@ -0,0 +1,60 @@
import { Logger } from '@revenge-mod/discord/common'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import {
defineLazyProperties,
defineLazyProperty,
} from '@revenge-mod/utils/object'
import type { PluginApiDiscord } from '@revenge-mod/discord/types'
registerPlugin(
{
id: 'revenge.api.discord',
name: 'Discord API',
description: '@revenge-mod/discord API for plugins.',
author: 'Revenge',
icon: 'PollsIcon',
},
{
init({ unscoped, decorate }) {
decorate(plugin => {
defineLazyProperty(
plugin.api,
'logger',
() =>
new Logger(`Revenge > Plugins (${plugin.manifest.id})`),
)
})
defineLazyProperties((unscoped.discord = {} as PluginApiDiscord), {
actions: () => {
return require('@revenge-mod/discord/actions')
},
common: () => {
return require('@revenge-mod/discord/common')
},
design: () => {
return require('@revenge-mod/discord/design')
},
native: () => {
return require('@revenge-mod/discord/native')
},
modules: () =>
defineLazyProperties({} as PluginApiDiscord.Modules, {
mainTabsV2: () => {
return require('@revenge-mod/discord/modules/main_tabs_v2')
},
settings: () => ({
...require('@revenge-mod/discord/modules/settings'),
renderer: require('@revenge-mod/discord/modules/settings/renderer'),
}),
}),
})
},
},
PluginFlags.Enabled,
// biome-ignore format: Don't format this
InternalPluginFlags.Internal |
InternalPluginFlags.Essential |
InternalPluginFlags.ImplicitDependency,
)

View file

@ -0,0 +1,40 @@
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { defineLazyProperties } from '@revenge-mod/utils/object'
import type { PluginApiExternals } from '@revenge-mod/externals/types'
registerPlugin(
{
id: 'revenge.api.externals',
name: 'Externals API',
description: '@revenge-mod/externals API for plugins.',
author: 'Revenge',
icon: 'PollsIcon',
},
{
init({ unscoped }) {
defineLazyProperties(
(unscoped.externals = {} as PluginApiExternals),
{
Browserify: () => {
return require('@revenge-mod/externals/browserify')
},
ReactNativeClipboard: () => {
return require('@revenge-mod/externals/react-native-clipboard')
},
ReactNavigation: () => {
return require('@revenge-mod/externals/react-navigation')
},
Shopify: () => {
return require('@revenge-mod/externals/shopify')
},
},
)
},
},
PluginFlags.Enabled,
// biome-ignore format: Don't format this
InternalPluginFlags.Internal |
InternalPluginFlags.Essential |
InternalPluginFlags.ImplicitDependency,
)

View file

@ -2,14 +2,14 @@ import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { Design } from '@revenge-mod/discord/design'
import { useState } from 'react'
import type { AssetId } from '@revenge-mod/assets/types'
import type { InternalPlugin } from '@revenge-mod/plugins/_'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
export interface FilterAndSortActionSheetProps {
filters: Record<
string,
{
icon: AssetId
filter: (p: InternalPlugin, iflags: number) => boolean
filter: (p: AnyPlugin, iflags: number) => boolean
}
>
filter: string[]
@ -18,7 +18,7 @@ export interface FilterAndSortActionSheetProps {
setMatchAll: (matchAll: boolean) => void
sorts: Record<
string,
[icon: AssetId, (a: InternalPlugin, b: InternalPlugin) => number]
[icon: AssetId, (a: AnyPlugin, b: AnyPlugin) => number]
>
sort: string
setSort: (fn: string) => void

View file

@ -20,7 +20,7 @@ import {
setEnablePluginTooltipVisible,
setEssentialPluginTooltipVisible,
} from './TooltipProvider'
import type { InternalPlugin } from '@revenge-mod/plugins/_'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { View } from 'react-native'
const { Card, Text, Stack, IconButton, createStyles } = Design
@ -34,7 +34,7 @@ export function InstalledPluginCard({
enabled,
rightGap,
}: {
plugin: InternalPlugin
plugin: AnyPlugin
iflags: number
enabled: boolean
rightGap?: boolean

View file

@ -8,7 +8,7 @@ import { useReRender } from '@revenge-mod/utils/react'
import { useEffect } from 'react'
import { RouteNames, Setting } from '../constants'
import type { SettingsItem } from '@revenge-mod/discord/modules/settings'
import type { InternalPlugin } from '@revenge-mod/plugins/_'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
const RevengePluginsSetting: SettingsItem = {
parent: null,
@ -58,8 +58,8 @@ const { AlertActionButton, AlertModal, Text } = Design
pEmitter.on('started', showReloadRequiredAlertIfNeeded)
pEmitter.on('stopped', showReloadRequiredAlertIfNeeded)
function showReloadRequiredAlertIfNeeded(plugin: InternalPlugin) {
if (plugin.flags & PluginFlags.ReloadRequired) {
function showReloadRequiredAlertIfNeeded(plugin: AnyPlugin) {
if (plugin.flags & PluginFlags.ReloadRequired)
AlertActionCreators.openAlert(
'plugin-reload-required',
<AlertModal
@ -87,7 +87,6 @@ function showReloadRequiredAlertIfNeeded(plugin: InternalPlugin) {
}
/>,
)
}
}
export default RevengePluginsSetting

View file

@ -1,6 +1,6 @@
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { registerSettingsItem } from '@revenge-mod/discord/modules/settings'
import { pEmitter, pMetadata } from '@revenge-mod/plugins/_'
import { pEmitter } from '@revenge-mod/plugins/_'
import { lazy, useLayoutEffect } from 'react'
import { Setting } from './constants'
import type { StackScreenProps } from '@react-navigation/stack'
@ -13,7 +13,7 @@ const NavigatorHeaderWithIcon = lazy(
pEmitter.on('started', plugin => {
if (plugin.SettingsComponent) {
const api = pMetadata.get(plugin.manifest.id)!.api as PluginApi<any>
const api = plugin.api as PluginApi<any>
function PluginSettings({
navigation,

View file

@ -22,7 +22,7 @@ import {
import { useUpdateOnPluginStatesChange } from '../definitions/RevengePluginsSetting'
import type { RouteProp } from '@react-navigation/core'
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
import type { InternalPlugin } from '@revenge-mod/plugins/_'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { FilterAndSortActionSheetProps } from '../components/FilterAndSortActionSheet'
import type { RouteNames, Setting } from '../constants'
@ -108,11 +108,7 @@ function Screen() {
const allPlugins = useMemo(
() =>
[...pList.values()].map(
plugin =>
[
plugin,
pMetadata.get(plugin.manifest.id)!.iflags,
] as const,
plugin => [plugin, pMetadata.get(plugin)!.iflags] as const,
),
[],
)
@ -194,7 +190,7 @@ function Screen() {
function PluginMasonryFlashList({
plugins,
}: {
plugins: (readonly [InternalPlugin, number])[]
plugins: (readonly [AnyPlugin, number])[]
}) {
const { width, height } = useWindowDimensions()
const numColumns = Math.floor((width - 16) / 448)

View file

@ -0,0 +1,30 @@
import { Storage } from '@revenge-mod/storage'
import { useReRender } from '@revenge-mod/utils/react'
import { useEffect } from 'react'
import { instanceToSubscriptions } from '../../../../lib/storage/src/_internal'
import type { StorageSubscription } from '@revenge-mod/storage'
// Actual implementation of Storage#use
const SP: Storage<any> = Storage.prototype
SP.use = function (filter) {
if (!this.cache) this.get()
const reRender = useReRender()
const subscriptions = instanceToSubscriptions.get(this)!
useEffect(() => {
const sub: StorageSubscription = filter
? v => {
if (filter(v)) reRender()
}
: reRender
subscriptions.add(sub)
return () => {
subscriptions.delete(sub)
}
}, [filter, reRender, subscriptions.add, subscriptions.delete])
return this.cache
}

View file

@ -0,0 +1,48 @@
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import {
PluginFlags,
PluginsStorageDirectory,
} from '@revenge-mod/plugins/constants'
import { getStorage } from '@revenge-mod/storage'
import { defineLazyProperty } from '@revenge-mod/utils/object'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { StorageOptions } from '@revenge-mod/storage'
const storageOptions = new WeakMap<AnyPlugin, StorageOptions>()
registerPlugin(
{
id: 'revenge.api.storage',
name: 'Storage API',
description: '@revenge-mod/storage API for plugins.',
author: 'Revenge',
icon: 'PollsIcon',
},
{
preInit({ decorate }) {
decorate((plugin, { storage }) => {
if (storage) storageOptions.set(plugin, storage)
})
},
init({ decorate }) {
require('./define-storage-use')
decorate(plugin => {
defineLazyProperty(plugin.api, 'storage', () =>
getStorage(
`${PluginsStorageDirectory}/${plugin.manifest.id}.json`,
{
...storageOptions.get(plugin),
directory: 'documents',
},
),
)
})
},
},
PluginFlags.Enabled,
// biome-ignore format: Don't format this
InternalPluginFlags.Internal |
InternalPluginFlags.Essential |
InternalPluginFlags.ImplicitDependency,
)

View file

@ -0,0 +1,33 @@
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
registerPlugin(
{
id: 'revenge.api.utils',
name: 'Utils API',
description: '@revenge-mod/utils API for plugins.',
author: 'Revenge',
icon: 'PollsIcon',
},
{
preInit({ unscoped }) {
unscoped.utils = {
callback: require('@revenge-mod/utils/callback'),
error: require('@revenge-mod/utils/error'),
object: require('@revenge-mod/utils/object'),
promise: require('@revenge-mod/utils/promise'),
proxy: require('@revenge-mod/utils/proxy'),
tree: require('@revenge-mod/utils/tree'),
}
},
init({ unscoped: { utils } }) {
utils.discord = require('@revenge-mod/utils/discord')
utils.react = require('@revenge-mod/utils/react')
},
},
PluginFlags.Enabled,
// biome-ignore format: Don't format this
InternalPluginFlags.Internal |
InternalPluginFlags.Essential |
InternalPluginFlags.ImplicitDependency,
)

View file

@ -19,10 +19,8 @@ onModuleFirstRequired(IndexModuleId, function onIndexRequired() {
nativeLoggingHook(`\u001b[31m--- PREINIT STAGE ---\u001b[0m`, 1)
// Initialize preinit libraries
require('@revenge-mod/utils/preinit')
require('@revenge-mod/react/preinit')
require('@revenge-mod/assets/preinit')
require('@revenge-mod/storage/preinit')
require('@revenge-mod/discord/preinit')
// Run all preinit plugins