feat: plugin system improvement

- Stop plugins prior to an update
- Native calls are now awaited in relevant places
- External plugin install feedback
- Plugins that are pending reloads now can't be started after an update
This commit is contained in:
PalmDevs 2026-07-07 23:12:40 +07:00
parent fb1a31640f
commit fb3692bc2d
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
8 changed files with 258 additions and 68 deletions

View file

@ -9,11 +9,13 @@ import {
disablePlugin,
InternalPluginFlags,
isPluginEnabled,
isPluginPendingReload,
PluginFlags,
pEmitter,
pList,
registerPlugin,
runPluginLate,
stopPlugin,
} from '.'
import type { PluginManifest, PluginOptionsFactory } from '../types'
import type { AnyPlugin } from '.'
@ -24,13 +26,64 @@ interface ExternalPlugin {
internal?: boolean
essential?: boolean
enabledByDefault?: boolean
/** Errors the native side already hit (eg. at boot before JS was up, after faulty update). */
errors?: string[]
}
type PluginInstallResult =
| { error: false; plugin: ExternalPlugin }
| { error: string }
export function registerExternalPlugins() {
registerJSMethod(
'revenge.plugins.loader.stopPlugin',
async function stopHandler(id: string) {
const plugin = pList.get(id)
if (plugin?.status) await stopPlugin(plugin)
},
)
registerJSMethod(
'revenge.plugins.loader.pluginInstalled',
(external: ExternalPlugin) => {
loadExternalPlugin(external)
async function installedHandler(result: PluginInstallResult) {
if (result.error !== false) {
pEmitter.emit('install', { error: result.error })
return
}
const { plugin } = result
const existing = pList.get(plugin.manifest.id)
try {
if (existing?.status) {
// Should already be stopped by this point, but just in case the native side didn't await
await stopPlugin(existing)
}
const pendingReload =
existing !== undefined && isPluginPendingReload(existing)
if (existing) pList.delete(plugin.manifest.id)
if (pendingReload) {
// Since reload is required to (un)apply changes, we don't load the new plugin yet
// We'll do the next time the app is started
const registered = pList.get(
registerExternalPlugin(plugin).id,
)!
registered.flags |= PluginFlags.PendingUpdate
} else await loadExternalPlugin(plugin)
} catch (e) {
pEmitter.emit('install', { error: getErrorStack(e) })
return
}
pEmitter.emit('install', {
error: false,
manifest: plugin.manifest,
updated: existing !== undefined,
})
},
)
@ -53,20 +106,26 @@ export function registerExternalPlugins() {
}
}
export function registerExternalPlugin({
manifest,
script,
internal,
essential,
enabledByDefault,
}: ExternalPlugin) {
return registerPlugin(
export function registerExternalPlugin(external: ExternalPlugin) {
const { manifest, script, internal, essential, enabledByDefault, errors } =
external
const dep = registerPlugin(
manifest,
createOptionsFactory(script),
essential || enabledByDefault ? PluginFlags.Enabled : 0,
(internal ? InternalPluginFlags.Internal : 0) |
(essential ? InternalPluginFlags.Essential : 0),
)
// Sync errors the native side already caught
if (errors?.length) {
const plugin = pList.get(dep.id)!
plugin.errors.push(...errors)
plugin.flags |= PluginFlags.Errored
}
return dep
}
export async function loadExternalPlugin(external: ExternalPlugin) {
@ -79,7 +138,7 @@ export async function loadExternalPlugin(external: ExternalPlugin) {
export async function uninstallExternalPlugin(plugin: AnyPlugin) {
if (isPluginEnabled(plugin)) await disablePlugin(plugin)
callBridgeMethod('revenge.plugins.loader.uninstallPlugin', [
await callBridgeMethod('revenge.plugins.loader.uninstallPlugin', [
plugin.manifest.id,
])

View file

@ -46,9 +46,14 @@ const MaxWaitTime = 5000
// This is never used in bridge, but still used internally by JS.
export const PluginFlags = {
Enabled: 1 << 0,
ReloadRequired: 1 << 1,
PendingReload: 1 << 1,
Errored: 1 << 2,
EnabledLate: 1 << 3,
/**
* Marks the plugin as pending an update. Set when {@link PluginFlags.PendingReload} is set when stopping the plugin to update.
* This means for the update to be applied safely, the updated plugin can only be started after a reload.
*/
PendingUpdate: 1 << 4,
}
const Flag = PluginFlags
@ -101,8 +106,13 @@ export const pEmitter = new TypedEventEmitter<{
stopped: [AnyPlugin]
errored: [AnyPlugin, unknown]
flagUpdate: [AnyPlugin]
install: [PluginInstallEvent]
}>()
export type PluginInstallEvent =
| { error: false; manifest: PluginManifest; updated: boolean }
| { error: string }
export const pList = new Map<PluginManifest['id'], AnyPlugin>()
const pMetadata = new WeakMap<AnyPlugin, InternalPluginMeta>()
@ -122,7 +132,7 @@ registerJSMethod('revenge.plugins.states.update', (id, state) => {
function pluginStateToFlags(state: PluginStateObject): number {
return (
(state.enabled ? Flag.Enabled : 0) |
(state.reloadRequired ? Flag.ReloadRequired : 0) |
(state.pendingReload ? Flag.PendingReload : 0) |
(state.errored ? Flag.Errored : 0) |
(state.enabledLate ? Flag.EnabledLate : 0)
)
@ -181,7 +191,7 @@ export function registerPlugin<O extends PluginApiExtensionsOptions>(
disable: () => disablePlugin(plugin),
stop: () => stopPlugin(plugin),
requireReload: () => {
plugin.flags |= Flag.ReloadRequired
plugin.flags |= Flag.PendingReload
},
api: undefined,
set flags(flags: number) {
@ -257,18 +267,14 @@ export function getPluginDependencies(plugin: AnyPlugin): AnyPlugin[] {
return (meta.dependencies = deps)
}
export function isPluginEnabled(plugin: Plugin<any, any>): boolean {
export function isPluginEnabled(plugin: AnyPlugin): boolean {
return Boolean((plugin as AnyPlugin).flags & Flag.Enabled)
}
export function isPluginEnabledLate(plugin: Plugin<any, any>): boolean {
export function isPluginEnabledLate(plugin: AnyPlugin): 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 {
return Boolean(iflags & InternalPluginFlags.Essential)
}
@ -277,13 +283,45 @@ export function isPluginInternal({ iflags }: InternalPluginMeta): boolean {
return Boolean(iflags & InternalPluginFlags.Internal)
}
export function isPluginErrored(plugin: Plugin<any, any>): boolean {
export function isPluginErrored(plugin: AnyPlugin): boolean {
return Boolean((plugin as AnyPlugin).flags & Flag.Errored)
}
function guardPluginEnabled(plugin: AnyPlugin) {
export function isPluginPendingReload(plugin: AnyPlugin): boolean {
return Boolean((plugin as AnyPlugin).flags & Flag.PendingReload)
}
export function isPluginPendingUpdate(plugin: AnyPlugin): boolean {
return Boolean((plugin as AnyPlugin).flags & Flag.PendingUpdate)
}
/**
* Requires that the plugin is in a state where it can be started, all of:
* - Enabled
* - Not pending an update
*/
function requirePluginStartableState(plugin: AnyPlugin) {
if (!isPluginEnabled(plugin))
throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`)
if (isPluginPendingUpdate(plugin))
throw new Error(
`Plugin "${plugin.manifest.id}" is pending an update and cannot be started`,
)
}
/**
* Checks if a plugin is startable.
*
* @see {@link requirePluginStartableState}.
*/
export function isPluginStartable(plugin: AnyPlugin): boolean {
try {
requirePluginStartableState(plugin)
return true
} catch {
return false
}
}
/**
@ -394,7 +432,7 @@ function tryPreparePluginStart(plugin: AnyPlugin) {
* Disables a plugin, as well as all its dependents.
*/
export async function disablePlugin(plugin: AnyPlugin) {
guardPluginEnabled(plugin)
requirePluginStartableState(plugin)
const meta = getInternalPluginMeta(plugin)!
@ -415,13 +453,13 @@ export async function disablePlugin(plugin: AnyPlugin) {
if (plugin.status && !(plugin.status & Status.Stopping))
await stopPlugin(plugin)
plugin.flags &= ~Flag.Enabled
pEmitter.emit('disabled', plugin)
callBridgeMethod('revenge.plugins.states.setEnabled', [
await callBridgeMethod('revenge.plugins.states.setEnabled', [
plugin.manifest.id,
false,
])
plugin.flags &= ~Flag.Enabled
pEmitter.emit('disabled', plugin)
}
/**
@ -437,17 +475,17 @@ export async function enablePlugin(plugin: AnyPlugin) {
}),
)
plugin.flags |= Flag.Enabled
pEmitter.emit('enabled', plugin)
callBridgeMethod('revenge.plugins.states.setEnabled', [
await callBridgeMethod('revenge.plugins.states.setEnabled', [
plugin.manifest.id,
true,
])
plugin.flags |= Flag.Enabled
pEmitter.emit('enabled', plugin)
}
export async function runPluginLate(plugin: AnyPlugin) {
guardPluginEnabled(plugin)
requirePluginStartableState(plugin)
if (plugin.status & Status.Started)
throw new Error(`Plugin "${plugin.manifest.id}" is already started`)
@ -466,7 +504,7 @@ export async function runPluginLate(plugin: AnyPlugin) {
// 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', [
await callBridgeMethod('revenge.plugins.loader.start', [
plugin.manifest.id,
])
@ -482,7 +520,7 @@ export async function runPluginLate(plugin: AnyPlugin) {
* Runs the preInit lifecycle of a plugin.
*/
export async function preInitPlugin(plugin: AnyPlugin) {
guardPluginEnabled(plugin)
requirePluginStartableState(plugin)
const {
manifest: { id },
@ -526,7 +564,7 @@ export async function preInitPlugin(plugin: AnyPlugin) {
* Runs the init lifecycle of a plugin.
*/
export async function initPlugin(plugin: AnyPlugin) {
guardPluginEnabled(plugin)
requirePluginStartableState(plugin)
const {
manifest: { id },
@ -571,7 +609,7 @@ export async function initPlugin(plugin: AnyPlugin) {
* Starts a plugin by running its start lifecycle.
*/
export async function startPlugin(plugin: AnyPlugin) {
guardPluginEnabled(plugin)
requirePluginStartableState(plugin)
const {
manifest: { id },
@ -615,7 +653,7 @@ export async function startPlugin(plugin: AnyPlugin) {
* Stops a plugin by running its stop lifecycle and cleanup functions.
*/
export async function stopPlugin(plugin: AnyPlugin) {
guardPluginEnabled(plugin)
requirePluginStartableState(plugin)
const {
manifest: { id },
@ -641,7 +679,7 @@ export async function stopPlugin(plugin: AnyPlugin) {
'Plugin lifecycles timed out, force stopping',
),
]).catch(e => {
plugin.flags |= Flag.ReloadRequired
plugin.flags |= Flag.PendingReload
return handlePluginError(e, plugin)
})
else if (
@ -683,7 +721,7 @@ export async function stopPlugin(plugin: AnyPlugin) {
async function cleanupPlugin(plugin: AnyPlugin, meta: InternalPluginMeta) {
async function handleStopError(e: unknown) {
// Some cleanup was unsuccessful, so we need to reload the app
plugin.flags |= Flag.ReloadRequired
plugin.flags |= Flag.PendingReload
return handlePluginError(e, plugin)
}
@ -718,7 +756,7 @@ declare module '@revenge-mod/modules/native' {
interface PluginStateObject {
enabled?: boolean
reloadRequired?: boolean
pendingReload?: boolean
errored?: boolean
enabledLate?: boolean
}

View file

@ -3,7 +3,11 @@ import { styles } from '@revenge-mod/components/_'
import FormSwitch from '@revenge-mod/components/FormSwitch'
import { Tokens } from '@revenge-mod/discord/common'
import { Design } from '@revenge-mod/discord/design'
import { isPluginEssential } from '@revenge-mod/plugins/_'
import {
isPluginEssential,
isPluginPendingUpdate,
isPluginStartable,
} from '@revenge-mod/plugins/_'
import { memo } from 'react'
import { Image, Pressable } from 'react-native'
import { handleDisablePlugin, handleEnablePlugin } from '../utils/actions'
@ -144,6 +148,10 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
} = plugin
const essential = isPluginEssential(meta)
const startable = isPluginStartable(plugin)
const pendingUpdate = isPluginPendingUpdate(plugin)
const toggleDisabled = essential || pendingUpdate
const enableTooltip = useEnablePluginTooltip()
const essentialTooltip = useEssentialPluginTooltip()
@ -175,13 +183,13 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
/>
{plugin.SettingsComponent && (
<Pressable
disabled={!startable}
onPress={() => {
if (!enabled)
requestAnimationFrame(() => {
enableTooltip.targetRef.current =
settingsRef.current
enableTooltip.setVisible(true)
})
requestAnimationFrame(() => {
enableTooltip.targetRef.current =
settingsRef.current
enableTooltip.setVisible(true)
})
}}
>
<IconButton
@ -189,27 +197,27 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
size="sm"
variant="secondary"
icon={SettingsIcon}
disabled={!startable}
onPress={() => {
openPluginSettings(plugin)
}}
disabled={!enabled}
/>
</Pressable>
)}
<Pressable
disabled={toggleDisabled}
onPress={() => {
if (essential)
requestAnimationFrame(() => {
essentialTooltip.targetRef.current =
switchRef.current
essentialTooltip.setVisible(true)
})
requestAnimationFrame(() => {
essentialTooltip.targetRef.current =
switchRef.current
essentialTooltip.setVisible(true)
})
}}
ref={switchRef}
>
<FormSwitch
key={plugin.manifest.id}
disabled={essential}
disabled={toggleDisabled}
onValueChange={enabled => {
if (enabled) handleEnablePlugin(plugin)
else handleDisablePlugin(plugin)

View file

@ -0,0 +1,17 @@
import { Design } from '@revenge-mod/discord/design'
const { AlertModal, AlertActionButton, Text } = Design
export default function PluginInstallFailedAlert({ error }: { error: string }) {
return (
<AlertModal
title="Plugin install failed"
content={
<Text variant="text-md/medium" color="text-feedback-critical">
{error}
</Text>
}
actions={<AlertActionButton text="Got it" />}
/>
)
}

View file

@ -11,6 +11,8 @@ import {
InternalPluginFlags,
isPluginEssential,
isPluginInternal,
isPluginPendingUpdate,
isPluginStartable,
PluginFlags,
} from '@revenge-mod/plugins/_'
import { PluginStatus } from '@revenge-mod/plugins/constants'
@ -53,6 +55,7 @@ export default function PluginOptionsActionSheet({
const meta = getInternalPluginMeta(plugin)
const { dependencies, dependents } = meta!
const essential = isPluginEssential(meta)
const pendingUpdate = isPluginPendingUpdate(plugin)
const {
manifest: { id, name, author, description, icon },
errors,
@ -71,6 +74,7 @@ export default function PluginOptionsActionSheet({
!essential && (
<FormSwitch
value={enabled}
disabled={pendingUpdate}
onValueChange={v => {
if (v) handleEnablePlugin(plugin)
else handleDisablePlugin(plugin)
@ -180,10 +184,10 @@ function PluginActions({
plugin: AnyPlugin
closeSheet: () => void
}) {
const enabled = usePluginEnabled(plugin)
const enableTooltip = useEnablePluginTooltip()
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
const meta = getInternalPluginMeta(plugin)
const startable = isPluginStartable(plugin)
return (
<Stack
@ -213,9 +217,8 @@ function PluginActions({
)}
{plugin.SettingsComponent && (
<Pressable
disabled={!startable}
onPress={() => {
if (enabled) return
requestAnimationFrame(() => {
enableTooltip.targetRef.current =
settingsRef.current
@ -223,13 +226,13 @@ function PluginActions({
})
}}
>
<Design.IconButton
<IconButton
ref={settingsRef}
variant="secondary"
size="lg"
icon={SettingsIcon}
label="Settings"
disabled={!enabled}
disabled={!startable}
onPress={() => {
openPluginSettings(plugin)
closeSheet()

View file

@ -1,4 +1,8 @@
import { Design } from '@revenge-mod/discord/design'
import {
isPluginPendingReload,
isPluginPendingUpdate,
} from '@revenge-mod/plugins/_'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
const { AlertModal, AlertActionButton, Text } = Design
@ -10,6 +14,9 @@ export default function PluginUninstallConfirmationAlert({
plugin: AnyPlugin
action: () => Promise<void>
}) {
const reloadPending =
isPluginPendingReload(plugin) || isPluginPendingUpdate(plugin)
return (
<AlertModal
title="Uninstall plugin?"
@ -21,6 +28,17 @@ export default function PluginUninstallConfirmationAlert({
and all of its data will be removed. This cannot be undone.
</Text>
}
extraContent={
reloadPending && (
<Text
variant="text-md/semibold"
color="text-feedback-critical"
>
This plugin is pending a reload. Uninstalling now may
leave side unintended effects.
</Text>
)
}
actions={
<>
<AlertActionButton

View file

@ -1,12 +1,17 @@
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { AlertActionCreators } from '@revenge-mod/discord/actions'
import {
AlertActionCreators,
ToastActionCreators,
} from '@revenge-mod/discord/actions'
import {
refreshSettingsNavigator,
registerSettingsItem,
} from '@revenge-mod/discord/modules/settings'
import { PluginFlags, pEmitter, pList } from '@revenge-mod/plugins/_'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { useLayoutEffect } from 'react'
import defer * as NavigatorHeaderWithIcon from './components/NavigatorHeaderWithIcon'
import PluginInstallFailedAlert from './components/PluginInstallFailedAlert'
import PluginsFailedToStartAlert from './components/PluginsFailedToStartAlert'
import PluginsRequireReloadAlert from './components/PluginsRequireReloadAlert'
import { Setting } from './constants'
@ -71,9 +76,11 @@ pEmitter.on('started', plugin => {
/// RELOAD REQUIRED ALERT
function showReloadRequiredAlertIfNeeded() {
function showPendingReloadAlertIfNeeded() {
const plugins = [...pList.values()].filter(
plugin => plugin.flags & PluginFlags.ReloadRequired,
plugin =>
plugin.flags &
(PluginFlags.PendingReload | PluginFlags.PendingUpdate),
)
if (!plugins.length) return
@ -100,13 +107,50 @@ function showErrorAlertIfNeeded() {
}
pEmitter.on('flagUpdate', plugin => {
if (plugin.flags & PluginFlags.ReloadRequired)
showReloadRequiredAlertIfNeeded()
if (plugin.flags & (PluginFlags.PendingReload | PluginFlags.PendingUpdate))
showPendingReloadAlertIfNeeded()
})
pEmitter.on('stopped', plugin => {
if (plugin.flags & PluginFlags.Errored) showErrorAlertIfNeeded()
})
/// PLUGIN INSTALL FEEDBACK
const pluginInstallToastKeyFor = (id: string) => `REVENGE_PLUGIN_INSTALL:${id}`
const PluginInstallFailedAlertKey = 'plugin-install-failed'
pEmitter.on('install', result => {
if (result.error !== false) {
AlertActionCreators.dismissAlert(PluginInstallFailedAlertKey)
AlertActionCreators.openAlert(
PluginInstallFailedAlertKey,
<PluginInstallFailedAlert error={result.error} />,
)
return
}
showInstallToast(
result.updated
? `Updated ${result.manifest.name}`
: `Installed ${result.manifest.name}`,
result.manifest.id,
)
})
const CircleCheckIcon = lookupGeneratedIconComponent(
'CircleCheckIcon',
'CircleCheckIcon-secondary',
'CircleCheckIcon-primary',
)
function showInstallToast(content: string, id: string) {
ToastActionCreators.open({
key: pluginInstallToastKeyFor(id),
content,
IconComponent: CircleCheckIcon,
})
}
showErrorAlertIfNeeded()
showReloadRequiredAlertIfNeeded()
showPendingReloadAlertIfNeeded()

View file

@ -1,6 +1,9 @@
import { AlertActionCreators } from '@revenge-mod/discord/actions'
import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2'
import { uninstallExternalPlugin } from '@revenge-mod/plugins/_'
import {
isPluginStartable,
uninstallExternalPlugin,
} from '@revenge-mod/plugins/_'
import { deleteStorageForPlugin } from '~plugins/preinit/api.storage'
import PluginClearDataConfirmationAlert from '../components/PluginClearDataConfirmationAlert'
import PluginHasDependenciesAlert from '../components/PluginHasDependenciesAlert'
@ -45,7 +48,7 @@ export function showPluginUninstallConfirmation(
}
export function openPluginSettings(plugin: AnyPlugin) {
if (!plugin.SettingsComponent) return
if (!plugin.SettingsComponent || !isPluginStartable(plugin)) return
const navigation = RootNavigationRef.getRootNavigationRef()
if (navigation.isReady()) navigation.navigate(plugin.manifest.id)