diff --git a/lib/plugins/src/_internal/decorators.ts b/lib/plugins/src/_internal/decorators.ts index 1fa990d..ecada49 100644 --- a/lib/plugins/src/_internal/decorators.ts +++ b/lib/plugins/src/_internal/decorators.ts @@ -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 = @@ -29,7 +29,7 @@ export function addPluginApiDecorator( export function decoratePluginApi( store: PluginApiDecoratorStore, - plugin: Plugin, + plugin: AnyPlugin, meta: InternalPluginMeta, ) { // Don't decorate API plugins with API plugins... diff --git a/lib/plugins/src/_internal/external-plugins.ts b/lib/plugins/src/_internal/external-plugins.ts new file mode 100644 index 0000000..d5ef4fc --- /dev/null +++ b/lib/plugins/src/_internal/external-plugins.ts @@ -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] + } +} diff --git a/lib/plugins/src/_internal/index.ts b/lib/plugins/src/_internal/index.ts index 952fff2..5bfb04f 100644 --- a/lib/plugins/src/_internal/index.ts +++ b/lib/plugins/src/_internal/index.ts @@ -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 +export type AnyPlugin = Plugin & { + /** @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 + optionsFactory?: PluginOptionsFactory flags: number } export const pUnscopedApi = uapi export const pEmitter = new TypedEventEmitter<{ register: [AnyPlugin, PluginOptions, update?: true] + unregister: [AnyPlugin] disabled: [AnyPlugin] enabled: [AnyPlugin] preInited: [AnyPlugin] @@ -92,17 +106,45 @@ export const pEmitter = new TypedEventEmitter<{ export const pList = new Map() const pMetadata = new WeakMap() -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( manifest: PluginManifest, - options: PluginOptions, + options: PluginOptions | PluginOptionsFactory, defflags: number, iflags: number, ) { @@ -122,19 +164,25 @@ export function registerPlugin( 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( 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( // 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): boolean { + return Boolean((plugin as AnyPlugin).flags & Flag.Enabled) +} + +export function isPluginEnabledLate(plugin: Plugin): boolean { + return Boolean((plugin as AnyPlugin).flags & Flag.EnabledLate) +} + +export function isPluginReloadRequired(plugin: Plugin): 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): 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 + } } diff --git a/lib/plugins/src/constants.ts b/lib/plugins/src/constants.ts index 104e107..7d9f9ce 100644 --- a/lib/plugins/src/constants.ts +++ b/lib/plugins/src/constants.ts @@ -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. */ diff --git a/lib/plugins/src/preinit.ts b/lib/plugins/src/preinit.ts index d961dbf..1756aef 100644 --- a/lib/plugins/src/preinit.ts +++ b/lib/plugins/src/preinit.ts @@ -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) diff --git a/lib/plugins/src/types.ts b/lib/plugins/src/types.ts index 9ad9c01..bde3ad9 100644 --- a/lib/plugins/src/types.ts +++ b/lib/plugins/src/types.ts @@ -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 } +/** + * 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 + /** * The plugin lifecycles. */ @@ -247,10 +257,6 @@ export interface Plugin< manifest: PluginManifest lifecycles: PluginLifecycles - /** - * @see {@link PluginFlags} - */ - flags: number /** * @see {@link PluginStatus} */ @@ -271,6 +277,10 @@ export interface Plugin< * Stop the plugin. */ stop(this: Plugin): Promise + /** + * Marks this plugin as requiring a reload to apply changes. + */ + requireReload(this: Plugin): void /** * The plugin API. diff --git a/src/plugins/preinit/api.discord/index.ts b/src/plugins/preinit/api.discord/index.ts index 3e37558..bc3331d 100644 --- a/src/plugins/preinit/api.discord/index.ts +++ b/src/plugins/preinit/api.discord/index.ts @@ -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( diff --git a/src/plugins/preinit/api.storage/index.ts b/src/plugins/preinit/api.storage/index.ts index 77e8dc9..cf10df1 100644 --- a/src/plugins/preinit/api.storage/index.ts +++ b/src/plugins/preinit/api.storage/index.ts @@ -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() +const storageOptions = new WeakMap, StorageOptions>() registerPlugin( { @@ -55,11 +55,11 @@ registerPlugin( InternalPluginFlags.API, ) -function getStoragePathForPlugin(plugin: AnyPlugin) { +function getStoragePathForPlugin(plugin: Plugin) { return `${PluginsStorageDirectory}/${plugin.manifest.id}.json` } -export async function deleteStorageForPlugin(plugin: AnyPlugin) { +export async function deleteStorageForPlugin(plugin: Plugin) { const api = plugin.api as InitPluginApi<{ storage: AnyObject }> | undefined const storage = api?.storage ?? getStorage(getStoragePathForPlugin(plugin)) console.log(api?.storage, storage, getStoragePathForPlugin(plugin)) diff --git a/src/plugins/preinit/api.utils/index.ts b/src/plugins/preinit/api.utils/index.ts index 46137df..ba95910 100644 --- a/src/plugins/preinit/api.utils/index.ts +++ b/src/plugins/preinit/api.utils/index.ts @@ -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' diff --git a/src/plugins/preinit/logging/index.ts b/src/plugins/preinit/logging/index.ts index 651e9cb..a5e021a 100644 --- a/src/plugins/preinit/logging/index.ts +++ b/src/plugins/preinit/logging/index.ts @@ -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__) { diff --git a/src/plugins/preinit/no-track/index.ts b/src/plugins/preinit/no-track/index.ts index 3c923bd..1832def 100644 --- a/src/plugins/preinit/no-track/index.ts +++ b/src/plugins/preinit/no-track/index.ts @@ -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, diff --git a/src/plugins/start/developer-kit/index.ts b/src/plugins/start/developer-kit/index.ts index 70a2020..d6369e2 100644 --- a/src/plugins/start/developer-kit/index.ts +++ b/src/plugins/start/developer-kit/index.ts @@ -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() } diff --git a/src/plugins/start/error-boundary/index.tsx b/src/plugins/start/error-boundary/index.tsx index 053e214..542dab0 100644 --- a/src/plugins/start/error-boundary/index.tsx +++ b/src/plugins/start/error-boundary/index.tsx @@ -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' diff --git a/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx b/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx index 4f0a73b..ff5c255 100644 --- a/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx +++ b/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx @@ -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 ( + {!isPluginInternal(meta) && ( + { + showPluginUninstallConfirmation(plugin, closeSheet) + }} + /> + )} {plugin.SettingsComponent && ( { diff --git a/src/plugins/start/settings/components/PluginUninstallConfirmationAlert.tsx b/src/plugins/start/settings/components/PluginUninstallConfirmationAlert.tsx new file mode 100644 index 0000000..cb92508 --- /dev/null +++ b/src/plugins/start/settings/components/PluginUninstallConfirmationAlert.tsx @@ -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 +}) { + return ( + + + {plugin.manifest.name} + {' '} + and all of its data will be removed. This cannot be undone. + + } + actions={ + <> + + + + } + /> + ) +} diff --git a/src/plugins/start/settings/index.ts b/src/plugins/start/settings/index.ts index a490b53..9f6239f 100644 --- a/src/plugins/start/settings/index.ts +++ b/src/plugins/start/settings/index.ts @@ -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' diff --git a/src/plugins/start/settings/plugins.tsx b/src/plugins/start/settings/plugins.tsx index 02d5fc4..b0f2b0c 100644 --- a/src/plugins/start/settings/plugins.tsx +++ b/src/plugins/start/settings/plugins.tsx @@ -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' diff --git a/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx b/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx index 2239108..4e4629b 100644 --- a/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx +++ b/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx @@ -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 ( + + callBridgeMethod( + 'revenge.plugins.loader.installPlugin', + [], + ), + }, + ]} + > + {props => ( + + )} + + ) +} + +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: () => , + }) + }, [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( () => diff --git a/src/plugins/start/settings/utils/alerts.tsx b/src/plugins/start/settings/utils/alerts.tsx index 4bd2793..69c91c8 100644 --- a/src/plugins/start/settings/utils/alerts.tsx +++ b/src/plugins/start/settings/utils/alerts.tsx @@ -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, + , + ) +} + export function openPluginSettings(plugin: AnyPlugin) { if (!plugin.SettingsComponent) return diff --git a/src/plugins/start/staff-settings/index.ts b/src/plugins/start/staff-settings/index.ts index 02398fc..4187034 100644 --- a/src/plugins/start/staff-settings/index.ts +++ b/src/plugins/start/staff-settings/index.ts @@ -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' diff --git a/src/plugins/start/user-badges/index.tsx b/src/plugins/start/user-badges/index.tsx index e53d32d..8df038f 100644 --- a/src/plugins/start/user-badges/index.tsx +++ b/src/plugins/start/user-badges/index.tsx @@ -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,