diff --git a/lib/discord/src/init.ts b/lib/discord/src/init.ts index aa7ecf1..1ba2661 100644 --- a/lib/discord/src/init.ts +++ b/lib/discord/src/init.ts @@ -1,4 +1,4 @@ -import { _initExts, _uapi } from '@revenge-mod/plugins/_' +import { _emitter, _uapi } from '@revenge-mod/plugins/_' import { defineLazyProperty } from '@revenge-mod/utils/objects' import { Logger } from './common' import type { UnscopedInitPluginApi } from '@revenge-mod/plugins/types' @@ -42,7 +42,7 @@ defineLazyProperty(discord, 'modules', () => { return modules }) -_initExts.push((api, { manifest: { id } }) => +_emitter.on('init', ({ manifest: { id } }, api) => defineLazyProperty( api, 'logger', diff --git a/lib/plugins/src/_internal.ts b/lib/plugins/src/_internal.ts index 8fd8215..00fd53c 100644 --- a/lib/plugins/src/_internal.ts +++ b/lib/plugins/src/_internal.ts @@ -1,3 +1,4 @@ +import { TypedEventEmitter } from '@revenge-mod/discord/common/utils' import { getStorage } from '@revenge-mod/storage' import { defineLazyProperty } from '@revenge-mod/utils/objects' import { allSettled, sleepReject } from '@revenge-mod/utils/promises' @@ -21,15 +22,20 @@ import type { export const _uapi = uapi -export const _preInitExts: Array< - (api: PreInitPluginApi, plugin: InternalPlugin) => void -> = [] -export const _initExts: Array< - (api: InitPluginApi, plugin: InternalPlugin) => void -> = [] -export const _startExts: Array< - (api: PluginApi, plugin: InternalPlugin) => void -> = [] +export const _emitter = new TypedEventEmitter<{ + register: [InternalPlugin] + disabled: [InternalPlugin] + enabled: [InternalPlugin] + preInit: [InternalPlugin, PreInitPluginApi] + preInited: [InternalPlugin] + init: [InternalPlugin, InitPluginApi] + inited: [InternalPlugin] + start: [InternalPlugin, PluginApi] + started: [InternalPlugin] + stop: [InternalPlugin] + stopped: [InternalPlugin] + error: [InternalPlugin, unknown] +}>() export const _plugins = new Map() export const _metas = new Map< @@ -82,6 +88,8 @@ function handlePluginError(e: unknown, plugin: InternalPlugin) { const log = (api as InitPluginApi).logger ?? console log.error('Plugin encountered an error', e) + _emitter.emit('error', plugin, e) + if (!(iflags & InternalPluginFlags.Essential)) return plugin.disable() } @@ -100,8 +108,7 @@ function preparePluginPreInit(plugin: InternalPlugin) { unscoped: _uapi, }) - for (const ext of _preInitExts) ext(api, plugin) - + _emitter.emit('preInit', plugin, api) meta[3] = PluginApiLevel.PreInit } @@ -116,8 +123,7 @@ function preparePluginInit(plugin: InternalPlugin) { }), ) - for (const ext of _initExts) ext(api, plugin) - + _emitter.emit('init', plugin, api) meta[3] = PluginApiLevel.Init } @@ -127,12 +133,14 @@ function preparePluginStart(plugin: InternalPlugin) { const api = meta[0] as PluginApi // api.settings = ... - for (const ext of _startExts) ext(api, plugin) - + _emitter.emit('start', plugin, api) meta[3] = PluginApiLevel.Start } async function disablePlugin(plugin: InternalPlugin) { + if (!(plugin.flags & Flag.Enabled)) + throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`) + const iflags = _metas.get(plugin.manifest.id)![2] ?? 0 if (iflags & InternalPluginFlags.Essential) throw new Error( @@ -145,12 +153,19 @@ async function disablePlugin(plugin: InternalPlugin) { // TODO(plugins): write to storage plugin.flags &= ~Flag.Enabled + + _emitter.emit('disabled', plugin) } export function enablePlugin(plugin: InternalPlugin, late: boolean) { + if (plugin.flags & Flag.Enabled) + throw new Error(`Plugin "${plugin.manifest.id}" is already enabled`) + // TODO(plugins): write to storage plugin.flags |= Flag.Enabled if (late) plugin.flags |= Flag.EnabledLate + + _emitter.emit('enabled', plugin) } export async function preInitPlugin(plugin: InternalPlugin) { @@ -158,23 +173,24 @@ export async function preInitPlugin(plugin: InternalPlugin) { manifest: { id }, lifecycles, } = plugin - if (!lifecycles.preInit) return - - const meta = _metas.get(id)! - const [, promises] = meta - - preparePluginPreInit(plugin) - - if (!(plugin.flags & Flag.Enabled)) - throw new Error(`Plugin "${id}" is not enabled`) - if (plugin.status & (Status.PreIniting | Status.PreInited)) - throw new Error( - `Plugin preInit lifecycle for "${id}" is already running`, - ) - - plugin.status |= Status.PreIniting try { + if (!lifecycles.preInit) return + + const meta = _metas.get(id)! + const [, promises] = meta + + if (!(plugin.flags & Flag.Enabled)) + throw new Error(`Plugin "${id}" is not enabled`) + if (plugin.status & (Status.PreIniting | Status.PreInited)) + throw new Error( + `Plugin preInit lifecycle for "${id}" is already running`, + ) + + preparePluginPreInit(plugin) + + plugin.status |= Status.PreIniting + const prom = lifecycles.preInit(meta[0] as PreInitPluginApi) promises.push(prom) await prom @@ -184,6 +200,8 @@ export async function preInitPlugin(plugin: InternalPlugin) { plugin.status &= ~Status.PreIniting } catch (e) { await handlePluginError(e, plugin) + } finally { + _emitter.emit('preInited', plugin) } } @@ -192,20 +210,23 @@ export async function initPlugin(plugin: InternalPlugin) { manifest: { id }, lifecycles, } = plugin - if (!lifecycles.init) return - - const meta = _metas.get(id)! - const [, promises, , apiLevel] = meta - - if (!(plugin.flags & Flag.Enabled)) - throw new Error(`Plugin "${id}" is not enabled`) - if (plugin.status & (Status.Initing | Status.Inited)) - throw new Error(`Plugin init lifecycle for "${id}" is already running`) - - if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(plugin) - if (apiLevel < PluginApiLevel.Init) preparePluginInit(plugin) try { + if (!lifecycles.init) return + + const meta = _metas.get(id)! + const [, promises, , apiLevel] = meta + + if (!(plugin.flags & Flag.Enabled)) + throw new Error(`Plugin "${id}" is not enabled`) + if (plugin.status & (Status.Initing | Status.Inited)) + throw new Error( + `Plugin init lifecycle for "${id}" is already running`, + ) + + if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(plugin) + if (apiLevel < PluginApiLevel.Init) preparePluginInit(plugin) + const prom = lifecycles.init(meta[0] as InitPluginApi) promises.push(prom) await prom @@ -215,6 +236,8 @@ export async function initPlugin(plugin: InternalPlugin) { plugin.status &= ~Status.Initing } catch (e) { await handlePluginError(e, plugin) + } finally { + _emitter.emit('inited', plugin) } } @@ -223,23 +246,26 @@ export async function startPlugin(plugin: InternalPlugin) { manifest: { id }, lifecycles, } = plugin - if (!lifecycles.start) return - - const meta = _metas.get(id)! - const [, promises, , apiLevel] = meta - - if (!(plugin.flags & Flag.Enabled)) - throw new Error(`Plugin "${id}" is not enabled`) - if (plugin.status & (Status.Starting | Status.Started)) - throw new Error(`Plugin start lifecycle for "${id}" is already running`) - - plugin.status |= Status.Starting - - if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(plugin) - if (apiLevel < PluginApiLevel.Init) preparePluginInit(plugin) - if (apiLevel < PluginApiLevel.Start) preparePluginStart(plugin) try { + if (!lifecycles.start) return + + const meta = _metas.get(id)! + const [, promises, , apiLevel] = meta + + if (!(plugin.flags & Flag.Enabled)) + throw new Error(`Plugin "${id}" is not enabled`) + if (plugin.status & (Status.Starting | Status.Started)) + throw new Error( + `Plugin start lifecycle for "${id}" is already running`, + ) + + plugin.status |= Status.Starting + + if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(plugin) + if (apiLevel < PluginApiLevel.Init) preparePluginInit(plugin) + if (apiLevel < PluginApiLevel.Start) preparePluginStart(plugin) + const prom = lifecycles.start(meta[0] as PluginApi) promises.push(prom) await prom @@ -249,6 +275,8 @@ export async function startPlugin(plugin: InternalPlugin) { plugin.status &= ~Status.Starting } catch (e) { await handlePluginError(e, plugin) + } finally { + _emitter.emit('started', plugin) } } @@ -262,41 +290,47 @@ export async function stopPlugin(plugin: InternalPlugin) { const meta = _metas.get(id)! const [, promises, iflags, apiLevel] = meta - if (iflags & InternalPluginFlags.Essential) - throw new Error(`Plugin "${id}" is essential and cannot be stopped`) - - if (!(plugin.flags & Flag.Enabled)) - throw new Error(`Plugin "${id}" is not enabled`) - if (plugin.status & Status.Stopping) - throw new Error(`Plugin "${id}" is stopping`) - - // If the plugin is running its lifecycles, we need to wait for it to finish, then we'll stop it - // We want to wait at max 5 seconds for the lifecycles to finish - if (plugin.status & (Status.PreIniting | Status.Initing | Status.Starting)) - await Promise.race([ - Promise.all(promises), - sleepReject( - MaxWaitTime, - 'Plugin lifecycles timed out, force stopping', - ), - ]) - .then(finished => { - // If the lifecycles don't finish in 5 seconds, a reload is probably required to unapply the changes - if (!finished) plugin.flags |= Flag.ReloadRequired - }) - .catch(e => handlePluginError(e, plugin)) - else if ( - !(plugin.status & (Status.PreInited | Status.Inited | Status.Started)) - ) - throw new Error(`Plugin "${id}" is not running`) - - if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(plugin) - if (apiLevel < PluginApiLevel.Init) preparePluginInit(plugin) - if (apiLevel < PluginApiLevel.Start) preparePluginStart(plugin) - - plugin.status |= Status.Stopping - try { + if (iflags & InternalPluginFlags.Essential) + throw new Error(`Plugin "${id}" is essential and cannot be stopped`) + + if (!(plugin.flags & Flag.Enabled)) + throw new Error(`Plugin "${id}" is not enabled`) + if (plugin.status & Status.Stopping) + throw new Error(`Plugin "${id}" is stopping`) + + // If the plugin is running its lifecycles, we need to wait for it to finish, then we'll stop it + // We want to wait at max 5 seconds for the lifecycles to finish + if ( + plugin.status & + (Status.PreIniting | Status.Initing | Status.Starting) + ) + await Promise.race([ + Promise.all(promises), + sleepReject( + MaxWaitTime, + 'Plugin lifecycles timed out, force stopping', + ), + ]) + .then(finished => { + // If the lifecycles don't finish in 5 seconds, a reload is probably required to unapply the changes + if (!finished) plugin.flags |= Flag.ReloadRequired + }) + .catch(e => handlePluginError(e, plugin)) + else if ( + !( + plugin.status & + (Status.PreInited | Status.Inited | Status.Started) + ) + ) + throw new Error(`Plugin "${id}" is not running`) + + if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(plugin) + if (apiLevel < PluginApiLevel.Init) preparePluginInit(plugin) + if (apiLevel < PluginApiLevel.Start) preparePluginStart(plugin) + + plugin.status |= Status.Stopping + if (lifecycles.stop) await Promise.race([ lifecycles.stop(meta[0] as PluginApi), @@ -309,13 +343,28 @@ export async function stopPlugin(plugin: InternalPlugin) { await handlePluginError(e, plugin) } finally { // Run cleanups - const results = await allSettled(plugin._c.map(cleanup => cleanup())) - for (const result of results) - if (result.status === 'rejected') { - await handlePluginError(result.reason, plugin) - // Some cleanup was unsuccessful, so we need to reload the app - plugin.flags |= Flag.ReloadRequired - } + + function handleStopError(e: unknown) { + // Some cleanup was unsuccessful, so we need to reload the app + plugin.flags |= Flag.ReloadRequired + return handlePluginError(e, plugin) + } + + try { + const results = await allSettled( + plugin._c.map(cleanup => cleanup()), + ) + + await Promise.all( + results.map( + result => + result.status === 'rejected' && + handleStopError(result.reason), + ), + ) + } catch (e) { + await handleStopError(e) + } // Reset APIs meta[0] = undefined @@ -327,6 +376,8 @@ export async function stopPlugin(plugin: InternalPlugin) { // Reset status plugin.status = 0 + + _emitter.emit('stopped', plugin) } } diff --git a/src/plugins/init/settings/components/PluginCard.tsx b/src/plugins/init/settings/components/PluginCard.tsx index fa42686..1a1fbdd 100644 --- a/src/plugins/init/settings/components/PluginCard.tsx +++ b/src/plugins/init/settings/components/PluginCard.tsx @@ -4,6 +4,7 @@ import FormSwitch from '@revenge-mod/components/FormSwitch' import { Tokens } from '@revenge-mod/discord/common' import { Design } from '@revenge-mod/discord/design' import { + _emitter, enablePlugin, InternalPluginFlags, initPlugin, @@ -12,6 +13,7 @@ import { } from '@revenge-mod/plugins/_' import { PluginFlags } from '@revenge-mod/plugins/constants' import { useReRender } from '@revenge-mod/utils/react' +import { useEffect } from 'react' import { Image, Pressable } from 'react-native' import { useClickOutside } from 'react-native-click-outside' import { navigatePluginSettings } from '../utils' @@ -40,11 +42,25 @@ export function InstalledPluginCard({ rightGap?: boolean }) { const { - manifest: { name, description, author, icon }, + manifest: { id, name, description, author, icon }, flags, } = plugin + // Re-render on plugin enable/disable const reRender = useReRender() + useEffect(() => { + const listener = (p: InternalPlugin) => + id === p.manifest.id && reRender() + + _emitter.on('disabled', listener) + _emitter.on('enabled', listener) + + return () => { + _emitter.off('disabled', listener) + _emitter.off('enabled', listener) + } + }) + const essential = Boolean(iflags & InternalPluginFlags.Essential) const enabled = Boolean(flags & PluginFlags.Enabled) const styles_ = usePluginCardStyles() @@ -122,13 +138,6 @@ export function InstalledPluginCard({ await initPlugin(plugin) await startPlugin(plugin) } else await plugin.disable() - - reRender() - - // TODO(plugins/settings): handle sorting after plugin enabled/disabled - // TODO(plugins/settings): show ReloadRequired modal - // make an event based system for this, so we can register a listener for when plugins are disabled - // and update the UI accordingly }} value={enabled} /> diff --git a/src/plugins/init/settings/definitions/RevengePluginsSetting.tsx b/src/plugins/init/settings/definitions/RevengePluginsSetting.tsx index 743bc0c..69fce5c 100644 --- a/src/plugins/init/settings/definitions/RevengePluginsSetting.tsx +++ b/src/plugins/init/settings/definitions/RevengePluginsSetting.tsx @@ -1,8 +1,14 @@ import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon' -import { _plugins } from '@revenge-mod/plugins/_' +import { AlertActionCreators } from '@revenge-mod/discord/actions' +import { Design } from '@revenge-mod/discord/design' +import { BundleUpdaterManager } from '@revenge-mod/discord/native' +import { _emitter, _plugins } from '@revenge-mod/plugins/_' import { PluginFlags } from '@revenge-mod/plugins/constants' +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/_' const RevengePluginsSetting: SettingsItem = { parent: null, @@ -10,10 +16,8 @@ const RevengePluginsSetting: SettingsItem = { IconComponent: () => , title: () => 'Plugins', useTrailing: () => { - let count = 0 - for (const plugin of _plugins.values()) - if (plugin.flags & PluginFlags.Enabled) count++ - return `${count} enabled` + useUpdateOnPluginStatesChange() + return `${enabledCount} enabled` }, screen: { route: RouteNames[Setting.RevengePlugins], @@ -22,4 +26,61 @@ const RevengePluginsSetting: SettingsItem = { }, } +let enabledCount = 0 + +for (const plugin of _plugins.values()) + if (plugin.flags & PluginFlags.Enabled) enabledCount++ + +_emitter.on('disabled', () => enabledCount--) +_emitter.on('enabled', () => enabledCount++) + +export function useUpdateOnPluginStatesChange() { + const reRender = useReRender() + + useEffect(() => { + _emitter.on('disabled', reRender) + _emitter.on('enabled', reRender) + + return () => { + _emitter.off('disabled', reRender) + _emitter.off('enabled', reRender) + } + }, [reRender]) +} + +const { AlertActionButton, AlertModal, Text } = Design + +_emitter.on('started', showReloadRequiredAlert) +_emitter.on('stopped', showReloadRequiredAlert) + +function showReloadRequiredAlert(plugin: InternalPlugin) { + if (plugin.flags & PluginFlags.ReloadRequired) { + AlertActionCreators.openAlert( + 'plugin-reload-required', + + Plugin{' '} + + {plugin.manifest.name} + {' '} + requires a reload to apply changes. + + } + actions={ + <> + BundleUpdaterManager.reload()} + /> + + + } + />, + ) + } +} + export default RevengePluginsSetting diff --git a/src/plugins/init/settings/screens/RevengePluginsSettingScreen.tsx b/src/plugins/init/settings/screens/RevengePluginsSettingScreen.tsx index d8538db..aab5054 100644 --- a/src/plugins/init/settings/screens/RevengePluginsSettingScreen.tsx +++ b/src/plugins/init/settings/screens/RevengePluginsSettingScreen.tsx @@ -19,6 +19,7 @@ import { EssentialPluginTooltipProvider, resetTooltips, } from '../components/TooltipProvider' +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/_' @@ -81,6 +82,8 @@ const Sorts = { } satisfies FilterAndSortActionSheetProps['sorts'] function Screen() { + useUpdateOnPluginStatesChange() + const navigation = ReactNavigationNative.useNavigation() const route = ReactNavigationNative.useRoute< @@ -110,32 +113,28 @@ function Screen() { [], ) - const plugins = useMemo( - () => - allPlugins - .filter(([plugin, iflags]) => { - if (filter.length === 0) return true - if (matchAll) - return filter.every(f => Filters[f][1](plugin, iflags)) + const plugins = allPlugins + .filter(([plugin, iflags]) => { + if (filter.length === 0) return true + if (matchAll) + return filter.every(f => Filters[f][1](plugin, iflags)) - return filter.some(f => Filters[f][1](plugin, iflags)) - }) - .filter(([plugin]) => { - const { name, description, author } = plugin.manifest - const query = search.toLowerCase() - return ( - name.toLowerCase().includes(query) || - description.toLowerCase().includes(query) || - author.toLowerCase().includes(query) - ) - }) - .sort(([a], [b]) => { - const result = Sorts[sort as keyof typeof Sorts][1](a, b) - if (reverse) return -result - return result - }), - [search, sort, reverse, filter, matchAll, allPlugins], - ) + return filter.some(f => Filters[f][1](plugin, iflags)) + }) + .filter(([plugin]) => { + const { name, description, author } = plugin.manifest + const query = search.toLowerCase() + return ( + name.toLowerCase().includes(query) || + description.toLowerCase().includes(query) || + author.toLowerCase().includes(query) + ) + }) + .sort(([a], [b]) => { + const result = Sorts[sort as keyof typeof Sorts][1](a, b) + if (reverse) return -result + return result + }) return ( <> diff --git a/tsconfig.json b/tsconfig.json index a89b996..75a652e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,13 @@ { "extends": "@revenge-mod/types/tsconfig", "compilerOptions": { - "types": ["@revenge-mod/types", "./types", "./types.modules", "node/util"], + "types": [ + "@revenge-mod/types", + "./types", + "./types.modules", + "node/util", + "node/events" + ], "allowJs": true, "paths": { "~/*": ["./src/*"],