diff --git a/lib/plugins/src/_internal/external-plugins.ts b/lib/plugins/src/_internal/external-plugins.ts index d5ef4fc..565f17b 100644 --- a/lib/plugins/src/_internal/external-plugins.ts +++ b/lib/plugins/src/_internal/external-plugins.ts @@ -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, ]) diff --git a/lib/plugins/src/_internal/index.ts b/lib/plugins/src/_internal/index.ts index 5bfb04f..2d7d0b2 100644 --- a/lib/plugins/src/_internal/index.ts +++ b/lib/plugins/src/_internal/index.ts @@ -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() const pMetadata = new WeakMap() @@ -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( 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): boolean { +export function isPluginEnabled(plugin: AnyPlugin): boolean { return Boolean((plugin as AnyPlugin).flags & Flag.Enabled) } -export function isPluginEnabledLate(plugin: Plugin): boolean { +export function isPluginEnabledLate(plugin: AnyPlugin): 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 { 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): 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 } diff --git a/src/plugins/start/settings/components/PluginCard.tsx b/src/plugins/start/settings/components/PluginCard.tsx index f77c0a6..4f56575 100644 --- a/src/plugins/start/settings/components/PluginCard.tsx +++ b/src/plugins/start/settings/components/PluginCard.tsx @@ -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 && ( { - if (!enabled) - requestAnimationFrame(() => { - enableTooltip.targetRef.current = - settingsRef.current - enableTooltip.setVisible(true) - }) + requestAnimationFrame(() => { + enableTooltip.targetRef.current = + settingsRef.current + enableTooltip.setVisible(true) + }) }} > { openPluginSettings(plugin) }} - disabled={!enabled} /> )} { - if (essential) - requestAnimationFrame(() => { - essentialTooltip.targetRef.current = - switchRef.current - essentialTooltip.setVisible(true) - }) + requestAnimationFrame(() => { + essentialTooltip.targetRef.current = + switchRef.current + essentialTooltip.setVisible(true) + }) }} ref={switchRef} > { if (enabled) handleEnablePlugin(plugin) else handleDisablePlugin(plugin) diff --git a/src/plugins/start/settings/components/PluginInstallFailedAlert.tsx b/src/plugins/start/settings/components/PluginInstallFailedAlert.tsx new file mode 100644 index 0000000..9009e0d --- /dev/null +++ b/src/plugins/start/settings/components/PluginInstallFailedAlert.tsx @@ -0,0 +1,17 @@ +import { Design } from '@revenge-mod/discord/design' + +const { AlertModal, AlertActionButton, Text } = Design + +export default function PluginInstallFailedAlert({ error }: { error: string }) { + return ( + + {error} + + } + actions={} + /> + ) +} diff --git a/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx b/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx index ff5c255..3957a79 100644 --- a/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx +++ b/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx @@ -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 && ( { 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 ( { - if (enabled) return - requestAnimationFrame(() => { enableTooltip.targetRef.current = settingsRef.current @@ -223,13 +226,13 @@ function PluginActions({ }) }} > - { openPluginSettings(plugin) closeSheet() diff --git a/src/plugins/start/settings/components/PluginUninstallConfirmationAlert.tsx b/src/plugins/start/settings/components/PluginUninstallConfirmationAlert.tsx index cb92508..abc3266 100644 --- a/src/plugins/start/settings/components/PluginUninstallConfirmationAlert.tsx +++ b/src/plugins/start/settings/components/PluginUninstallConfirmationAlert.tsx @@ -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 }) { + const reloadPending = + isPluginPendingReload(plugin) || isPluginPendingUpdate(plugin) + return ( } + extraContent={ + reloadPending && ( + + This plugin is pending a reload. Uninstalling now may + leave side unintended effects. + + ) + } actions={ <> { /// 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, + , + ) + 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() diff --git a/src/plugins/start/settings/utils/alerts.tsx b/src/plugins/start/settings/utils/alerts.tsx index 69c91c8..10a8b83 100644 --- a/src/plugins/start/settings/utils/alerts.tsx +++ b/src/plugins/start/settings/utils/alerts.tsx @@ -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)