feat(lib/plugins): external plugins support

This is the first draft. Things may change in the future.
Loader changes are required as well.
This commit is contained in:
PalmDevs 2026-07-07 00:03:14 +07:00
parent 00047da9b3
commit bf8b99efb9
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
21 changed files with 460 additions and 111 deletions

View file

@ -1,5 +1,5 @@
import { getInternalPluginMeta, getPluginDependencies } from '.'
import type { Plugin, PluginApiDecorator } from '../types'
import type { PluginApiDecorator } from '../types'
import type { AnyPlugin, InternalPluginMeta } from '.'
export type PluginApiDecoratorStore<T extends 'PreInit' | 'Init' | 'Start'> =
@ -29,7 +29,7 @@ export function addPluginApiDecorator(
export function decoratePluginApi(
store: PluginApiDecoratorStore<any>,
plugin: Plugin<any, any>,
plugin: AnyPlugin,
meta: InternalPluginMeta,
) {
// Don't decorate API plugins with API plugins...

View file

@ -0,0 +1,101 @@
import {
callBridgeMethod,
callBridgeMethodSync,
registerJSMethod,
} from '@revenge-mod/modules/native'
import { getErrorStack } from '@revenge-mod/utils/error'
import { pUnscopedApi } from '../apis'
import {
disablePlugin,
InternalPluginFlags,
isPluginEnabled,
PluginFlags,
pEmitter,
pList,
registerPlugin,
runPluginLate,
} from '.'
import type { PluginManifest, PluginOptionsFactory } from '../types'
import type { AnyPlugin } from '.'
interface ExternalPlugin {
manifest: PluginManifest
script?: string
internal?: boolean
essential?: boolean
enabledByDefault?: boolean
}
export function registerExternalPlugins() {
registerJSMethod(
'revenge.plugins.loader.pluginInstalled',
(external: ExternalPlugin) => {
loadExternalPlugin(external)
},
)
const externals = callBridgeMethodSync(
'revenge.plugins.loader.getPlugins',
[],
)
if (!externals) return
for (const external of externals)
try {
// Skip plugins whose JS counterpart is already registered
if (pList.has(external.manifest.id)) continue
registerExternalPlugin(external)
} catch (e) {
nativeLoggingHook(
`\u001b[31mFailed to register external plugin "${external.manifest.id}": ${getErrorStack(e)}\u001b[0m`,
2,
)
}
}
export function registerExternalPlugin({
manifest,
script,
internal,
essential,
enabledByDefault,
}: ExternalPlugin) {
return registerPlugin(
manifest,
createOptionsFactory(script),
essential || enabledByDefault ? PluginFlags.Enabled : 0,
(internal ? InternalPluginFlags.Internal : 0) |
(essential ? InternalPluginFlags.Essential : 0),
)
}
export async function loadExternalPlugin(external: ExternalPlugin) {
const { id } = registerExternalPlugin(external)
const plugin = pList.get(id)
if (plugin && isPluginEnabled(plugin)) await runPluginLate(plugin)
}
export async function uninstallExternalPlugin(plugin: AnyPlugin) {
if (isPluginEnabled(plugin)) await disablePlugin(plugin)
callBridgeMethod('revenge.plugins.loader.uninstallPlugin', [
plugin.manifest.id,
])
pList.delete(plugin.manifest.id)
pEmitter.emit('unregister', plugin)
}
function createOptionsFactory(script?: string): PluginOptionsFactory {
if (!script) return () => ({})
return () => new Function('revenge', `return ${script}`)(pUnscopedApi)
}
declare module '@revenge-mod/modules/native' {
interface Methods {
'revenge.plugins.loader.getPlugins': [[], ExternalPlugin[] | null]
'revenge.plugins.loader.installPlugin': [[], null]
'revenge.plugins.loader.uninstallPlugin': [[string], null]
}
}

View file

@ -2,15 +2,12 @@ import { TypedEventEmitter } from '@revenge-mod/discord/common/utils'
import {
callBridgeMethod,
callBridgeMethodSync,
registerJSMethod,
} from '@revenge-mod/modules/native'
import { getErrorStack } from '@revenge-mod/utils/error'
import { sleepReject } from '@revenge-mod/utils/promise'
import { pUnscopedApi as uapi } from '../apis'
import {
PluginFlags as Flag,
PersistentPluginFlags,
PluginStatus as Status,
} from '../constants'
import { PluginStatus as Status } from '../constants'
import {
addPluginApiDecorator,
decoratePluginApi,
@ -34,13 +31,28 @@ import type {
PluginDependency,
PluginManifest,
PluginOptions,
PluginOptionsFactory,
PreInitPluginApi,
} from '../types'
export type AnyPlugin = Plugin<any, any>
export type AnyPlugin = Plugin<any, any> & {
/** @internal */
flags: number
}
const MaxWaitTime = 5000
// TODO: Maybe remove this later?
// This is never used in bridge, but still used internally by JS.
export const PluginFlags = {
Enabled: 1 << 0,
ReloadRequired: 1 << 1,
Errored: 1 << 2,
EnabledLate: 1 << 3,
}
const Flag = PluginFlags
const PluginApiLevel = {
None: 0,
PreInit: 1,
@ -73,12 +85,14 @@ export interface InternalPluginMeta {
dependents: AnyPlugin[]
dependencies?: AnyPlugin[]
options: PluginOptions<any>
optionsFactory?: PluginOptionsFactory<any>
flags: number
}
export const pUnscopedApi = uapi
export const pEmitter = new TypedEventEmitter<{
register: [AnyPlugin, PluginOptions<any>, update?: true]
unregister: [AnyPlugin]
disabled: [AnyPlugin]
enabled: [AnyPlugin]
preInited: [AnyPlugin]
@ -92,17 +106,45 @@ export const pEmitter = new TypedEventEmitter<{
export const pList = new Map<PluginManifest['id'], AnyPlugin>()
const pMetadata = new WeakMap<AnyPlugin, InternalPluginMeta>()
const { flags: PersistedFlags }: PersistedPluginStates = callBridgeMethodSync(
'revenge.plugins.states.read',
[],
) ?? {
flags: {},
const { states: InitialPersistedStates }: PersistedPluginStates =
callBridgeMethodSync('revenge.plugins.states.read', []) ?? {
states: {},
}
// Native is the source of truth, we're expecting dispatches from native to sync states
registerJSMethod('revenge.plugins.states.update', (id, state) => {
applyPluginFlags(
id as PluginManifest['id'],
pluginStateToFlags(state as PluginStateObject),
)
})
function pluginStateToFlags(state: PluginStateObject): number {
return (
(state.enabled ? Flag.Enabled : 0) |
(state.reloadRequired ? Flag.ReloadRequired : 0) |
(state.errored ? Flag.Errored : 0) |
(state.enabledLate ? Flag.EnabledLate : 0)
)
}
pEmitter.on('flagUpdate', plugin => {
PersistedFlags[plugin.manifest.id] = plugin.flags & PersistentPluginFlags
callBridgeMethod('revenge.plugins.states.write', [PersistedFlags])
})
async function applyPluginFlags(id: PluginManifest['id'], flags: number) {
const plugin = pList.get(id)
if (!plugin || plugin.flags === flags) return
const wasEnabled = plugin.flags & Flag.Enabled
const nowEnabled = flags & Flag.Enabled
if (wasEnabled && !nowEnabled) {
if (plugin.status && !(plugin.status & Status.Stopping))
await stopPlugin(plugin)
plugin.flags = flags
pEmitter.emit('disabled', plugin)
} else {
plugin.flags = flags
if (!wasEnabled && nowEnabled) pEmitter.emit('enabled', plugin)
}
}
/**
* Registers a new plugin with the system.
@ -114,7 +156,7 @@ pEmitter.on('flagUpdate', plugin => {
*/
export function registerPlugin<O extends PluginApiExtensionsOptions>(
manifest: PluginManifest,
options: PluginOptions<O>,
options: PluginOptions<O> | PluginOptionsFactory<O>,
defflags: number,
iflags: number,
) {
@ -122,19 +164,25 @@ export function registerPlugin<O extends PluginApiExtensionsOptions>(
if (pList.has(manifest.id))
throw new Error(`Plugin with ID "${manifest.id}" already registered`)
const factory = typeof options === 'function' ? options : undefined
const resolved = typeof options === 'function' ? undefined : options
const plugin = {
errors: [],
manifest,
lifecycles: {
preInit: options.preInit,
init: options.init,
start: options.start,
stop: options.stop,
preInit: resolved?.preInit,
init: resolved?.init,
start: resolved?.start,
stop: resolved?.stop,
},
SettingsComponent: options.SettingsComponent,
SettingsComponent: resolved?.SettingsComponent,
status: 0,
disable: () => disablePlugin(plugin),
stop: () => stopPlugin(plugin),
requireReload: () => {
plugin.flags |= Flag.ReloadRequired
},
api: undefined,
set flags(flags: number) {
if (meta.flags === flags) return
@ -153,8 +201,11 @@ export function registerPlugin<O extends PluginApiExtensionsOptions>(
apiLevel: PluginApiLevel.None,
dependents: [],
handleError: e => handlePluginError(e, plugin),
options,
flags: PersistedFlags[manifest.id] ?? defflags,
options: resolved ?? {},
optionsFactory: factory,
flags: InitialPersistedStates[manifest.id]
? pluginStateToFlags(InitialPersistedStates[manifest.id])
: defflags,
}
pMetadata.set(plugin, meta)
@ -167,7 +218,7 @@ export function registerPlugin<O extends PluginApiExtensionsOptions>(
// Only add to pending if the plugin is enabled
else if (isPluginEnabled(plugin)) pPending.add(plugin)
pEmitter.emit('register', plugin, options)
pEmitter.emit('register', plugin, meta.options)
return { id: manifest.id } satisfies PluginDependency
}
@ -206,8 +257,16 @@ export function getPluginDependencies(plugin: AnyPlugin): AnyPlugin[] {
return (meta.dependencies = deps)
}
export function isPluginEnabled({ flags }: AnyPlugin): boolean {
return Boolean(flags & Flag.Enabled)
export function isPluginEnabled(plugin: Plugin<any, any>): boolean {
return Boolean((plugin as AnyPlugin).flags & Flag.Enabled)
}
export function isPluginEnabledLate(plugin: Plugin<any, any>): boolean {
return Boolean((plugin as AnyPlugin).flags & Flag.EnabledLate)
}
export function isPluginReloadRequired(plugin: Plugin<any, any>): boolean {
return Boolean((plugin as AnyPlugin).flags & Flag.ReloadRequired)
}
export function isPluginEssential({ iflags }: InternalPluginMeta): boolean {
@ -218,8 +277,8 @@ export function isPluginInternal({ iflags }: InternalPluginMeta): boolean {
return Boolean(iflags & InternalPluginFlags.Internal)
}
export function isPluginErrored(plugin: AnyPlugin) {
return Boolean(plugin.flags & Flag.Errored)
export function isPluginErrored(plugin: Plugin<any, any>): boolean {
return Boolean((plugin as AnyPlugin).flags & Flag.Errored)
}
function guardPluginEnabled(plugin: AnyPlugin) {
@ -246,6 +305,29 @@ async function handlePluginError(e: unknown, plugin: AnyPlugin) {
await plugin.disable()
}
/**
* Resolves a deferred options factory, populating the plugin's lifecycles and options.
*/
function resolvePluginOptions(plugin: AnyPlugin, meta: InternalPluginMeta) {
const { optionsFactory } = meta
if (!optionsFactory) return
meta.optionsFactory = undefined
try {
const options = optionsFactory()
meta.options = options
plugin.lifecycles.preInit = options.preInit
plugin.lifecycles.init = options.init
plugin.lifecycles.start = options.start
plugin.lifecycles.stop = options.stop
plugin.SettingsComponent = options.SettingsComponent
} catch (e) {
meta.handleError(e)
}
}
/**
* Prepares the plugin API for the preInit lifecycle.
*/
@ -257,6 +339,8 @@ function tryPreparePluginPreInit(plugin: AnyPlugin) {
plugin.errors = []
plugin.flags &= ~Flag.Errored
resolvePluginOptions(plugin, meta)
plugin.api = {
cleanup: (...items) => {
meta.cleanups.push(...items)
@ -333,6 +417,11 @@ export async function disablePlugin(plugin: AnyPlugin) {
plugin.flags &= ~Flag.Enabled
pEmitter.emit('disabled', plugin)
callBridgeMethod('revenge.plugins.states.setEnabled', [
plugin.manifest.id,
false,
])
}
/**
@ -350,6 +439,11 @@ export async function enablePlugin(plugin: AnyPlugin) {
plugin.flags |= Flag.Enabled
pEmitter.emit('enabled', plugin)
callBridgeMethod('revenge.plugins.states.setEnabled', [
plugin.manifest.id,
true,
])
}
export async function runPluginLate(plugin: AnyPlugin) {
@ -370,6 +464,12 @@ export async function runPluginLate(plugin: AnyPlugin) {
.map(async function runLate(plugin) {
plugin.flags |= Flag.EnabledLate
// Plugin would already be started native-side if it was not started late
// But on late starts (enable, fresh install), we must start the native side too
callBridgeMethod('revenge.plugins.loader.start', [
plugin.manifest.id,
])
// Prepare the plugin API
await preInitPlugin(plugin)
await initPlugin(plugin)
@ -603,17 +703,28 @@ export function getInternalPluginMeta(plugin: AnyPlugin): InternalPluginMeta {
return pMetadata.get(plugin)!
}
export { uninstallExternalPlugin } from './external-plugins'
declare module '@revenge-mod/modules/native' {
interface Methods {
'revenge.plugins.loader.start': [[id: PluginManifest['id']], null]
'revenge.plugins.states.read': [[], PersistedPluginStates | null]
'revenge.plugins.states.write': [[statuses: PersistedPluginFlags], void]
'revenge.plugins.states.setEnabled': [
[id: PluginManifest['id'], enabled: boolean],
null,
]
}
}
interface PersistedPluginStates {
flags: PersistedPluginFlags
interface PluginStateObject {
enabled?: boolean
reloadRequired?: boolean
errored?: boolean
enabledLate?: boolean
}
interface PersistedPluginFlags {
[id: PluginManifest['id']]: number
interface PersistedPluginStates {
states: {
[id: PluginManifest['id']]: PluginStateObject
}
}

View file

@ -1,31 +1,3 @@
/**
* The plugin flags.
*/
export const PluginFlags = {
/**
* The plugin is enabled.
*/
Enabled: 1 << 0,
/**
* The plugin requires a reload to apply changes.
*/
ReloadRequired: 1 << 1,
/**
* The plugin has errors.
*/
Errored: 1 << 2,
/**
* The plugin was enabled after the app was started.
* This is usually caused by a newly installed plugin, or a plugin that was re-enabled.
*/
EnabledLate: 1 << 3,
}
/**
* A bitmask of {@link PluginFlags} that are persisted to storage.
*/
export const PersistentPluginFlags = PluginFlags.Enabled
/**
* The plugin status.
*/

View file

@ -1,6 +1,8 @@
import { isPluginEnabled, preInitPlugin } from './_internal'
import { computePendingNodes, pListOrdered } from './_internal/dependency-graph'
import { registerExternalPlugins } from './_internal/external-plugins'
registerExternalPlugins()
computePendingNodes()
for (const plugin of pListOrdered)

View file

@ -4,7 +4,7 @@ import type { PluginApiExternals } from './apis/externals'
import type { PluginApiModules } from './apis/modules'
import type { PluginApiPlugins } from './apis/plugins'
import type { PluginApiReact } from './apis/react'
import type { PluginFlags, PluginStatus } from './constants'
import type { PluginStatus } from './constants'
// biome-ignore lint/suspicious/noEmptyInterface: To be extended by actual extensions
export interface PluginApiExtensionsOptions {}
@ -200,6 +200,16 @@ export interface PluginOptions<
SettingsComponent?: PluginSettingsComponent<O>
}
/**
* A factory that lazily creates the plugin options.
*
* Only passed when the options (and lifecycles) must only be created right before the plugin runs,
* for example to avoid evaluating external plugin code until it is actually used.
*/
export type PluginOptionsFactory<
O extends PluginApiExtensionsOptions = PluginApiExtensionsOptions,
> = () => PluginOptions<O>
/**
* The plugin lifecycles.
*/
@ -247,10 +257,6 @@ export interface Plugin<
manifest: PluginManifest
lifecycles: PluginLifecycles<O>
/**
* @see {@link PluginFlags}
*/
flags: number
/**
* @see {@link PluginStatus}
*/
@ -271,6 +277,10 @@ export interface Plugin<
* Stop the plugin.
*/
stop(this: Plugin<O, S>): Promise<void>
/**
* Marks this plugin as requiring a reload to apply changes.
*/
requireReload(this: Plugin<O, S>): void
/**
* The plugin API.

View file

@ -1,5 +1,8 @@
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
} from '@revenge-mod/plugins/_'
import { defineLazyProperty } from '@revenge-mod/utils/object'
registerPlugin(

View file

@ -1,17 +1,17 @@
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import {
InternalPluginFlags,
PluginFlags,
PluginsStorageDirectory,
} from '@revenge-mod/plugins/constants'
registerPlugin,
} from '@revenge-mod/plugins/_'
import { PluginsStorageDirectory } from '@revenge-mod/plugins/constants'
import * as storage from '@revenge-mod/storage'
import { getStorage } from '@revenge-mod/storage'
import { defineLazyProperty } from '@revenge-mod/utils/object'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { InitPluginApi, Plugin } from '@revenge-mod/plugins/types'
import type { StorageOptions } from '@revenge-mod/storage'
import type { AnyObject } from '@revenge-mod/utils/types'
const storageOptions = new WeakMap<AnyPlugin, StorageOptions>()
const storageOptions = new WeakMap<Plugin<any, any>, StorageOptions>()
registerPlugin(
{
@ -55,11 +55,11 @@ registerPlugin(
InternalPluginFlags.API,
)
function getStoragePathForPlugin(plugin: AnyPlugin) {
function getStoragePathForPlugin(plugin: Plugin<any, any>) {
return `${PluginsStorageDirectory}/${plugin.manifest.id}.json`
}
export async function deleteStorageForPlugin(plugin: AnyPlugin) {
export async function deleteStorageForPlugin(plugin: Plugin<any, any>) {
const api = plugin.api as InitPluginApi<{ storage: AnyObject }> | undefined
const storage = api?.storage ?? getStorage(getStoragePathForPlugin(plugin))
console.log(api?.storage, storage, getStoragePathForPlugin(plugin))

View file

@ -1,5 +1,8 @@
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
} from '@revenge-mod/plugins/_'
import defer * as UtilsCallback from '@revenge-mod/utils/callback'
import defer * as UtilsDiscord from '@revenge-mod/utils/discord'
import defer * as UtilsError from '@revenge-mod/utils/error'

View file

@ -1,6 +1,9 @@
import { AppStartPerformance } from '@revenge-mod/discord/preinit'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
} from '@revenge-mod/plugins/_'
import { BuildEnvironment, FullVersion } from '~/constants'
if (__DEV__) {

View file

@ -2,8 +2,12 @@ import { waitForModules } from '@revenge-mod/modules/finders'
import { withProps } from '@revenge-mod/modules/finders/filters'
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
import { instead } from '@revenge-mod/patcher'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import {
InternalPluginFlags,
isPluginEnabledLate,
PluginFlags,
registerPlugin,
} from '@revenge-mod/plugins/_'
import { noop } from '@revenge-mod/utils/callback'
import { getCurrentStack } from '@revenge-mod/utils/error'
@ -45,8 +49,7 @@ registerPlugin(
},
{
preInit({ cleanup, plugin }) {
if (plugin.flags & PluginFlags.EnabledLate)
plugin.flags |= PluginFlags.ReloadRequired
if (isPluginEnabledLate(plugin)) plugin.requireReload()
// utils/SentryUtils.native.tsx
const unsubSU = waitForModules(
@ -206,7 +209,7 @@ registerPlugin(
cleanup(unsubTI, unsubATAC, unsubHTTPUtils)
},
stop({ plugin }) {
plugin.flags |= PluginFlags.ReloadRequired
plugin.requireReload()
},
},
PluginFlags.Enabled,

View file

@ -3,8 +3,12 @@ import {
refreshSettingsNavigator,
refreshSettingsOverviewScreen,
} from '@revenge-mod/discord/modules/settings'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import {
InternalPluginFlags,
isPluginEnabledLate,
PluginFlags,
registerPlugin,
} from '@revenge-mod/plugins/_'
import pluginSettings from '../settings'
import * as dt from './devtools'
import defer * as rdt from './react-devtools'
@ -52,7 +56,7 @@ registerPlugin<{ storage: Storage }>(
onSettingsModulesLoaded(utils.register)
if (api_.plugin.flags & PluginFlags.EnabledLate) {
if (isPluginEnabledLate(api_.plugin)) {
refreshSettingsOverviewScreen()
refreshSettingsNavigator()
}

View file

@ -1,8 +1,11 @@
import { waitForModules } from '@revenge-mod/modules/finders'
import { withName } from '@revenge-mod/modules/finders/filters'
import { instead } from '@revenge-mod/patcher'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
} from '@revenge-mod/plugins/_'
import ErrorBoundaryScreen from './components/ErrorBoundaryScreen'
import type { Component, ReactNode } from 'react'

View file

@ -10,8 +10,10 @@ import {
getInternalPluginMeta,
InternalPluginFlags,
isPluginEssential,
isPluginInternal,
PluginFlags,
} from '@revenge-mod/plugins/_'
import { PluginFlags, PluginStatus } from '@revenge-mod/plugins/constants'
import { PluginStatus } from '@revenge-mod/plugins/constants'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { getErrorStack } from '@revenge-mod/utils/error'
import { Pressable } from 'react-native'
@ -20,6 +22,7 @@ import { handleDisablePlugin, handleEnablePlugin } from '../utils/actions'
import {
openPluginSettings,
showPluginClearDataConfirmation,
showPluginUninstallConfirmation,
} from '../utils/alerts'
import { PluginInfo } from './PluginCard'
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
@ -39,6 +42,7 @@ const { ActionSheet, IconButton, TableRowGroup, TableRow, Stack } = Design
const FileWarningIcon = getAssetIdByName('FileWarningIcon', 'png')!
const SettingsIcon = getAssetIdByName('SettingsIcon', 'png')!
const TrashIcon = getAssetIdByName('TrashIcon', 'png')!
export default function PluginOptionsActionSheet({
plugin,
@ -179,6 +183,7 @@ function PluginActions({
const enabled = usePluginEnabled(plugin)
const enableTooltip = useEnablePluginTooltip()
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
const meta = getInternalPluginMeta(plugin)
return (
<Stack
@ -195,6 +200,17 @@ function PluginActions({
showPluginClearDataConfirmation(plugin, closeSheet)
}}
/>
{!isPluginInternal(meta) && (
<IconButton
variant="destructive"
size="lg"
icon={TrashIcon}
label="Uninstall"
onPress={() => {
showPluginUninstallConfirmation(plugin, closeSheet)
}}
/>
)}
{plugin.SettingsComponent && (
<Pressable
onPress={() => {

View file

@ -0,0 +1,36 @@
import { Design } from '@revenge-mod/discord/design'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
const { AlertModal, AlertActionButton, Text } = Design
export default function PluginUninstallConfirmationAlert({
plugin,
action,
}: {
plugin: AnyPlugin
action: () => Promise<void>
}) {
return (
<AlertModal
title="Uninstall plugin?"
content={
<Text color="text-default">
<Text variant="text-md/semibold" color="text-default">
{plugin.manifest.name}
</Text>{' '}
and all of its data will be removed. This cannot be undone.
</Text>
}
actions={
<>
<AlertActionButton
onPress={action}
text="Uninstall"
variant="destructive"
/>
<AlertActionButton text="Cancel" variant="secondary" />
</>
}
/>
)
}

View file

@ -4,8 +4,11 @@ import { waitForModuleWithImportedPath } from '@revenge-mod/discord/utils/module
import { waitForModules } from '@revenge-mod/modules/finders'
import { withName } from '@revenge-mod/modules/finders/filters'
import { instead } from '@revenge-mod/patcher'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
} from '@revenge-mod/plugins/_'
import { React } from '@revenge-mod/react'
import { asap, noop } from '@revenge-mod/utils/callback'
import { getCurrentStack } from '@revenge-mod/utils/error'

View file

@ -4,8 +4,7 @@ import {
refreshSettingsNavigator,
registerSettingsItem,
} from '@revenge-mod/discord/modules/settings'
import { pEmitter, pList } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { PluginFlags, pEmitter, pList } from '@revenge-mod/plugins/_'
import { useLayoutEffect } from 'react'
import defer * as NavigatorHeaderWithIcon from './components/NavigatorHeaderWithIcon'
import PluginsFailedToStartAlert from './components/PluginsFailedToStartAlert'

View file

@ -5,17 +5,25 @@ import Page from '@revenge-mod/components/Page'
import SearchInput from '@revenge-mod/components/SearchInput'
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { callBridgeMethod } from '@revenge-mod/modules/native'
import {
getInternalPluginMeta,
InternalPluginFlags,
isPluginEnabled,
isPluginEssential,
isPluginInternal,
PluginFlags,
pEmitter,
pList,
} from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { debounce } from '@revenge-mod/utils/callback'
import { useCallback, useMemo, useState } from 'react'
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react'
import { View } from 'react-native'
import { ClickOutsideProvider } from 'react-native-click-outside'
import RevengeIcon from '~assets/RevengeIcon'
@ -30,9 +38,10 @@ import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navi
import type { FilterAndSortActionSheetProps } from '../components/FilterAndSortActionSheet'
import type { RouteNames, Setting } from '../constants'
const { Stack, IconButton, LayerScope } = Design
const { Stack, IconButton, LayerScope, ContextMenu } = Design
const FiltersHorizontalIcon = getAssetIdByName('FiltersHorizontalIcon', 'png')!
const MoreHorizontalIcon = getAssetIdByName('MoreHorizontalIcon')!
export default function RevengePluginsSettingScreen() {
return (
@ -98,6 +107,39 @@ const Sorts = {
],
} satisfies FilterAndSortActionSheetProps['sorts']
function HeaderContextMenu() {
return (
<ContextMenu
title="More options"
align="below"
items={[
{
label: 'Install from file',
action: () =>
callBridgeMethod(
'revenge.plugins.loader.installPlugin',
[],
),
},
]}
>
{props => (
<IconButton
{...props}
icon={MoreHorizontalIcon}
variant="tertiary"
/>
)}
</ContextMenu>
)
}
function snapshotPlugins() {
return [...pList.values()].map(
plugin => [plugin, getInternalPluginMeta(plugin)!] as const,
)
}
function Screen() {
const navigation = useNavigation()
const route =
@ -114,18 +156,30 @@ function Screen() {
[],
)
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => <HeaderContextMenu />,
})
}, [navigation])
const filter = route.params?.filter ?? DefaultFilters
const matchAll = route.params?.matchAll ?? true
const reverse = route.params?.reverse ?? false
const sort = route.params?.sort ?? DefaultSort
const allPlugins = useMemo(
() =>
[...pList.values()].map(
plugin => [plugin, getInternalPluginMeta(plugin)!] as const,
),
[],
)
const [allPlugins, setAllPlugins] = useState(snapshotPlugins)
useEffect(() => {
const handleUpdate = () => setAllPlugins(snapshotPlugins())
pEmitter.on('register', handleUpdate)
pEmitter.on('unregister', handleUpdate)
return () => {
pEmitter.off('register', handleUpdate)
pEmitter.off('unregister', handleUpdate)
}
}, [])
const plugins = useMemo(
() =>

View file

@ -1,10 +1,12 @@
import { AlertActionCreators } from '@revenge-mod/discord/actions'
import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2'
import { uninstallExternalPlugin } from '@revenge-mod/plugins/_'
import { deleteStorageForPlugin } from '~plugins/preinit/api.storage'
import PluginClearDataConfirmationAlert from '../components/PluginClearDataConfirmationAlert'
import PluginHasDependenciesAlert from '../components/PluginHasDependenciesAlert'
import PluginHasDependentsAlert from '../components/PluginHasDependentsAlert'
import PluginStatesProvider from '../components/PluginStateProvider'
import PluginUninstallConfirmationAlert from '../components/PluginUninstallConfirmationAlert'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
export function showPluginClearDataConfirmation(
@ -24,6 +26,24 @@ export function showPluginClearDataConfirmation(
)
}
export function showPluginUninstallConfirmation(
plugin: AnyPlugin,
callback: () => void,
) {
const KEY = 'plugin-uninstall-confirmation'
async function action() {
await deleteStorageForPlugin(plugin)
await uninstallExternalPlugin(plugin)
callback()
}
AlertActionCreators.openAlert(
KEY,
<PluginUninstallConfirmationAlert plugin={plugin} action={action} />,
)
}
export function openPluginSettings(plugin: AnyPlugin) {
if (!plugin.SettingsComponent) return

View file

@ -3,8 +3,11 @@ import { getStore, Stores } from '@revenge-mod/discord/flux'
import { getModules } from '@revenge-mod/modules/finders'
import { withProps } from '@revenge-mod/modules/finders/filters'
import { instead } from '@revenge-mod/patcher'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
} from '@revenge-mod/plugins/_'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import type { DiscordModules } from '@revenge-mod/discord/types'

View file

@ -2,8 +2,11 @@ import { AlertActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { getModules } from '@revenge-mod/modules/finders'
import { withProps } from '@revenge-mod/modules/finders/filters'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
} from '@revenge-mod/plugins/_'
import { afterJSX, beforeJSX } from '@revenge-mod/react/jsx-runtime'
import { findInReactFiber } from '@revenge-mod/utils/react'
import { Image } from 'react-native'
@ -92,7 +95,7 @@ registerPlugin(
cleanup(unsub)
},
stop({ plugin }) {
plugin.flags |= PluginFlags.ReloadRequired
plugin.requireReload()
},
},
PluginFlags.Enabled,