From 00047da9b33ddd64c06e9551c2e1cc7825478e4d Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Mon, 6 Jul 2026 23:48:27 +0700 Subject: [PATCH 01/23] feat(lib/modules): support native to JS bridge --- lib/modules/src/native/index.ts | 58 +++++++++++++++++++++++++++++++++ types/globals.d.ts | 4 +++ 2 files changed, 62 insertions(+) diff --git a/lib/modules/src/native/index.ts b/lib/modules/src/native/index.ts index 1034384..a09ac1a 100644 --- a/lib/modules/src/native/index.ts +++ b/lib/modules/src/native/index.ts @@ -1,3 +1,5 @@ +import { getCurrentStack, getErrorStack } from '@revenge-mod/utils/error' + const turboModuleProxy = globalThis.__turboModuleProxy /** @@ -103,6 +105,61 @@ export function getBridgeInfo(): BridgeInfo | null { } } +type AnyFunction = (...args: any[]) => any + +const CallableReturnNativeMethodName = 'revenge.__callableReturn' as const +const CallableModuleName = 'RevengeBridge' +const ExposedJSMethods: { + [methodName: string]: AnyFunction +} = {} + +/** + * Registers a JS method that can be called from native code. + * + * @param name The name of the method to register. + * @param method The method implementation. + */ +export function registerJSMethod(name: string, method: AnyFunction) { + if (__DEV__ && ExposedJSMethods[name]) + nativeLoggingHook( + `\u001b[33mWarning: Overwriting existing registered JS method: ${name}\n${getCurrentStack()}\u001b[0m`, + 1, + ) + + ExposedJSMethods[name] = _wrapJSMethod(method) +} + +function _wrapJSMethod(method: AnyFunction) { + return (...args: any[]) => { + try { + const ret = method(...args) + _returnJSCall({ result: ret ?? null }) + } catch (error) { + _returnJSCall({ error: getErrorStack(error) }) + } + } +} + +function _returnJSCall(payload: object) { + callBridgeMethodSync(CallableReturnNativeMethodName, [payload]) +} + +RN$registerCallableModule( + CallableModuleName, + () => + new Proxy(ExposedJSMethods, { + get(target, prop: string) { + const method = target[prop] + if (!method) + return () => { + // Can't catch this native side, this will crash the app + throw new Error(`JS method not found: ${prop}`) + } + return method + }, + }), +) + export interface BridgeInfo { name: string version: number @@ -114,4 +171,5 @@ export type MethodResult = Methods[T][1] export interface Methods { 'revenge.info': [[], BridgeInfo] + [CallableReturnNativeMethodName]: [[payload: object], void] } diff --git a/types/globals.d.ts b/types/globals.d.ts index f155af3..8f58aca 100644 --- a/types/globals.d.ts +++ b/types/globals.d.ts @@ -109,6 +109,10 @@ declare global { var nativeModuleProxy: Record var __turboModuleProxy: ((name: string) => unknown) | undefined function nativeLoggingHook(str: string, level: number): void + function RN$registerCallableModule( + name: string, + moduleProvider: () => object, + ): void function alert(message: unknown): void function queueMicrotask(cb: () => void): void From bf8b99efb96880b15f58d730e178fcb9836c2bd6 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Tue, 7 Jul 2026 00:03:14 +0700 Subject: [PATCH 02/23] feat(lib/plugins): external plugins support This is the first draft. Things may change in the future. Loader changes are required as well. --- lib/plugins/src/_internal/decorators.ts | 4 +- lib/plugins/src/_internal/external-plugins.ts | 101 ++++++++++ lib/plugins/src/_internal/index.ts | 177 ++++++++++++++---- lib/plugins/src/constants.ts | 28 --- lib/plugins/src/preinit.ts | 2 + lib/plugins/src/types.ts | 20 +- src/plugins/preinit/api.discord/index.ts | 7 +- src/plugins/preinit/api.storage/index.ts | 14 +- src/plugins/preinit/api.utils/index.ts | 7 +- src/plugins/preinit/logging/index.ts | 7 +- src/plugins/preinit/no-track/index.ts | 13 +- src/plugins/start/developer-kit/index.ts | 10 +- src/plugins/start/error-boundary/index.tsx | 7 +- .../components/PluginOptionsActionSheet.tsx | 18 +- .../PluginUninstallConfirmationAlert.tsx | 36 ++++ src/plugins/start/settings/index.ts | 7 +- src/plugins/start/settings/plugins.tsx | 3 +- .../screens/RevengePluginsSettingScreen.tsx | 74 +++++++- src/plugins/start/settings/utils/alerts.tsx | 20 ++ src/plugins/start/staff-settings/index.ts | 7 +- src/plugins/start/user-badges/index.tsx | 9 +- 21 files changed, 460 insertions(+), 111 deletions(-) create mode 100644 lib/plugins/src/_internal/external-plugins.ts create mode 100644 src/plugins/start/settings/components/PluginUninstallConfirmationAlert.tsx 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, From 420f3e803ef8a96b7b100e4b147dcc7decd944b4 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Tue, 7 Jul 2026 00:21:53 +0700 Subject: [PATCH 03/23] fix(lib/plugins)!: update storage layout No migration here. It also replaces some specific APIs. --- lib/plugins/src/constants.ts | 3 ++- src/plugins/preinit/api.storage/index.ts | 12 ++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/plugins/src/constants.ts b/lib/plugins/src/constants.ts index 7d9f9ce..3dd15a3 100644 --- a/lib/plugins/src/constants.ts +++ b/lib/plugins/src/constants.ts @@ -11,4 +11,5 @@ export const PluginStatus = { Stopping: 1 << 6, } -export const PluginsStorageDirectory = 'revenge/plugins/storage' +export const pluginStoragePathFor = (id: string) => + `revenge/plugins/storage/${id}/storage.json` diff --git a/src/plugins/preinit/api.storage/index.ts b/src/plugins/preinit/api.storage/index.ts index cf10df1..48c09f8 100644 --- a/src/plugins/preinit/api.storage/index.ts +++ b/src/plugins/preinit/api.storage/index.ts @@ -3,7 +3,7 @@ import { PluginFlags, registerPlugin, } from '@revenge-mod/plugins/_' -import { PluginsStorageDirectory } from '@revenge-mod/plugins/constants' +import { pluginStoragePathFor } 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' @@ -31,7 +31,7 @@ registerPlugin( }, init({ decorate }) { const makePluginStorage = (plugin: Plugin, opts?: StorageOptions) => - getStorage(getStoragePathForPlugin(plugin), { + getStorage(pluginStoragePathFor(plugin.manifest.id), { ...opts, directory: 'documents', }) @@ -55,13 +55,9 @@ registerPlugin( InternalPluginFlags.API, ) -function getStoragePathForPlugin(plugin: Plugin) { - return `${PluginsStorageDirectory}/${plugin.manifest.id}.json` -} - 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)) + const storage = + api?.storage ?? getStorage(pluginStoragePathFor(plugin.manifest.id)) if (await storage.exists()) await storage.delete() } From 27c445e196ef185a61ddceed4298b25c32ed09e0 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Tue, 7 Jul 2026 21:50:36 +0700 Subject: [PATCH 04/23] fix(lib/modules/native): await callable method properly --- lib/modules/src/native/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/modules/src/native/index.ts b/lib/modules/src/native/index.ts index a09ac1a..62b4180 100644 --- a/lib/modules/src/native/index.ts +++ b/lib/modules/src/native/index.ts @@ -133,7 +133,12 @@ function _wrapJSMethod(method: AnyFunction) { return (...args: any[]) => { try { const ret = method(...args) - _returnJSCall({ result: ret ?? null }) + if (ret instanceof Promise) + ret.then( + result => _returnJSCall({ result: result ?? null }), + error => _returnJSCall({ error: getErrorStack(error) }), + ) + else _returnJSCall({ result: ret ?? null }) } catch (error) { _returnJSCall({ error: getErrorStack(error) }) } From fb1a31640fdd0c358fa802112a99f12d4f70727c Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Tue, 7 Jul 2026 23:06:47 +0700 Subject: [PATCH 05/23] fix(plugins/settings): use new imported path for settings navigator patch --- src/plugins/start/settings/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/start/settings/index.ts b/src/plugins/start/settings/index.ts index 9f6239f..9b75077 100644 --- a/src/plugins/start/settings/index.ts +++ b/src/plugins/start/settings/index.ts @@ -43,7 +43,7 @@ const pluginSettings = registerPlugin( }) waitForModuleWithImportedPath( - 'modules/user_settings/native/core/SettingsNavigator.tsx', + 'modules/user_settings/core/native/SettingsNavigator.tsx', exports => { patchSettingsNavigator(exports) }, From fb3692bc2d3f8256021d81387a8c5a01eacc8df6 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Tue, 7 Jul 2026 23:12:40 +0700 Subject: [PATCH 06/23] 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 --- lib/plugins/src/_internal/external-plugins.ts | 81 +++++++++++++--- lib/plugins/src/_internal/index.ts | 96 +++++++++++++------ .../start/settings/components/PluginCard.tsx | 38 +++++--- .../components/PluginInstallFailedAlert.tsx | 17 ++++ .../components/PluginOptionsActionSheet.tsx | 13 ++- .../PluginUninstallConfirmationAlert.tsx | 18 ++++ src/plugins/start/settings/plugins.tsx | 56 +++++++++-- src/plugins/start/settings/utils/alerts.tsx | 7 +- 8 files changed, 258 insertions(+), 68 deletions(-) create mode 100644 src/plugins/start/settings/components/PluginInstallFailedAlert.tsx 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) From 3f60b4dfb716ff80c45a402dabf73c39b12acccd Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Thu, 9 Jul 2026 23:09:29 +0700 Subject: [PATCH 07/23] feat!: more plugin system improvements + API changes + other fixes BREAKING CHANGES: - lib/modules!: Renamed `callBridgeMethod*` to `callNativeMethod*` and interface `Methods` to `NativeMethods`. This is to achieve naming consistency with the native API and be more clear. - lib/storage!: Renamed to `@revenge-mod/json-storage`. A storage can be in any arbitrary format. While there's little potential for non-JSON storage formats, but the naming should allow for it regardless. This is not a drop-in replacements. There are breaking changes in export names and new APIs. - plugins/revenge.api.storage!: Renamed to `revenge.api.jsonStorage`. Plugin options are renamed and its structure has changed as well. - Native now holds the path for official storage APIs. This update uses new paths without any migration! - lib/plugins!: `Plugin.errors` is now read-only. Use `Plugin.reportError(e)` instead. - lib/plugins!: `Plugin.flags` has been completely removed in favor of these methods: `Plugin.requireReload()`, `Plugin.disable()`. OTHER CHANGES: - Fixed sorting for 'Enabled first' - Updated native method names to be more consistent - Cleand up dead code - Make methods more 'safe' by removing non-null assertion casts --- lib/assets/src/caches.ts | 10 +- lib/{storage => json-storage}/package.json | 2 +- lib/{storage => json-storage}/src/index.ts | 118 +++++++------ lib/{storage => json-storage}/src/init.ts | 10 +- lib/json-storage/src/types.ts | 31 ++++ lib/modules/src/caches.ts | 10 +- lib/modules/src/metro/patches.ts | 4 +- lib/modules/src/native/fs.ts | 26 +-- lib/modules/src/native/index.ts | 16 +- lib/plugins/src/_internal/decorators.ts | 2 +- lib/plugins/src/_internal/dependency-graph.ts | 2 +- lib/plugins/src/_internal/external-plugins.ts | 33 ++-- lib/plugins/src/_internal/index.ts | 163 +++++++++++------- lib/plugins/src/constants.ts | 14 +- lib/plugins/src/types.ts | 12 +- lib/storage/src/types.ts | 23 --- src/index.ts | 6 +- src/init.ts | 2 +- src/plugins/preinit/api.json-storage/index.ts | 81 +++++++++ src/plugins/preinit/api.storage/index.ts | 63 ------- .../definitions/DTAutoConnectSetting.tsx | 4 +- .../definitions/RDTAutoConnectSetting.tsx | 4 +- src/plugins/start/developer-kit/index.ts | 8 +- .../screens/RevengeDeveloperSettingScreen.tsx | 10 +- .../start/settings/components/PluginCard.tsx | 24 +-- .../components/PluginOptionsActionSheet.tsx | 17 +- src/plugins/start/settings/plugins.tsx | 19 +- .../screens/RevengePluginsSettingScreen.tsx | 16 +- src/plugins/start/settings/utils/actions.tsx | 2 +- src/plugins/start/settings/utils/alerts.tsx | 7 +- 30 files changed, 427 insertions(+), 312 deletions(-) rename lib/{storage => json-storage}/package.json (85%) rename lib/{storage => json-storage}/src/index.ts (58%) rename lib/{storage => json-storage}/src/init.ts (61%) create mode 100644 lib/json-storage/src/types.ts delete mode 100644 lib/storage/src/types.ts create mode 100644 src/plugins/preinit/api.json-storage/index.ts delete mode 100644 src/plugins/preinit/api.storage/index.ts diff --git a/lib/assets/src/caches.ts b/lib/assets/src/caches.ts index 926e693..1fc3d43 100644 --- a/lib/assets/src/caches.ts +++ b/lib/assets/src/caches.ts @@ -1,6 +1,6 @@ import { - callBridgeMethod, - callBridgeMethodSync, + callNativeMethod, + callNativeMethodSync, } from '@revenge-mod/modules/native' import { debounce } from '@revenge-mod/utils/callback' import type { Metro } from '@revenge-mod/modules/types' @@ -15,7 +15,7 @@ export const Uncached: Cache = { // In-memory cache export let cache: Cache = - callBridgeMethodSync('revenge.caches.assets.read', []) ?? Uncached + callNativeMethodSync('revenge.caches.assets.read', []) ?? Uncached if (cache.version !== ExpectedCacheVersion) { // TODO: Alert to user to update build @@ -32,7 +32,7 @@ export interface Cache { } const save = debounce(() => { - callBridgeMethod('revenge.caches.assets.write', [cache.data]) + callNativeMethod('revenge.caches.assets.write', [cache.data]) }, 1000) export function cacheAsset(asset: Asset, moduleId: Metro.ModuleID) { @@ -43,7 +43,7 @@ export function cacheAsset(asset: Asset, moduleId: Metro.ModuleID) { } declare module '@revenge-mod/modules/native' { - interface Methods { + interface NativeMethods { 'revenge.caches.assets.read': [[], Cache | null] 'revenge.caches.assets.write': [[data: Cache['data']], void] } diff --git a/lib/storage/package.json b/lib/json-storage/package.json similarity index 85% rename from lib/storage/package.json rename to lib/json-storage/package.json index cdfa0e4..3af3db1 100644 --- a/lib/storage/package.json +++ b/lib/json-storage/package.json @@ -1,5 +1,5 @@ { - "name": "@revenge-mod/storage", + "name": "@revenge-mod/json-storage", "private": true, "type": "module", "exports": { diff --git a/lib/storage/src/index.ts b/lib/json-storage/src/index.ts similarity index 58% rename from lib/storage/src/index.ts rename to lib/json-storage/src/index.ts index e63f37d..2d0e1f8 100644 --- a/lib/storage/src/index.ts +++ b/lib/json-storage/src/index.ts @@ -1,14 +1,30 @@ -import { FileModule } from '@revenge-mod/discord/native' +import { + exists as fsExists, + getConstants, + readFile, + rm, + writeFile, +} from '@revenge-mod/modules/native/fs' +import { pluginStorageDirFor } from '@revenge-mod/plugins/constants' import { getErrorStack } from '@revenge-mod/utils/error' import { cloneDeep, mergeDeep } from '@revenge-mod/utils/object' import type { AnyObject, DeepPartial, If } from '@revenge-mod/utils/types' -export type StorageSubscription = ( +/** + * Get the storage path for a plugin's JSON storage document. + * + * @param id The plugin ID. + * @param file The file name (or relative path) of the storage document inside the plugin's storage directory. + */ +export const pluginStoragePathFor = (id: string, file = 'storage.json') => + `${pluginStorageDirFor(id)}/${file}` + +export type JsonStorageSubscription = ( update: DeepPartial, - mode: (typeof StorageUpdateMode)[keyof typeof StorageUpdateMode], + mode: (typeof JsonStorageUpdateMode)[keyof typeof JsonStorageUpdateMode], ) => void -export const StorageUpdateMode = { +export const JsonStorageUpdateMode = { /** * The update will be merged into the existing storage. */ @@ -18,29 +34,37 @@ export const StorageUpdateMode = { */ Replace: 1, /** - * Same behavior as {@link StorageUpdateMode.Replace}, but for the intial load of the storage. + * Same behavior as {@link JsonStorageUpdateMode.Replace}, but for the intial load of the storage. */ Load: 2, } as const -export function Storage( - this: Storage, +/** + * Create a new JSON storage object. + * + * @param path The path to the storage document. + * - Relative paths resolve against the app data directory (`/data/data/` on Android). + * Use `files/...` for app data or `cache/...` for cache on Android. + * - Absolute paths are used as-is. + * + * @param options Options for the storage. + */ +export function JsonStorage( + this: JsonStorage, path: string, - options?: StorageOptions, + options?: JsonStorageOptions, ) { - const { CacheDirPath, DocumentsDirPath } = FileModule.getConstants() + const fullPath = path.startsWith('/') + ? path + : `${getConstants().data}/${path}` - const directory = options?.directory ?? 'documents' - const dirPath = directory === 'cache' ? CacheDirPath : DocumentsDirPath - const fullPath = `${dirPath}/${path}` - - const subscriptions = new Set>() + const subscriptions = new Set>() this.loaded = false - this.exists = () => FileModule.fileExists(fullPath) + this.exists = () => fsExists(fullPath) this.delete = async function () { - await FileModule.removeFile(directory, path) + await rm(fullPath) const success = !(await this.exists()) if (this.loaded && success) await this.get() return success @@ -51,13 +75,13 @@ export function Storage( return () => subscriptions.delete(callback) } - async function write(storage: Storage) { + async function write(storage: JsonStorage) { try { const contents = JSON.stringify(storage.cache) - await FileModule.writeFile(directory, path, contents, 'utf8') + await writeFile(fullPath, contents) } catch (e) { nativeLoggingHook( - `Failed to write storage (<${directory}>/${path}): ${getErrorStack(e)}`, + `Failed to write storage (${fullPath}): ${getErrorStack(e)}`, 2, ) } @@ -71,17 +95,17 @@ export function Storage( return this.cache } - const contents = await FileModule.readFile(fullPath, 'utf8') + const contents = await readFile(fullPath) if (contents) { this.loaded = true try { const cache = (this.cache = JSON.parse(contents)) for (const sub of subscriptions) - sub(cache, StorageUpdateMode.Load) + sub(cache, JsonStorageUpdateMode.Load) return cache } catch (e) { nativeLoggingHook( - `Failed to parse storage (<${directory}>/${path}): ${getErrorStack(e)}`, + `Failed to parse storage (${fullPath}): ${getErrorStack(e)}`, 2, ) } @@ -98,7 +122,9 @@ export function Storage( for (const sub of subscriptions) sub( value, - replace ? StorageUpdateMode.Replace : StorageUpdateMode.Merge, + replace + ? JsonStorageUpdateMode.Replace + : JsonStorageUpdateMode.Merge, ) } @@ -106,34 +132,30 @@ export function Storage( } // React is only initialized right before the init stage, so this is a dummy method -// See init.ts in api.storage plugin for the actual implementation -Storage.prototype.use = () => { - throw new Error('Storage#use can only be called after the init stage!') +// See init.ts in api.json-storage plugin for the actual implementation +JsonStorage.prototype.use = () => { + throw new Error('JsonStorage#use can only be called after the init stage!') } /** - * Get a storage object for a given path and directory. + * Get a JSON storage object for a given path. * - * @param path Path relative to the directory. - * @param directory Directory to use. Can be either 'cache' or 'documents'. + * @param path The path to the storage document. + * - Relative paths resolve against the app data directory (`/data/data/` on Android). + * Use `files/...` for app data or `cache/...` for cache on Android. + * - Absolute paths are used as-is. */ -export function getStorage( +export function getJsonStorage( path: string, - options?: StorageOptions, -): Storage { - const storage: Storage = Object.create(Storage.prototype) - Storage.call(storage, path, options) + options?: JsonStorageOptions, +): JsonStorage { + const storage: JsonStorage = Object.create(JsonStorage.prototype) + JsonStorage.call(storage, path, options) return storage } -export interface StorageOptions { - /** - * The directory of the storage file. - * - * @default 'documents' - */ - directory?: StorageDirectory +export interface JsonStorageOptions { /** * The default value to use for the storage. This will also be used for cache. * @@ -148,11 +170,11 @@ export interface StorageOptions { load?: boolean } -export type UseStorageFilter = ( - ...params: Parameters> +export type UseJsonStorageFilter = ( + ...params: Parameters> ) => any -export interface Storage { +export interface JsonStorage { /** * Whether the storage has been loaded. If the storage is not loaded, `storage.cache` may be `undefined`. * If you have `options.default` set, you can use this property to check if `storage.cache` is the default value or not. @@ -172,7 +194,7 @@ export interface Storage { * ```tsx * type Settings = { key: boolean, nested: { key: boolean } } * - * const SettingsStorage = getStorage('settings.json') + * const SettingsStorage = getJsonStorage('settings.json') * * const MyComponent = () => { * // Re-renders every time any of the keys in the settings object change @@ -190,14 +212,14 @@ export interface Storage { * // ... * } */ - use(filter?: UseStorageFilter): T | undefined + use(filter?: UseJsonStorageFilter): T | undefined /** * Subscribe to storage updates. * * @param callback The callback to call when the storage is updated. * @returns A function to unsubscribe. */ - subscribe(callback: StorageSubscription): () => void + subscribe(callback: JsonStorageSubscription): () => void /** * Get the storage. */ @@ -222,5 +244,3 @@ export interface Storage { */ delete(): Promise } - -export type StorageDirectory = 'cache' | 'documents' diff --git a/lib/storage/src/init.ts b/lib/json-storage/src/init.ts similarity index 61% rename from lib/storage/src/init.ts rename to lib/json-storage/src/init.ts index ba06f07..d4cc10d 100644 --- a/lib/storage/src/init.ts +++ b/lib/json-storage/src/init.ts @@ -1,17 +1,17 @@ -import { Storage } from '@revenge-mod/storage' +import { JsonStorage } from '@revenge-mod/json-storage' import { useReRender } from '@revenge-mod/utils/react' import { useLayoutEffect } from 'react' -import type { StorageSubscription } from '@revenge-mod/storage' +import type { JsonStorageSubscription } from '@revenge-mod/json-storage' -const proto = Storage.prototype as Storage -// Actual implementation of Storage#use +const proto = JsonStorage.prototype as JsonStorage +// Actual implementation of JsonStorage#use proto.use = function (filter) { if (!this.cache) this.get() const reRender = useReRender() useLayoutEffect(() => { - const sub: StorageSubscription = filter + const sub: JsonStorageSubscription = filter ? (update, mode) => { if (filter(update, mode)) reRender() } diff --git a/lib/json-storage/src/types.ts b/lib/json-storage/src/types.ts new file mode 100644 index 0000000..60f65da --- /dev/null +++ b/lib/json-storage/src/types.ts @@ -0,0 +1,31 @@ +import type { AnyObject } from '@revenge-mod/utils/types' +import type { JsonStorage, JsonStorageOptions } from '.' + +declare module '@revenge-mod/plugins/types' { + export interface UnscopedPreInitPluginApi { + jsonStorage: typeof import('.') + } + + export interface PluginApiExtensionsOptions { + jsonStorage?: AnyObject + } + + export interface PluginOptions { + jsonStorage?: JsonStorageOptions> & { + /** + * File name (or relative path) of the storage document inside the + * plugin's storage directory. + * + * @default 'storage.json' + */ + file?: string + } + } + + export interface InitPluginApi { + /** + * The plugin JSON storage. + */ + jsonStorage: JsonStorage> + } +} diff --git a/lib/modules/src/caches.ts b/lib/modules/src/caches.ts index 314db95..fad3e7a 100644 --- a/lib/modules/src/caches.ts +++ b/lib/modules/src/caches.ts @@ -1,6 +1,6 @@ import { - callBridgeMethod, - callBridgeMethodSync, + callNativeMethod, + callNativeMethodSync, } from '@revenge-mod/modules/native' import { debounce } from '@revenge-mod/utils/callback' import type { Metro } from '@revenge-mod/modules/types' @@ -17,7 +17,7 @@ export const Uncached: Cache = { // In-memory cache export let cache: Cache = - callBridgeMethodSync('revenge.caches.modules.read', []) ?? Uncached + callNativeMethodSync('revenge.caches.modules.read', []) ?? Uncached if (cache.version !== ExpectedCacheVersion) { // TODO: Alert to user to update build @@ -37,7 +37,7 @@ export interface Cache { } const save = debounce(() => { - callBridgeMethod('revenge.caches.modules.write', [ + callNativeMethod('revenge.caches.modules.write', [ cache.blacklist, cache.finds, ]) @@ -74,7 +74,7 @@ export const getFilterMatches = ( export const getBlacklistedModules = () => cache.blacklist declare module '@revenge-mod/modules/native' { - interface Methods { + interface NativeMethods { 'revenge.caches.modules.read': [[], Cache | null] 'revenge.caches.modules.write': [ [blacklist: Blacklist, finds: Finds], diff --git a/lib/modules/src/metro/patches.ts b/lib/modules/src/metro/patches.ts index edf3db8..e785613 100644 --- a/lib/modules/src/metro/patches.ts +++ b/lib/modules/src/metro/patches.ts @@ -1,4 +1,4 @@ -import { callBridgeMethodSync } from '@revenge-mod/modules/native' +import { callNativeMethodSync } from '@revenge-mod/modules/native' import { getErrorStack } from '@revenge-mod/utils/error' import { FullVersion } from '~constants' import { cache, cacheBlacklistedModule, Uncached } from '../caches' @@ -191,7 +191,7 @@ function handleFactoryCall( const msg = `Module ${mInitializingId} failed to initialize:\n\n${getErrorStack(e)}` if (__DEV__) { - callBridgeMethodSync('revenge.alertError', [msg, FullVersion]) + callNativeMethodSync('revenge.alertError', [msg, FullVersion]) } else { // So... it wasn't a great idea to throw, Discord has pushed a broken build that has some failing modules // Vanilla Metro would swallow the error and just return an empty object as the exports..., insanity diff --git a/lib/modules/src/native/fs.ts b/lib/modules/src/native/fs.ts index 2bb7cce..c2e85d1 100644 --- a/lib/modules/src/native/fs.ts +++ b/lib/modules/src/native/fs.ts @@ -1,50 +1,50 @@ import { - callBridgeMethod, - callBridgeMethodSync, + callNativeMethod, + callNativeMethodSync, } from '@revenge-mod/modules/native' export function readFile(path: string) { - return callBridgeMethod('revenge.fs.read', [path]) + return callNativeMethod('revenge.fs.read', [path]) } export function writeFile(path: string, data: string) { - return callBridgeMethod('revenge.fs.write', [path, data]) + return callNativeMethod('revenge.fs.write', [path, data]) } export function exists(path: string) { - return callBridgeMethod('revenge.fs.exists', [path]) + return callNativeMethod('revenge.fs.exists', [path]) } export function rm(path: string) { - return callBridgeMethod('revenge.fs.delete', [path]) + return callNativeMethod('revenge.fs.delete', [path]) } export function existsSync(path: string) { - return callBridgeMethodSync('revenge.fs.exists', [path]) + return callNativeMethodSync('revenge.fs.exists', [path]) } export function readFileSync(path: string) { - return callBridgeMethodSync('revenge.fs.read', [path]) + return callNativeMethodSync('revenge.fs.read', [path]) } export function writeFileSync(path: string, data: string) { - return callBridgeMethodSync('revenge.fs.write', [path, data]) + return callNativeMethodSync('revenge.fs.write', [path, data]) } export function rmSync(path: string) { - return callBridgeMethodSync('revenge.fs.exists', [path]) + return callNativeMethodSync('revenge.fs.exists', [path]) } export function deleteFileSync(path: string) { - return callBridgeMethodSync('revenge.fs.delete', [path]) + return callNativeMethodSync('revenge.fs.delete', [path]) } export function getConstants() { - return callBridgeMethodSync('revenge.fs.getConstants', []) + return callNativeMethodSync('revenge.fs.getConstants', []) } declare module '@revenge-mod/modules/native' { - export interface Methods { + export interface NativeMethods { 'revenge.fs.getConstants': [ [], { diff --git a/lib/modules/src/native/index.ts b/lib/modules/src/native/index.ts index 62b4180..0a98a80 100644 --- a/lib/modules/src/native/index.ts +++ b/lib/modules/src/native/index.ts @@ -47,7 +47,7 @@ function makePayload(name: string, args: any[]): object { * @param args The arguments to pass to the native method. * @returns A promise that resolves with the result of the native method call. */ -export async function callBridgeMethod( +export async function callNativeMethod( name: N, args: MethodArgs, ): Promise> { @@ -74,7 +74,7 @@ export async function callBridgeMethod( * @param args The arguments to pass to the native method. * @returns The result of the native method call. */ -export function callBridgeMethodSync( +export function callNativeMethodSync( name: N, args: MethodArgs, ): MethodResult { @@ -95,7 +95,7 @@ export function callBridgeMethodSync( */ export function getBridgeInfo(): BridgeInfo | null { try { - return callBridgeMethodSync('revenge.info', []) + return callNativeMethodSync('revenge.info', []) } catch (e) { nativeLoggingHook( `\u001b[31mFailed to get native bridge info: ${e}\u001b[0m`, @@ -146,7 +146,7 @@ function _wrapJSMethod(method: AnyFunction) { } function _returnJSCall(payload: object) { - callBridgeMethodSync(CallableReturnNativeMethodName, [payload]) + callNativeMethodSync(CallableReturnNativeMethodName, [payload]) } RN$registerCallableModule( @@ -170,11 +170,11 @@ export interface BridgeInfo { version: number } -export type MethodName = Extract -export type MethodArgs = Methods[T][0] -export type MethodResult = Methods[T][1] +export type MethodName = Extract +export type MethodArgs = NativeMethods[T][0] +export type MethodResult = NativeMethods[T][1] -export interface Methods { +export interface NativeMethods { 'revenge.info': [[], BridgeInfo] [CallableReturnNativeMethodName]: [[payload: object], void] } diff --git a/lib/plugins/src/_internal/decorators.ts b/lib/plugins/src/_internal/decorators.ts index ecada49..aa44bf7 100644 --- a/lib/plugins/src/_internal/decorators.ts +++ b/lib/plugins/src/_internal/decorators.ts @@ -51,7 +51,7 @@ export function decoratePluginApi( const decorators = store.get(dep) if (decorators) { - const { handleError } = getInternalPluginMeta(dep)! + const { handleError } = getInternalPluginMeta(dep) try { for (const decorator of decorators) diff --git a/lib/plugins/src/_internal/dependency-graph.ts b/lib/plugins/src/_internal/dependency-graph.ts index b1ec25f..06cd828 100644 --- a/lib/plugins/src/_internal/dependency-graph.ts +++ b/lib/plugins/src/_internal/dependency-graph.ts @@ -67,7 +67,7 @@ export function resolvePluginGraph(plugin: AnyPlugin) { pRootNodes.add(plugin) for (const dep of getPluginDependencies(plugin)) { - const depMeta = getInternalPluginMeta(dep)! + const depMeta = getInternalPluginMeta(dep) depMeta.dependents.push(plugin) // Not a root node if it has dependencies diff --git a/lib/plugins/src/_internal/external-plugins.ts b/lib/plugins/src/_internal/external-plugins.ts index 565f17b..660fb51 100644 --- a/lib/plugins/src/_internal/external-plugins.ts +++ b/lib/plugins/src/_internal/external-plugins.ts @@ -1,12 +1,14 @@ import { - callBridgeMethod, - callBridgeMethodSync, + callNativeMethod, + callNativeMethodSync, registerJSMethod, } from '@revenge-mod/modules/native' import { getErrorStack } from '@revenge-mod/utils/error' import { pUnscopedApi } from '../apis' import { disablePlugin, + getInternalPluginMeta, + handlePluginError, InternalPluginFlags, isPluginEnabled, isPluginPendingReload, @@ -36,7 +38,7 @@ type PluginInstallResult = export function registerExternalPlugins() { registerJSMethod( - 'revenge.plugins.loader.stopPlugin', + 'revenge.plugins.stop', async function stopHandler(id: string) { const plugin = pList.get(id) if (plugin?.status) await stopPlugin(plugin) @@ -44,7 +46,7 @@ export function registerExternalPlugins() { ) registerJSMethod( - 'revenge.plugins.loader.pluginInstalled', + 'revenge.plugins.events.pluginInstalled', async function installedHandler(result: PluginInstallResult) { if (result.error !== false) { pEmitter.emit('install', { error: result.error }) @@ -72,7 +74,8 @@ export function registerExternalPlugins() { registerExternalPlugin(plugin).id, )! - registered.flags |= PluginFlags.PendingUpdate + const meta = getInternalPluginMeta(registered) + meta.flags |= PluginFlags.PendingUpdate } else await loadExternalPlugin(plugin) } catch (e) { pEmitter.emit('install', { error: getErrorStack(e) }) @@ -87,10 +90,7 @@ export function registerExternalPlugins() { }, ) - const externals = callBridgeMethodSync( - 'revenge.plugins.loader.getPlugins', - [], - ) + const externals = callNativeMethodSync('revenge.plugins.list', []) if (!externals) return for (const external of externals) @@ -121,8 +121,7 @@ export function registerExternalPlugin(external: ExternalPlugin) { // Sync errors the native side already caught if (errors?.length) { const plugin = pList.get(dep.id)! - plugin.errors.push(...errors) - plugin.flags |= PluginFlags.Errored + for (const error of errors) handlePluginError(error, plugin) } return dep @@ -138,9 +137,7 @@ export async function loadExternalPlugin(external: ExternalPlugin) { export async function uninstallExternalPlugin(plugin: AnyPlugin) { if (isPluginEnabled(plugin)) await disablePlugin(plugin) - await callBridgeMethod('revenge.plugins.loader.uninstallPlugin', [ - plugin.manifest.id, - ]) + await callNativeMethod('revenge.plugins.uninstall', [plugin.manifest.id]) pList.delete(plugin.manifest.id) pEmitter.emit('unregister', plugin) @@ -152,9 +149,9 @@ function createOptionsFactory(script?: string): PluginOptionsFactory { } 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] + interface NativeMethods { + 'revenge.plugins.list': [[], ExternalPlugin[] | null] + 'revenge.plugins.install': [[], null] + 'revenge.plugins.uninstall': [[string], null] } } diff --git a/lib/plugins/src/_internal/index.ts b/lib/plugins/src/_internal/index.ts index 2d7d0b2..e0f43c7 100644 --- a/lib/plugins/src/_internal/index.ts +++ b/lib/plugins/src/_internal/index.ts @@ -1,13 +1,14 @@ import { TypedEventEmitter } from '@revenge-mod/discord/common/utils' import { - callBridgeMethod, - callBridgeMethodSync, + callNativeMethod, + callNativeMethodSync, registerJSMethod, } from '@revenge-mod/modules/native' +import { exists, rm } from '@revenge-mod/modules/native/fs' import { getErrorStack } from '@revenge-mod/utils/error' import { sleepReject } from '@revenge-mod/utils/promise' import { pUnscopedApi as uapi } from '../apis' -import { PluginStatus as Status } from '../constants' +import { pluginStorageDirFor, PluginStatus as Status } from '../constants' import { addPluginApiDecorator, decoratePluginApi, @@ -35,10 +36,7 @@ import type { PreInitPluginApi, } from '../types' -export type AnyPlugin = Plugin & { - /** @internal */ - flags: number -} +export type AnyPlugin = Plugin const MaxWaitTime = 5000 @@ -47,13 +45,12 @@ const MaxWaitTime = 5000 export const PluginFlags = { Enabled: 1 << 0, PendingReload: 1 << 1, - Errored: 1 << 2, - EnabledLate: 1 << 3, + EnabledLate: 1 << 2, /** * 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, + PendingUpdate: 1 << 3, } const Flag = PluginFlags @@ -92,6 +89,7 @@ export interface InternalPluginMeta { options: PluginOptions optionsFactory?: PluginOptionsFactory flags: number + nativeErrors: readonly string[] } export const pUnscopedApi = uapi @@ -116,8 +114,10 @@ export type PluginInstallEvent = export const pList = new Map() const pMetadata = new WeakMap() +/// STATE-SYNC + const { states: InitialPersistedStates }: PersistedPluginStates = - callBridgeMethodSync('revenge.plugins.states.read', []) ?? { + callNativeMethodSync('revenge.plugins.states.read', []) ?? { states: {}, } @@ -133,29 +133,48 @@ function pluginStateToFlags(state: PluginStateObject): number { return ( (state.enabled ? Flag.Enabled : 0) | (state.pendingReload ? Flag.PendingReload : 0) | - (state.errored ? Flag.Errored : 0) | (state.enabledLate ? Flag.EnabledLate : 0) ) } async function applyPluginFlags(id: PluginManifest['id'], flags: number) { const plugin = pList.get(id) - if (!plugin || plugin.flags === flags) return + if (!plugin) return - const wasEnabled = plugin.flags & Flag.Enabled + const meta = getInternalPluginMeta(plugin) + if (meta.flags === flags) return + + const wasEnabled = meta.flags & Flag.Enabled const nowEnabled = flags & Flag.Enabled if (wasEnabled && !nowEnabled) { if (plugin.status && !(plugin.status & Status.Stopping)) await stopPlugin(plugin) - plugin.flags = flags + meta.flags = flags pEmitter.emit('disabled', plugin) } else { - plugin.flags = flags + meta.flags = flags if (!wasEnabled && nowEnabled) pEmitter.emit('enabled', plugin) } } +/// NATIVE-ERROR SYNC + +registerJSMethod( + 'revenge.plugins.events.pluginErrored', + (id: string, errors: string[]) => { + const plugin = pList.get(id) + if (!plugin) return + + const meta = getInternalPluginMeta(plugin) + if (meta) { + meta.nativeErrors = Object.freeze(errors) + } + }, +) + +/// REST OF THE SYSTEM + /** * Registers a new plugin with the system. * @@ -190,22 +209,20 @@ export function registerPlugin( status: 0, disable: () => disablePlugin(plugin), stop: () => stopPlugin(plugin), + reportError: (e: unknown) => handlePluginError(e, plugin), requireReload: () => { - plugin.flags |= Flag.PendingReload + meta.flags |= Flag.PendingReload }, api: undefined, - set flags(flags: number) { - if (meta.flags === flags) return - meta.flags = flags - pEmitter.emit('flagUpdate', this) - }, - get flags() { - return meta.flags - }, } + let flags = InitialPersistedStates[manifest.id] + ? pluginStateToFlags(InitialPersistedStates[manifest.id]) + : defflags + const meta: InternalPluginMeta = { cleanups: [], + nativeErrors: Object.freeze([]), promises: [], iflags, apiLevel: PluginApiLevel.None, @@ -213,9 +230,14 @@ export function registerPlugin( handleError: e => handlePluginError(e, plugin), options: resolved ?? {}, optionsFactory: factory, - flags: InitialPersistedStates[manifest.id] - ? pluginStateToFlags(InitialPersistedStates[manifest.id]) - : defflags, + set flags(newFlags: number) { + if (newFlags === flags) return + flags = newFlags + pEmitter.emit('flagUpdate', plugin) + }, + get flags() { + return flags + }, } pMetadata.set(plugin, meta) @@ -268,11 +290,13 @@ export function getPluginDependencies(plugin: AnyPlugin): AnyPlugin[] { } export function isPluginEnabled(plugin: AnyPlugin): boolean { - return Boolean((plugin as AnyPlugin).flags & Flag.Enabled) + const meta = getInternalPluginMeta(plugin) + return Boolean(meta && meta.flags & Flag.Enabled) } export function isPluginEnabledLate(plugin: AnyPlugin): boolean { - return Boolean((plugin as AnyPlugin).flags & Flag.EnabledLate) + const meta = getInternalPluginMeta(plugin) + return Boolean(meta && meta.flags & Flag.EnabledLate) } export function isPluginEssential({ iflags }: InternalPluginMeta): boolean { @@ -284,15 +308,17 @@ export function isPluginInternal({ iflags }: InternalPluginMeta): boolean { } export function isPluginErrored(plugin: AnyPlugin): boolean { - return Boolean((plugin as AnyPlugin).flags & Flag.Errored) + return plugin.errors.length > 0 } export function isPluginPendingReload(plugin: AnyPlugin): boolean { - return Boolean((plugin as AnyPlugin).flags & Flag.PendingReload) + const meta = getInternalPluginMeta(plugin) + return Boolean(meta && meta.flags & Flag.PendingReload) } export function isPluginPendingUpdate(plugin: AnyPlugin): boolean { - return Boolean((plugin as AnyPlugin).flags & Flag.PendingUpdate) + const meta = getInternalPluginMeta(plugin) + return Boolean(meta && meta.flags & Flag.PendingUpdate) } /** @@ -327,9 +353,10 @@ export function isPluginStartable(plugin: AnyPlugin): boolean { /** * Handles errors that occur in plugins. */ -async function handlePluginError(e: unknown, plugin: AnyPlugin) { - plugin.errors.push(e) - plugin.flags |= Flag.Errored +export async function handlePluginError(e: unknown, plugin: AnyPlugin) { + ;(plugin.errors as unknown[]).push(e) + + // TODO: Emit errored event so UI can update? nativeLoggingHook( `\u001b[31mPlugin "${plugin.manifest.id}" encountered an error: ${getErrorStack(e)}\u001b[0m`, @@ -337,9 +364,12 @@ async function handlePluginError(e: unknown, plugin: AnyPlugin) { ) plugin.api?.logger?.error('Plugin encountered an error', e) - pEmitter.emit('errored', plugin, e) - if (!isPluginEssential(getInternalPluginMeta(plugin)!)) + if ( + !isPluginEssential(getInternalPluginMeta(plugin)) && + // Multiple errors may surface, but we only want to disable the plugin once + isPluginEnabled(plugin) + ) await plugin.disable() } @@ -370,12 +400,11 @@ function resolvePluginOptions(plugin: AnyPlugin, meta: InternalPluginMeta) { * Prepares the plugin API for the preInit lifecycle. */ function tryPreparePluginPreInit(plugin: AnyPlugin) { - const meta = getInternalPluginMeta(plugin)! + const meta = getInternalPluginMeta(plugin) if (meta.apiLevel >= PluginApiLevel.PreInit) return // Clear errors from previous runs plugin.errors = [] - plugin.flags &= ~Flag.Errored resolvePluginOptions(plugin, meta) @@ -398,7 +427,7 @@ function tryPreparePluginPreInit(plugin: AnyPlugin) { * Prepares the plugin API for the init lifecycle. */ function tryPreparePluginInit(plugin: AnyPlugin) { - const meta = getInternalPluginMeta(plugin)! + const meta = getInternalPluginMeta(plugin) if (meta.apiLevel >= PluginApiLevel.Init) return const api = plugin.api as InitPluginApi @@ -415,7 +444,7 @@ function tryPreparePluginInit(plugin: AnyPlugin) { * Prepares the plugin API for the start lifecycle. */ function tryPreparePluginStart(plugin: AnyPlugin) { - const meta = getInternalPluginMeta(plugin)! + const meta = getInternalPluginMeta(plugin) if (meta.apiLevel >= PluginApiLevel.Start) return const api = plugin.api as PluginApi @@ -434,7 +463,7 @@ function tryPreparePluginStart(plugin: AnyPlugin) { export async function disablePlugin(plugin: AnyPlugin) { requirePluginStartableState(plugin) - const meta = getInternalPluginMeta(plugin)! + const meta = getInternalPluginMeta(plugin) if (isPluginEssential(meta)) throw new Error( @@ -445,7 +474,8 @@ export async function disablePlugin(plugin: AnyPlugin) { await Promise.all( dependents.map(dep => { - if (dep.flags & Flag.Enabled) return disablePlugin(dep) + if (getInternalPluginMeta(dep)!.flags & Flag.Enabled) + return disablePlugin(dep) }), ) @@ -453,12 +483,12 @@ export async function disablePlugin(plugin: AnyPlugin) { if (plugin.status && !(plugin.status & Status.Stopping)) await stopPlugin(plugin) - await callBridgeMethod('revenge.plugins.states.setEnabled', [ + await callNativeMethod('revenge.plugins.setEnabled', [ plugin.manifest.id, false, ]) - plugin.flags &= ~Flag.Enabled + meta.flags &= ~Flag.Enabled pEmitter.emit('disabled', plugin) } @@ -475,12 +505,14 @@ export async function enablePlugin(plugin: AnyPlugin) { }), ) - await callBridgeMethod('revenge.plugins.states.setEnabled', [ + await callNativeMethod('revenge.plugins.setEnabled', [ plugin.manifest.id, true, ]) - plugin.flags |= Flag.Enabled + const meta = getInternalPluginMeta(plugin) + meta.flags |= Flag.Enabled + pEmitter.emit('enabled', plugin) } @@ -500,11 +532,12 @@ export async function runPluginLate(plugin: AnyPlugin) { pListOrdered .filter(plugin => !plugin.status) .map(async function runLate(plugin) { - plugin.flags |= Flag.EnabledLate + const meta = getInternalPluginMeta(plugin) + meta.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 - await callBridgeMethod('revenge.plugins.loader.start', [ + await callNativeMethod('revenge.plugins.startNative', [ plugin.manifest.id, ]) @@ -534,7 +567,7 @@ export async function preInitPlugin(plugin: AnyPlugin) { tryPreparePluginPreInit(plugin) const { lifecycles } = plugin - const { promises, handleError } = getInternalPluginMeta(plugin)! + const { promises, handleError } = getInternalPluginMeta(plugin) try { if (!lifecycles.preInit) return @@ -623,7 +656,7 @@ export async function startPlugin(plugin: AnyPlugin) { tryPreparePluginStart(plugin) const { lifecycles } = plugin - const { promises, handleError } = getInternalPluginMeta(plugin)! + const { promises, handleError } = getInternalPluginMeta(plugin) try { if (!lifecycles.start) return @@ -659,7 +692,7 @@ export async function stopPlugin(plugin: AnyPlugin) { manifest: { id }, } = plugin - const meta = getInternalPluginMeta(plugin)! + const meta = getInternalPluginMeta(plugin) if (isPluginEssential(meta)) throw new Error(`Plugin "${id}" is essential and cannot be stopped`) @@ -679,7 +712,7 @@ export async function stopPlugin(plugin: AnyPlugin) { 'Plugin lifecycles timed out, force stopping', ), ]).catch(e => { - plugin.flags |= Flag.PendingReload + meta.flags |= Flag.PendingReload return handlePluginError(e, plugin) }) else if ( @@ -721,7 +754,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.PendingReload + meta.flags |= Flag.PendingReload return handlePluginError(e, plugin) } @@ -738,16 +771,28 @@ async function cleanupPlugin(plugin: AnyPlugin, meta: InternalPluginMeta) { } export function getInternalPluginMeta(plugin: AnyPlugin): InternalPluginMeta { - return pMetadata.get(plugin)! + const meta = pMetadata.get(plugin) + if (!meta) + throw new Error( + `Plugin "${plugin.manifest.id}" has no internal metadata, is it registered?`, + ) + + return meta +} + +export async function deleteStorageForPlugin(plugin: Plugin) { + const dir = pluginStorageDirFor(plugin.manifest.id) + + if (await exists(dir)) await rm(dir) } export { uninstallExternalPlugin } from './external-plugins' declare module '@revenge-mod/modules/native' { - interface Methods { - 'revenge.plugins.loader.start': [[id: PluginManifest['id']], null] + interface NativeMethods { + 'revenge.plugins.startNative': [[id: PluginManifest['id']], null] 'revenge.plugins.states.read': [[], PersistedPluginStates | null] - 'revenge.plugins.states.setEnabled': [ + 'revenge.plugins.setEnabled': [ [id: PluginManifest['id'], enabled: boolean], null, ] diff --git a/lib/plugins/src/constants.ts b/lib/plugins/src/constants.ts index 3dd15a3..73794b1 100644 --- a/lib/plugins/src/constants.ts +++ b/lib/plugins/src/constants.ts @@ -1,3 +1,5 @@ +import { callNativeMethodSync } from '@revenge-mod/modules/native' + /** * The plugin status. */ @@ -11,5 +13,13 @@ export const PluginStatus = { Stopping: 1 << 6, } -export const pluginStoragePathFor = (id: string) => - `revenge/plugins/storage/${id}/storage.json` +const { storageRootPath } = callNativeMethodSync( + 'revenge.plugins.getConstants', + [], +) + +/** + * Per-plugin storage directory, relative to app data directory. + */ +// TODO: is it best to share this with the native side, or should we let native side be the sole source of truth? +export const pluginStorageDirFor = (id: string) => `${storageRootPath}/${id}` diff --git a/lib/plugins/src/types.ts b/lib/plugins/src/types.ts index bde3ad9..b71757a 100644 --- a/lib/plugins/src/types.ts +++ b/lib/plugins/src/types.ts @@ -264,7 +264,11 @@ export interface Plugin< /** * Errors encountered during the plugin lifecycles. */ - errors: unknown[] + errors: readonly unknown[] + /** + * Reports an error encountered during the plugin's execution. + */ + reportError(e: unknown): void SettingsComponent?: PluginSettingsComponent @@ -308,3 +312,9 @@ export type PluginApiInLifecycleMap< export interface PluginSettingsComponent< O extends PluginApiExtensionsOptions = PluginApiExtensionsOptions, > extends FunctionComponent<{ api: PluginApi }> {} + +declare module '@revenge-mod/modules/native' { + export interface NativeMethods { + 'revenge.plugins.getConstants': [[], { storageRootPath: string }] + } +} diff --git a/lib/storage/src/types.ts b/lib/storage/src/types.ts deleted file mode 100644 index 458d263..0000000 --- a/lib/storage/src/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { AnyObject } from '@revenge-mod/utils/types' -import type { Storage, StorageOptions } from '.' - -declare module '@revenge-mod/plugins/types' { - export interface UnscopedPreInitPluginApi { - storage: typeof import('.') - } - - export interface PluginApiExtensionsOptions { - storage?: AnyObject - } - - export interface PluginOptions { - storage?: Omit>, 'directory'> - } - - export interface InitPluginApi { - /** - * The plugin storage. - */ - storage: Storage> - } -} diff --git a/src/index.ts b/src/index.ts index bd2a628..43af63d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { callBridgeMethod } from '@revenge-mod/modules/native' +import { callNativeMethod } from '@revenge-mod/modules/native' import { getErrorStack } from '@revenge-mod/utils/error' import { BuildEnvironment, FullVersion } from '~constants' import type { Metro } from '@revenge-mod/modules/types' @@ -27,7 +27,7 @@ Object.defineProperty(globalThis, '__registerSegment', { export function onError(error: unknown) { const stack = getErrorStack(error) - callBridgeMethod('revenge.alertError', [ + callNativeMethod('revenge.alertError', [ stack, `${FullVersion} (${BuildEnvironment})`, ]) @@ -36,7 +36,7 @@ export function onError(error: unknown) { } declare module '@revenge-mod/modules/native' { - export interface Methods { + export interface NativeMethods { 'revenge.alertError': [[error: string, version: string], void] } } diff --git a/src/init.ts b/src/init.ts index a2ae104..51a3f51 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,6 +1,6 @@ // Initialize init libraries import '@revenge-mod/react/init' -import '@revenge-mod/storage/init' +import '@revenge-mod/json-storage/init' import { onRunApplication } from '@revenge-mod/react/native' import { onError } from '~index' diff --git a/src/plugins/preinit/api.json-storage/index.ts b/src/plugins/preinit/api.json-storage/index.ts new file mode 100644 index 0000000..e8c8782 --- /dev/null +++ b/src/plugins/preinit/api.json-storage/index.ts @@ -0,0 +1,81 @@ +import * as jsonStorage from '@revenge-mod/json-storage' +import { getJsonStorage } from '@revenge-mod/json-storage' +import { exists, rm } from '@revenge-mod/modules/native/fs' +import { + InternalPluginFlags, + PluginFlags, + registerPlugin, +} from '@revenge-mod/plugins/_' +import { defineLazyProperty } from '@revenge-mod/utils/object' +import type { JsonStorage, JsonStorageOptions } from '@revenge-mod/json-storage' +import type { InitPluginApi, Plugin } from '@revenge-mod/plugins/types' +import type { AnyObject } from '@revenge-mod/utils/types' + +type PluginJsonStorageOptions = JsonStorageOptions & { + file?: string +} + +const storageOptions = new WeakMap, PluginJsonStorageOptions>() + +registerPlugin( + { + id: 'revenge.api.json-storage', + name: 'JSON Storage API', + description: '@revenge-mod/json-storage API for plugins.', + author: 'Revenge', + icon: 'PollsIcon', + }, + { + preInit({ decorate, unscoped }) { + unscoped.jsonStorage = jsonStorage + + decorate((plugin, { jsonStorage }) => { + if (jsonStorage) storageOptions.set(plugin, jsonStorage) + }) + }, + init({ decorate }) { + const makePluginStorage = ( + plugin: Plugin, + opts?: PluginJsonStorageOptions, + ) => + getJsonStorage( + jsonStorage.pluginStoragePathFor( + plugin.manifest.id, + opts?.file, + ), + opts, + ) + + decorate(plugin => { + const opts = storageOptions.get(plugin) + + if (opts?.load) + plugin.api.jsonStorage = makePluginStorage(plugin, opts) + else + defineLazyProperty(plugin.api, 'jsonStorage', () => + makePluginStorage(plugin, opts), + ) + }) + }, + }, + PluginFlags.Enabled, + // biome-ignore format: Don't format this + InternalPluginFlags.Internal | + InternalPluginFlags.Essential | + InternalPluginFlags.API, +) + +export async function deleteJsonStorageForPlugin(plugin: Plugin) { + const opts = storageOptions.get(plugin)! + + const path = jsonStorage.pluginStoragePathFor(plugin.manifest.id, opts.file) + + if (await exists(path)) await rm(path) + + const api = plugin.api as + | InitPluginApi<{ jsonStorage: AnyObject }> + | undefined + + const storage = api?.jsonStorage as JsonStorage | undefined + await storage?.get() +} diff --git a/src/plugins/preinit/api.storage/index.ts b/src/plugins/preinit/api.storage/index.ts deleted file mode 100644 index 48c09f8..0000000 --- a/src/plugins/preinit/api.storage/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - InternalPluginFlags, - PluginFlags, - registerPlugin, -} from '@revenge-mod/plugins/_' -import { pluginStoragePathFor } 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 { 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, StorageOptions>() - -registerPlugin( - { - id: 'revenge.api.storage', - name: 'Storage API', - description: '@revenge-mod/storage API for plugins.', - author: 'Revenge', - icon: 'PollsIcon', - }, - { - preInit({ decorate, unscoped }) { - unscoped.storage = storage - - decorate((plugin, { storage }) => { - if (storage) storageOptions.set(plugin, storage) - }) - }, - init({ decorate }) { - const makePluginStorage = (plugin: Plugin, opts?: StorageOptions) => - getStorage(pluginStoragePathFor(plugin.manifest.id), { - ...opts, - directory: 'documents', - }) - - decorate(plugin => { - const opts = storageOptions.get(plugin) - - if (opts?.load) - plugin.api.storage = makePluginStorage(plugin, opts) - else - defineLazyProperty(plugin.api, 'storage', () => - makePluginStorage(plugin, opts), - ) - }) - }, - }, - PluginFlags.Enabled, - // biome-ignore format: Don't format this - InternalPluginFlags.Internal | - InternalPluginFlags.Essential | - InternalPluginFlags.API, -) - -export async function deleteStorageForPlugin(plugin: Plugin) { - const api = plugin.api as InitPluginApi<{ storage: AnyObject }> | undefined - const storage = - api?.storage ?? getStorage(pluginStoragePathFor(plugin.manifest.id)) - if (await storage.exists()) await storage.delete() -} diff --git a/src/plugins/start/developer-kit/definitions/DTAutoConnectSetting.tsx b/src/plugins/start/developer-kit/definitions/DTAutoConnectSetting.tsx index b8a0008..db922c8 100644 --- a/src/plugins/start/developer-kit/definitions/DTAutoConnectSetting.tsx +++ b/src/plugins/start/developer-kit/definitions/DTAutoConnectSetting.tsx @@ -9,10 +9,10 @@ const DTAutoConnectSetting: SettingsItem = { useTitle: () => 'Auto-connect to DevTools', useDescription: () => 'Automatically connect to DevTools during startup.', useValue: () => - api.storage.use(s => s.devTools?.autoConnect !== undefined)!.devTools + api.jsonStorage.use(s => s.devTools?.autoConnect !== undefined)!.devTools .autoConnect, onValueChange: v => { - api.storage.set({ devTools: { autoConnect: v } }) + api.jsonStorage.set({ devTools: { autoConnect: v } }) }, type: 'toggle', } diff --git a/src/plugins/start/developer-kit/definitions/RDTAutoConnectSetting.tsx b/src/plugins/start/developer-kit/definitions/RDTAutoConnectSetting.tsx index a785d7b..3c49412 100644 --- a/src/plugins/start/developer-kit/definitions/RDTAutoConnectSetting.tsx +++ b/src/plugins/start/developer-kit/definitions/RDTAutoConnectSetting.tsx @@ -12,10 +12,10 @@ const RDTAutoConnectSetting: SettingsItem = { 'Automatically connect to React DevTools during startup.', usePredicate: () => RDTContext.active, useValue: () => - api.storage.use(s => s.reactDevTools?.autoConnect !== undefined)! + api.jsonStorage.use(s => s.reactDevTools?.autoConnect !== undefined)! .reactDevTools.autoConnect, onValueChange: v => { - api.storage.set({ reactDevTools: { autoConnect: v } }) + api.jsonStorage.set({ reactDevTools: { autoConnect: v } }) }, type: 'toggle', } diff --git a/src/plugins/start/developer-kit/index.ts b/src/plugins/start/developer-kit/index.ts index d6369e2..184b4dd 100644 --- a/src/plugins/start/developer-kit/index.ts +++ b/src/plugins/start/developer-kit/index.ts @@ -37,7 +37,7 @@ const defaultStorage: Storage = { } // TODO(PalmDevs): only register in development builds once updates can be made automatic -registerPlugin<{ storage: Storage }>( +registerPlugin<{ jsonStorage: Storage }>( { id: 'revenge.developer-kit', name: 'Developer Kit', @@ -47,7 +47,7 @@ registerPlugin<{ storage: Storage }>( dependencies: [pluginSettings], }, { - storage: { + jsonStorage: { load: true, default: defaultStorage, }, @@ -61,7 +61,7 @@ registerPlugin<{ storage: Storage }>( refreshSettingsNavigator() } - const settings = await api.storage.get() + const settings = await api.jsonStorage.get() dt.DTContext.addr = settings.devTools.address dt.DTContext.alias = settings.devTools.alias ?? '' @@ -79,4 +79,4 @@ registerPlugin<{ storage: Storage }>( ) // Expose to EvalJSSetting -export let api: PluginApi<{ storage: Storage }> +export let api: PluginApi<{ jsonStorage: Storage }> diff --git a/src/plugins/start/developer-kit/screens/RevengeDeveloperSettingScreen.tsx b/src/plugins/start/developer-kit/screens/RevengeDeveloperSettingScreen.tsx index cfbaaf7..00bc67e 100644 --- a/src/plugins/start/developer-kit/screens/RevengeDeveloperSettingScreen.tsx +++ b/src/plugins/start/developer-kit/screens/RevengeDeveloperSettingScreen.tsx @@ -85,7 +85,7 @@ export default function RevengeDeveloperSettingScreen() { export function DTConnectionSetting() { const open = useIsDTConnected() - const settings = api.storage.use( + const settings = api.jsonStorage.use( s => s.devTools?.address !== undefined || s.devTools?.alias !== undefined, @@ -100,7 +100,7 @@ export function DTConnectionSetting() { label="DevTools" leadingText="ws://" onBlur={() => - api.storage + api.jsonStorage .set({ devTools: { address: DTContext.addr, @@ -125,7 +125,7 @@ export function DTConnectionSetting() { isDisabled={open} label="DevTools Alias" onBlur={() => - api.storage + api.jsonStorage .set({ devTools: { alias: DTContext.alias, @@ -150,7 +150,7 @@ export function DTConnectionSetting() { export function RDTAddrSetting() { const open = useIsRDTConnected() - const settings = api.storage.use(s => s.reactDevTools?.address) + const settings = api.jsonStorage.use(s => s.reactDevTools?.address) return ( - api.storage + api.jsonStorage .set({ reactDevTools: { address: RDTContext.addr, diff --git a/src/plugins/start/settings/components/PluginCard.tsx b/src/plugins/start/settings/components/PluginCard.tsx index 4f56575..9b55a0c 100644 --- a/src/plugins/start/settings/components/PluginCard.tsx +++ b/src/plugins/start/settings/components/PluginCard.tsx @@ -183,13 +183,13 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({ /> {plugin.SettingsComponent && ( { - requestAnimationFrame(() => { - enableTooltip.targetRef.current = - settingsRef.current - enableTooltip.setVisible(true) - }) + if (!startable) + requestAnimationFrame(() => { + enableTooltip.targetRef.current = + settingsRef.current + enableTooltip.setVisible(true) + }) }} > )} { - requestAnimationFrame(() => { - essentialTooltip.targetRef.current = - switchRef.current - essentialTooltip.setVisible(true) - }) + if (toggleDisabled) + requestAnimationFrame(() => { + essentialTooltip.targetRef.current = + switchRef.current + essentialTooltip.setVisible(true) + }) }} ref={switchRef} > diff --git a/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx b/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx index 3957a79..c0b7217 100644 --- a/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx +++ b/src/plugins/start/settings/components/PluginOptionsActionSheet.tsx @@ -58,9 +58,8 @@ export default function PluginOptionsActionSheet({ const pendingUpdate = isPluginPendingUpdate(plugin) const { manifest: { id, name, author, description, icon }, - errors, - flags, } = plugin + const errors = [...plugin.errors, ...meta.nativeErrors] return ( @@ -136,7 +135,7 @@ export default function PluginOptionsActionSheet({ } label="Flags" - subLabel={bitFieldToString(PluginFlags, flags)} + subLabel={bitFieldToString(PluginFlags, meta.flags)} /> {meta.iflags && ( { - requestAnimationFrame(() => { - enableTooltip.targetRef.current = - settingsRef.current - enableTooltip.setVisible(true) - }) + if (!startable) + requestAnimationFrame(() => { + enableTooltip.targetRef.current = + settingsRef.current + enableTooltip.setVisible(true) + }) }} > { function showPendingReloadAlertIfNeeded() { const plugins = [...pList.values()].filter( plugin => - plugin.flags & - (PluginFlags.PendingReload | PluginFlags.PendingUpdate), + isPluginPendingReload(plugin) || isPluginPendingUpdate(plugin), ) if (!plugins.length) return @@ -93,8 +98,8 @@ function showPendingReloadAlertIfNeeded() { } function showErrorAlertIfNeeded() { - const plugins = [...pList.values()].filter( - plugin => plugin.flags & PluginFlags.Errored, + const plugins = [...pList.values()].filter(plugin => + isPluginErrored(plugin), ) if (!plugins.length) return @@ -107,12 +112,12 @@ function showErrorAlertIfNeeded() { } pEmitter.on('flagUpdate', plugin => { - if (plugin.flags & (PluginFlags.PendingReload | PluginFlags.PendingUpdate)) + if (isPluginPendingReload(plugin) || isPluginPendingUpdate(plugin)) showPendingReloadAlertIfNeeded() }) pEmitter.on('stopped', plugin => { - if (plugin.flags & PluginFlags.Errored) showErrorAlertIfNeeded() + if (isPluginErrored(plugin)) showErrorAlertIfNeeded() }) /// PLUGIN INSTALL FEEDBACK diff --git a/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx b/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx index 4e4629b..a6788d3 100644 --- a/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx +++ b/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx @@ -5,14 +5,13 @@ 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 { callNativeMethod } from '@revenge-mod/modules/native' import { getInternalPluginMeta, InternalPluginFlags, isPluginEnabled, isPluginEssential, isPluginInternal, - PluginFlags, pEmitter, pList, } from '@revenge-mod/plugins/_' @@ -103,7 +102,11 @@ const Sorts = { 'Enabled first': [ getAssetIdByName('CircleCheckIcon')!, (a, b) => - (b.flags & PluginFlags.Enabled) - (a.flags & PluginFlags.Enabled), + isPluginEnabled(a) === isPluginEnabled(b) + ? a.manifest.name.localeCompare(b.manifest.name) + : isPluginEnabled(a) + ? -1 + : 1, ], } satisfies FilterAndSortActionSheetProps['sorts'] @@ -116,10 +119,7 @@ function HeaderContextMenu() { { label: 'Install from file', action: () => - callBridgeMethod( - 'revenge.plugins.loader.installPlugin', - [], - ), + callNativeMethod('revenge.plugins.install', []), }, ]} > @@ -136,7 +136,7 @@ function HeaderContextMenu() { function snapshotPlugins() { return [...pList.values()].map( - plugin => [plugin, getInternalPluginMeta(plugin)!] as const, + plugin => [plugin, getInternalPluginMeta(plugin)] as const, ) } diff --git a/src/plugins/start/settings/utils/actions.tsx b/src/plugins/start/settings/utils/actions.tsx index 1ae26ea..84d6257 100644 --- a/src/plugins/start/settings/utils/actions.tsx +++ b/src/plugins/start/settings/utils/actions.tsx @@ -27,7 +27,7 @@ export async function handleEnablePlugin(plugin: AnyPlugin) { } export async function handleDisablePlugin(plugin: AnyPlugin) { - const { dependents } = getInternalPluginMeta(plugin)! + const { dependents } = getInternalPluginMeta(plugin) const action = () => disablePlugin(plugin) const enabledDeps = dependents.filter(isPluginEnabled) diff --git a/src/plugins/start/settings/utils/alerts.tsx b/src/plugins/start/settings/utils/alerts.tsx index 10a8b83..9b0206c 100644 --- a/src/plugins/start/settings/utils/alerts.tsx +++ b/src/plugins/start/settings/utils/alerts.tsx @@ -1,10 +1,11 @@ import { AlertActionCreators } from '@revenge-mod/discord/actions' import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2' import { + deleteStorageForPlugin, isPluginStartable, uninstallExternalPlugin, } from '@revenge-mod/plugins/_' -import { deleteStorageForPlugin } from '~plugins/preinit/api.storage' +import { deleteJsonStorageForPlugin } from '~plugins/preinit/api.json-storage' import PluginClearDataConfirmationAlert from '../components/PluginClearDataConfirmationAlert' import PluginHasDependenciesAlert from '../components/PluginHasDependenciesAlert' import PluginHasDependentsAlert from '../components/PluginHasDependentsAlert' @@ -20,6 +21,8 @@ export function showPluginClearDataConfirmation( async function action() { await deleteStorageForPlugin(plugin) + // Trigger update for JSON storage + await deleteJsonStorageForPlugin(plugin) callback() } @@ -36,7 +39,7 @@ export function showPluginUninstallConfirmation( const KEY = 'plugin-uninstall-confirmation' async function action() { - await deleteStorageForPlugin(plugin) + // Uninstalling also deletes data await uninstallExternalPlugin(plugin) callback() } From eb3f03fb60f7665be4799a9ef514fd9f316a3f85 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Fri, 17 Jul 2026 21:10:29 +0700 Subject: [PATCH 08/23] feat(lib/discord): add types for `FloatingActionButton` --- lib/discord/src/design.ts | 1 + lib/discord/src/types/index.ts | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/lib/discord/src/design.ts b/lib/discord/src/design.ts index a38df9e..e2c5624 100644 --- a/lib/discord/src/design.ts +++ b/lib/discord/src/design.ts @@ -87,6 +87,7 @@ export interface Design { Card: DiscordModules.Components.Card ContextMenu: DiscordModules.Components.ContextMenu ContextMenuItem: DiscordModules.Components.ContextMenuItem + FloatingActionButton: DiscordModules.Components.FloatingActionButton IconButton: DiscordModules.Components.IconButton ImageButton: DiscordModules.Components.ImageButton LayerScope: DiscordModules.Components.LayerScope diff --git a/lib/discord/src/types/index.ts b/lib/discord/src/types/index.ts index 089c566..91bdad1 100644 --- a/lib/discord/src/types/index.ts +++ b/lib/discord/src/types/index.ts @@ -306,6 +306,15 @@ export namespace DiscordModules { export type ImageButton = FC + export interface FloatingActionButtonProps { + icon: number + onPress: () => void + positionBottom?: number + accessibilityLabel?: string + } + + export type FloatingActionButton = FC + export interface StackProps extends ViewProps { spacing?: number align?: ViewStyle['alignItems'] From f97c273befa4ea72c8b8e54ed2295c809f1f1451 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Sun, 19 Jul 2026 15:36:22 +0700 Subject: [PATCH 09/23] feat(lib/discord/modules/settings): allow adding setting items to sections with index and by lambda --- lib/discord/src/modules/settings/index.ts | 50 +++++++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/lib/discord/src/modules/settings/index.ts b/lib/discord/src/modules/settings/index.ts index 365cad1..e1291c0 100644 --- a/lib/discord/src/modules/settings/index.ts +++ b/lib/discord/src/modules/settings/index.ts @@ -84,15 +84,57 @@ export function registerSettingsItems(record: Record) { * * @param key The section to add the settings item to. * @param item The settings item to add. + * @param index The index to insert the settings item at. Defaults to -1 (end of the section). * @returns A function to remove the settings item from the section. */ -export function addSettingsItemToSection(key: string, item: string) { +export function addSettingsItemToSection( + key: string, + item: string, + index?: number, +): () => void +/** + * Adds a settings item to an existing section. + * + * @param key The section to add the settings item to. + * @param fn A function that takes the current settings items and returns the new settings items. Removing items will result in an error. + * @returns A function to remove the settings items from the section. + */ +export function addSettingsItemToSection( + key: string, + fn: (items: string[]) => string[], +): () => void +export function addSettingsItemToSection( + key: string, + item: string | ((items: string[]) => string[]), + index = -1, +) { const section = sSections[key] if (!section) throw new Error(`Section "${key}" does not exist`) - const newLength = section.settings.push(item) - return () => { - delete section.settings[newLength - 1] + if (typeof item === 'function') { + const before = [...section.settings] + const after = [...item(before)] + const added = after.filter(i => !before.includes(i)) + const removed = before.filter(i => !after.includes(i)) + if (removed.length > 0) + throw new Error( + `Cannot remove settings items from section "${key}"`, + ) + + section.settings = after + + return () => { + section.settings = section.settings.filter(i => !added.includes(i)) + } + } else { + const actualIndex = + index < 0 ? section.settings.length + (index + 1) : index + section.settings.splice(actualIndex, 0, item) + + return () => { + const idx = section.settings.indexOf(item) + if (idx !== -1) section.settings.splice(idx, 1) + } } } From fb35d7af9522641756414ecb9863b345975f7f1d Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Sun, 19 Jul 2026 20:19:19 +0700 Subject: [PATCH 10/23] fix(plugins/api.json-storage): remove entire dir when clearing and only update storage when needed --- src/plugins/preinit/api.json-storage/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/plugins/preinit/api.json-storage/index.ts b/src/plugins/preinit/api.json-storage/index.ts index e8c8782..62d5804 100644 --- a/src/plugins/preinit/api.json-storage/index.ts +++ b/src/plugins/preinit/api.json-storage/index.ts @@ -6,6 +6,7 @@ import { PluginFlags, registerPlugin, } from '@revenge-mod/plugins/_' +import { pluginStorageDirFor } from '@revenge-mod/plugins/constants' import { defineLazyProperty } from '@revenge-mod/utils/object' import type { JsonStorage, JsonStorageOptions } from '@revenge-mod/json-storage' import type { InitPluginApi, Plugin } from '@revenge-mod/plugins/types' @@ -66,16 +67,16 @@ registerPlugin( ) export async function deleteJsonStorageForPlugin(plugin: Plugin) { - const opts = storageOptions.get(plugin)! - - const path = jsonStorage.pluginStoragePathFor(plugin.manifest.id, opts.file) - + const path = pluginStorageDirFor(plugin.manifest.id) if (await exists(path)) await rm(path) const api = plugin.api as | InitPluginApi<{ jsonStorage: AnyObject }> | undefined - const storage = api?.jsonStorage as JsonStorage | undefined + // Only update already initialized storages + const storage = Object.getOwnPropertyDescriptor(api, 'jsonStorage') + ?.value as JsonStorage | undefined + await storage?.get() } From 9b412a9404940549c176dd04b1445d36cf283f44 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Sat, 25 Jul 2026 23:35:23 +0700 Subject: [PATCH 11/23] feat!: new repo system + versioning system support + bug fixes BREAKING CHANGES: - New versioning system: Allowing any amount of number segments, with prerelease support. - Any number of segments is supported: 1, 1.0, 1.0.0, 1.0.0.0, ... - Prerelease labels are compared "digit-run", similar to Debian. - Plugins must specify a version. - User-facing plugins should use CalVer. API plugins should use SemVer. Revenge APIs use SemVer while the new `discord` dependency parses the Discord version string directly. - Dependency is now a Map, with support for version ranges and optional. Suggested URL is removed. (See new repo system) - Plugins will now have to declare a dependency on `revenge.api` (Revenge API version) and `discord` (Discord version). - Manifest now requires a format version (currently `1`) New APIs: - lib/plugins/utils: Utilities to format versioning Features: - New repo system: Repository design is inspired by Linux package repositories. - Repos host manifest, versions, channels, download URL/size, and hash for multiple plugins. - New dependency system: Plugins are versioned, and shouldn't break when updates come. Plugins can now declare dependency on supported Discord/Revenge API versions, so they won't break with auto-updates. Dependencies can be optional, but dependency on `revenge.api` and `discord` must be required. Dependents are soft-disabled when dependencies don't satisfy. Once satisfied, the plugin can start again. - All updates for running plugins REQUIRE a reload to prevent stale linking issues. Both native and JS-side. - Update checking is handled entirely by JS. But the native-side still handles installation, indexing, and verification. - Local indexes are updated against remote indexes. Dependency checking always uses local indexes. - Users can add custom repositories and custom their priority order. URL is the identity. - Plugins can now have custom data URI icons instead of being restricted to React Native app assets. - External plugin installs require UI confirmation before installing. - Pending Update and Pending Reload filters in Plugins page. - Ability to browse external plugins in repositories. Official repository will be added soon. Fixes/Refactor: - Refactored Plugin-related settings to a separate "Plugin Settings" plugin. - Masonry layout fixes in landscape. - Updated plugins now show "reload to apply" toast instead. - Removed dead code. --- lib/plugins/package.json | 6 + lib/plugins/src/_internal/dependency-graph.ts | 41 +- lib/plugins/src/_internal/external-plugins.ts | 193 ++++++--- lib/plugins/src/_internal/index.ts | 338 +++++++++++++--- lib/plugins/src/_internal/repositories.ts | 296 ++++++++++++++ lib/plugins/src/apis/plugins.ts | 3 + lib/plugins/src/constants.ts | 2 +- lib/plugins/src/init.ts | 4 +- lib/plugins/src/preinit.ts | 4 +- lib/plugins/src/start.ts | 4 +- lib/plugins/src/types.ts | 56 ++- lib/plugins/src/utils.ts | 7 + src/plugins/preinit/api.discord/index.ts | 4 +- src/plugins/preinit/api.json-storage/index.ts | 4 +- src/plugins/preinit/api.utils/index.ts | 4 +- src/plugins/preinit/logging/index.ts | 4 +- src/plugins/preinit/no-track/index.ts | 4 +- src/plugins/start/developer-kit/index.ts | 6 +- src/plugins/start/developer-kit/utils.ts | 2 +- src/plugins/start/error-boundary/index.tsx | 4 +- .../BrowseFilterAndSortActionSheet.tsx | 109 ++++++ .../components/BrowsePluginActionSheet.tsx | 68 ++++ .../components/FilterAndSortActionSheet.tsx | 0 .../components/NavigatorHeaderWithIcon.tsx | 0 .../components/PluginCard.tsx | 124 ++++-- .../PluginClearDataConfirmationAlert.tsx | 0 .../components/PluginHasDependenciesAlert.tsx | 14 +- .../components/PluginHasDependentsAlert.tsx | 14 +- .../components/PluginIcon.tsx | 39 ++ .../components/PluginInstallConfirmAlert.tsx | 49 +++ .../components/PluginInstallFailedAlert.tsx | 35 ++ .../components/PluginList.tsx | 165 ++++++++ .../PluginMissingDependenciesAlert.tsx | 52 +++ .../components/PluginOptionsActionSheet.tsx | 260 +++++++++---- .../components/PluginStateProvider.tsx | 5 + .../PluginUninstallConfirmationAlert.tsx | 0 .../components/PluginsFailedToStartAlert.tsx | 0 .../components/PluginsRequireReloadAlert.tsx | 0 .../components/TooltipProvider.tsx | 0 .../start/settings.plugins/constants.ts | 11 + .../RevengePluginsAdvancedSetting.tsx | 17 + .../RevengePluginsBrowseSetting.tsx | 17 + .../definitions/RevengePluginsSetting.tsx | 0 src/plugins/start/settings.plugins/index.ts | 104 +++++ .../plugins.tsx | 21 +- .../start/settings.plugins/register.tsx | 17 + .../RevengePluginsAdvancedSettingScreen.tsx | 368 ++++++++++++++++++ .../RevengePluginsBrowseSettingScreen.tsx | 229 +++++++++++ .../screens/RevengePluginsSettingScreen.tsx | 67 ++-- src/plugins/start/settings.plugins/types.ts | 16 + .../start/settings.plugins/utils/actions.tsx | 87 +++++ .../utils/alerts.tsx | 27 +- .../start/settings.plugins/utils/repos.tsx | 159 ++++++++ .../utils/sheets.tsx | 13 + .../components/PluginInstallFailedAlert.tsx | 17 - .../start/settings/components/PluginList.tsx | 89 ----- src/plugins/start/settings/constants.ts | 2 - src/plugins/start/settings/index.ts | 7 +- src/plugins/start/settings/register.tsx | 4 +- src/plugins/start/settings/types.ts | 9 +- src/plugins/start/settings/utils/actions.tsx | 38 -- src/plugins/start/staff-settings/index.ts | 4 +- src/plugins/start/user-badges/index.tsx | 4 +- 63 files changed, 2746 insertions(+), 501 deletions(-) create mode 100644 lib/plugins/src/_internal/repositories.ts create mode 100644 lib/plugins/src/utils.ts create mode 100644 src/plugins/start/settings.plugins/components/BrowseFilterAndSortActionSheet.tsx create mode 100644 src/plugins/start/settings.plugins/components/BrowsePluginActionSheet.tsx rename src/plugins/start/{settings => settings.plugins}/components/FilterAndSortActionSheet.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/NavigatorHeaderWithIcon.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/PluginCard.tsx (72%) rename src/plugins/start/{settings => settings.plugins}/components/PluginClearDataConfirmationAlert.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/PluginHasDependenciesAlert.tsx (83%) rename src/plugins/start/{settings => settings.plugins}/components/PluginHasDependentsAlert.tsx (83%) create mode 100644 src/plugins/start/settings.plugins/components/PluginIcon.tsx create mode 100644 src/plugins/start/settings.plugins/components/PluginInstallConfirmAlert.tsx create mode 100644 src/plugins/start/settings.plugins/components/PluginInstallFailedAlert.tsx create mode 100644 src/plugins/start/settings.plugins/components/PluginList.tsx create mode 100644 src/plugins/start/settings.plugins/components/PluginMissingDependenciesAlert.tsx rename src/plugins/start/{settings => settings.plugins}/components/PluginOptionsActionSheet.tsx (51%) rename src/plugins/start/{settings => settings.plugins}/components/PluginStateProvider.tsx (93%) rename src/plugins/start/{settings => settings.plugins}/components/PluginUninstallConfirmationAlert.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/PluginsFailedToStartAlert.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/PluginsRequireReloadAlert.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/TooltipProvider.tsx (100%) create mode 100644 src/plugins/start/settings.plugins/constants.ts create mode 100644 src/plugins/start/settings.plugins/definitions/RevengePluginsAdvancedSetting.tsx create mode 100644 src/plugins/start/settings.plugins/definitions/RevengePluginsBrowseSetting.tsx rename src/plugins/start/{settings => settings.plugins}/definitions/RevengePluginsSetting.tsx (100%) create mode 100644 src/plugins/start/settings.plugins/index.ts rename src/plugins/start/{settings => settings.plugins}/plugins.tsx (87%) create mode 100644 src/plugins/start/settings.plugins/register.tsx create mode 100644 src/plugins/start/settings.plugins/screens/RevengePluginsAdvancedSettingScreen.tsx create mode 100644 src/plugins/start/settings.plugins/screens/RevengePluginsBrowseSettingScreen.tsx rename src/plugins/start/{settings => settings.plugins}/screens/RevengePluginsSettingScreen.tsx (84%) create mode 100644 src/plugins/start/settings.plugins/types.ts create mode 100644 src/plugins/start/settings.plugins/utils/actions.tsx rename src/plugins/start/{settings => settings.plugins}/utils/alerts.tsx (79%) create mode 100644 src/plugins/start/settings.plugins/utils/repos.tsx rename src/plugins/start/{settings => settings.plugins}/utils/sheets.tsx (66%) delete mode 100644 src/plugins/start/settings/components/PluginInstallFailedAlert.tsx delete mode 100644 src/plugins/start/settings/components/PluginList.tsx delete mode 100644 src/plugins/start/settings/utils/actions.tsx diff --git a/lib/plugins/package.json b/lib/plugins/package.json index 33012c3..a880495 100644 --- a/lib/plugins/package.json +++ b/lib/plugins/package.json @@ -6,6 +6,9 @@ "./constants": { "default": "./src/constants.ts" }, + "./utils": { + "default": "./src/utils.ts" + }, "./preinit": { "default": "./src/preinit.ts" }, @@ -20,6 +23,9 @@ }, "./_": { "default": "./src/_internal/index.ts" + }, + "./_/repositories": { + "default": "./src/_internal/repositories.ts" } } } diff --git a/lib/plugins/src/_internal/dependency-graph.ts b/lib/plugins/src/_internal/dependency-graph.ts index 06cd828..f3499d1 100644 --- a/lib/plugins/src/_internal/dependency-graph.ts +++ b/lib/plugins/src/_internal/dependency-graph.ts @@ -1,4 +1,4 @@ -import { getInternalPluginMeta, getPluginDependencies } from '../_internal' +import { getPluginDependencies, isPluginStartable } from '../_internal' import { pApis } from './decorators' import type { AnyPlugin } from '../_internal' @@ -23,8 +23,27 @@ export const pListOrdered: AnyPlugin[] = [] // Pending plugins to be computed export const pPending = new Set() +// Reserved dependencies verified by native +export const ApiDependencyId = 'revenge.api' +export const DiscordDependencyId = 'discord' + +export function isReservedDependency(id: string) { + return id === ApiDependencyId || id === DiscordDependencyId +} + +/** + * Whether the plugin has start-order dependencies. Reserved dependencies don't count. + */ +function hasGraphableDependencies(plugin: AnyPlugin): boolean { + const deps = plugin.manifest.dependencies + if (!deps) return false + for (const id in deps) if (!isReservedDependency(id)) return true + return false +} + export function computePendingNodes() { - for (const plugin of pPending) resolvePluginGraph(plugin) + for (const plugin of pPending) + if (isPluginStartable(plugin)) resolvePluginGraph(plugin) const apis: AnyPlugin[] = [] @@ -44,8 +63,8 @@ export function computePendingNodes() { continue } - if (plugin.manifest.dependencies?.length) { - for (const dep of getPluginDependencies(plugin)) + if (hasGraphableDependencies(plugin)) { + for (const dep of getPluginDependencies(plugin, false)) if (!pLeafOrSingleNodes.has(dep)) stack.push(dep) stack.push(plugin) @@ -60,18 +79,12 @@ export function computePendingNodes() { } export function resolvePluginGraph(plugin: AnyPlugin) { - const { manifest } = plugin - - if (manifest.dependencies?.length) { + if (hasGraphableDependencies(plugin)) { // Optimisitically add to root nodes (if there are dependents, it will be removed later) pRootNodes.add(plugin) - for (const dep of getPluginDependencies(plugin)) { - const depMeta = getInternalPluginMeta(dep) - depMeta.dependents.push(plugin) - - // Not a root node if it has dependencies - if (dep.manifest.dependencies?.length) pRootNodes.delete(dep) - } + // Not a root node if it has dependencies + for (const dep of getPluginDependencies(plugin, false)) + if (hasGraphableDependencies(dep)) pRootNodes.delete(dep) } else pLeafOrSingleNodes.add(plugin) } diff --git a/lib/plugins/src/_internal/external-plugins.ts b/lib/plugins/src/_internal/external-plugins.ts index 660fb51..b9a34c7 100644 --- a/lib/plugins/src/_internal/external-plugins.ts +++ b/lib/plugins/src/_internal/external-plugins.ts @@ -11,16 +11,22 @@ import { handlePluginError, InternalPluginFlags, isPluginEnabled, - isPluginPendingReload, PluginFlags, pEmitter, pList, + registerInternalPlugin, registerPlugin, - runPluginLate, - stopPlugin, + toPluginError, } from '.' +import { pPending } from './dependency-graph' +import { registerRepositoryEvents } from './repositories' import type { PluginManifest, PluginOptionsFactory } from '../types' -import type { AnyPlugin } from '.' +import type { + AnyPlugin, + PluginError, + PluginInstallReadyEvent, + PluginSource, +} from '.' interface ExternalPlugin { manifest: PluginManifest @@ -28,68 +34,89 @@ interface ExternalPlugin { internal?: boolean essential?: boolean enabledByDefault?: boolean + api?: boolean + /** + * The plugin failed to load at native boot (session-skip). + * It is registered so the user sees it and the reasons, but it never runs this session. + * - Dependency failures keep the enabled flag (auto-recovers next boot once resolved). + * - Own-fault failures (bad code, bad manifest) arrive already disabled by native. + */ + failed?: boolean + /** Where the plugin came from. Missing or `repo: null` means sideloaded. */ + source?: PluginSource | null + unsatisfiedOptionalDependencies?: string[] /** Errors the native side already hit (eg. at boot before JS was up, after faulty update). */ - errors?: string[] + errors?: PluginError[] } type PluginInstallResult = | { error: false; plugin: ExternalPlugin } - | { error: string } + | { error: PluginError } export function registerExternalPlugins() { registerJSMethod( - 'revenge.plugins.stop', - async function stopHandler(id: string) { - const plugin = pList.get(id) - if (plugin?.status) await stopPlugin(plugin) - }, - ) - - registerJSMethod( - 'revenge.plugins.events.pluginInstalled', + 'revenge.plugins.events.pluginInstallResult', async function installedHandler(result: PluginInstallResult) { if (result.error !== false) { pEmitter.emit('install', { error: result.error }) return } + // Native only fires this for fresh installs of new IDs + // Updates go through `pluginUpdated` instead and apply at reload 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, - )! - - const meta = getInternalPluginMeta(registered) - meta.flags |= PluginFlags.PendingUpdate - } else await loadExternalPlugin(plugin) + registerExternalPlugin(plugin) } catch (e) { - pEmitter.emit('install', { error: getErrorStack(e) }) + pEmitter.emit('install', { error: toPluginError(e) }) return } pEmitter.emit('install', { error: false, manifest: plugin.manifest, - updated: existing !== undefined, + updated: false, + pending: false, }) }, ) + registerJSMethod( + 'revenge.plugins.events.pluginUpdated', + async function updatePendingHandler({ + id, + version, + }: { + id: string + version: string + }) { + // The new version is on disk only, the running plugin keeps its old code until reload + // PendingUpdate causes the reload alert to show up + const plugin = pList.get(id) + if (plugin) { + const meta = getInternalPluginMeta(plugin) + meta.flags |= PluginFlags.PendingUpdate + } + + pEmitter.emit('install', { + error: false, + pending: true, + id, + version, + }) + }, + ) + + registerJSMethod( + 'revenge.plugins.events.pluginInstallReady', + (event: PluginInstallReadyEvent) => { + pEmitter.emit('installReady', event) + }, + ) + + registerRepositoryEvents() + const externals = callNativeMethodSync('revenge.plugins.list', []) if (!externals) return @@ -107,31 +134,54 @@ export function registerExternalPlugins() { } export function registerExternalPlugin(external: ExternalPlugin) { - const { manifest, script, internal, essential, enabledByDefault, errors } = - external - - const dep = registerPlugin( + const { manifest, - createOptionsFactory(script), - essential || enabledByDefault ? PluginFlags.Enabled : 0, - (internal ? InternalPluginFlags.Internal : 0) | - (essential ? InternalPluginFlags.Essential : 0), - ) + script, + internal, + essential, + enabledByDefault, + api, + failed, + source, + unsatisfiedOptionalDependencies, + errors, + } = external - // Sync errors the native side already caught - if (errors?.length) { - const plugin = pList.get(dep.id)! - for (const error of errors) handlePluginError(error, plugin) + const id = internal + ? registerInternalPlugin( + manifest, + createOptionsFactory(script), + essential || enabledByDefault ? PluginFlags.Enabled : 0, + InternalPluginFlags.Internal | + (essential ? InternalPluginFlags.Essential : 0) | + (api ? InternalPluginFlags.API : 0), + ) + : registerPlugin( + manifest, + createOptionsFactory(script), + enabledByDefault ? PluginFlags.Enabled : 0, + ) + + const plugin = pList.get(id)! + const meta = getInternalPluginMeta(plugin) + + meta.source = source + if (unsatisfiedOptionalDependencies?.length) + meta.unsatisfiedOptionalDependencies = Object.freeze( + unsatisfiedOptionalDependencies, + ) + + /** @see {@link PluginFlags.Failed} */ + if (failed) { + meta.flags |= PluginFlags.Failed + pPending.delete(plugin) } - return dep -} + // Sync errors the native side already caught + if (errors?.length) + for (const error of errors) handlePluginError(error, plugin) -export async function loadExternalPlugin(external: ExternalPlugin) { - const { id } = registerExternalPlugin(external) - const plugin = pList.get(id) - - if (plugin && isPluginEnabled(plugin)) await runPluginLate(plugin) + return id } export async function uninstallExternalPlugin(plugin: AnyPlugin) { @@ -143,6 +193,27 @@ export async function uninstallExternalPlugin(plugin: AnyPlugin) { pEmitter.emit('unregister', plugin) } +export async function resyncPluginSources() { + const externals = await callNativeMethod('revenge.plugins.list', []) + if (!externals) return + + for (const external of externals) { + const plugin = pList.get(external.manifest.id) + if (!plugin) continue + + const meta = getInternalPluginMeta(plugin) + meta.source = external.source + pEmitter.emit('flagUpdate', plugin) + } +} + +export function confirmInstall( + token: string, + accepted: boolean, +): Promise<{ result: 'installed' | 'pending' | 'cancelled' }> { + return callNativeMethod('revenge.plugins.confirmInstall', [token, accepted]) +} + function createOptionsFactory(script?: string): PluginOptionsFactory { if (!script) return () => ({}) return () => new Function('revenge', `return ${script}`)(pUnscopedApi) @@ -151,7 +222,11 @@ function createOptionsFactory(script?: string): PluginOptionsFactory { declare module '@revenge-mod/modules/native' { interface NativeMethods { 'revenge.plugins.list': [[], ExternalPlugin[] | null] - 'revenge.plugins.install': [[], null] + 'revenge.plugins.installFile': [[], null] 'revenge.plugins.uninstall': [[string], null] + 'revenge.plugins.confirmInstall': [ + [token: string, accepted: boolean], + { result: 'installed' | 'pending' | 'cancelled' }, + ] } } diff --git a/lib/plugins/src/_internal/index.ts b/lib/plugins/src/_internal/index.ts index e0f43c7..de36a7c 100644 --- a/lib/plugins/src/_internal/index.ts +++ b/lib/plugins/src/_internal/index.ts @@ -18,7 +18,9 @@ import { pDecoratorsStart, } from './decorators' import { + ApiDependencyId, computePendingNodes, + DiscordDependencyId, pLeafOrSingleNodes, pListOrdered, pPending, @@ -29,28 +31,45 @@ import type { PluginApi, PluginApiExtensionsOptions, PluginCleanup, - PluginDependency, PluginManifest, PluginOptions, PluginOptionsFactory, + PluginVersion, PreInitPluginApi, } from '../types' export type AnyPlugin = Plugin +const InternalPluginVersion: PluginVersion = (() => { + const [segments, label] = __BUILD_VERSION__.split('-') + const nums = segments.split('.').map(Number) + if (!label) return { nums } + return { nums, label } +})() + 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, PendingReload: 1 << 1, EnabledLate: 1 << 2, /** - * 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. + * A newer version of the plugin is on disk, but the running version is still active. + * The new plugin version will be reloaded on next reload. + * + * This flag is JS-side. */ PendingUpdate: 1 << 3, + /** + * The plugin failed to load this session (aka. session-skip), but is registered so the user sees it and the reason. + * It can never run in this session, and it isn't disabled however. + * + * This is usually because some dependencies failed to start, but could be caused by other misc errors too, + * such as missing dependencies, bad code, etc. + * + * This flag is JS-side. + */ + Failed: 1 << 4, } const Flag = PluginFlags @@ -84,12 +103,22 @@ export interface InternalPluginMeta { cleanups: PluginCleanup[] iflags: number apiLevel: number - dependents: AnyPlugin[] - dependencies?: AnyPlugin[] + unsatisfiedOptionalDependencies: readonly string[] options: PluginOptions optionsFactory?: PluginOptionsFactory flags: number - nativeErrors: readonly string[] + nativeErrors: readonly PluginError[] + /** + * Where the plugin came from. `repo: null` or missing means sideloaded. + * Internal plugins never have one. + */ + source?: PluginSource | null +} + +export interface PluginSource { + // url + repo: string | null + channel: string } export const pUnscopedApi = uapi @@ -105,11 +134,99 @@ export const pEmitter = new TypedEventEmitter<{ errored: [AnyPlugin, unknown] flagUpdate: [AnyPlugin] install: [PluginInstallEvent] + installReady: [PluginInstallReadyEvent] + allReposRefreshed: [] }>() +/** + * A sideloaded plugin was staged and validated. + * The user can confirm (or declines) through `confirmInstall(token, accepted)`. + */ +export interface PluginInstallReadyEvent { + /** Single-use confirmation token. */ + token: string + manifest: { + id: string + name: string + description: string + author: string + version: string + icon?: string | null + } + /** The installed version this replaces, or null for a fresh install. */ + replaces: string | null +} + export type PluginInstallEvent = - | { error: false; manifest: PluginManifest; updated: boolean } - | { error: string } + | { + error: false + manifest: PluginManifest + updated: boolean + pending: false + } + | { + /** + * The plugin was applied on disk only. The running version, if any, is + * untouched and the new one loads at next reload. + */ + error: false + pending: true + id: string + version: string + } + | { error: PluginError } + +/// PLUGIN ERRORS + +export interface PluginError { + /** One of {@link PluginErrorCodes}, or something unknown. */ + code: string + message: string + stack?: string | null +} + +export const PluginErrorCodes = { + // Discovery / boot + ManifestInvalid: 'MANIFEST_INVALID', + DependencyMissing: 'DEPENDENCY_MISSING', + DependencyUnsatisfied: 'DEPENDENCY_UNSATISFIED', + DependencyFailed: 'DEPENDENCY_FAILED', + DependencyCycle: 'DEPENDENCY_CYCLE', + LoadFailed: 'LOAD_FAILED', + + /* The plugin's own code threw */ + PluginError: 'PLUGIN_ERROR', + + InstallInvalidZip: 'INSTALL_INVALID_ZIP', + InstallVerifyFailed: 'INSTALL_VERIFY_FAILED', + InstallMismatch: 'INSTALL_MISMATCH', + InstallFailed: 'INSTALL_FAILED', +} as const + +export function isPluginError(e: unknown): e is PluginError { + return ( + typeof e === 'object' && + e !== null && + typeof (e as PluginError).code === 'string' && + typeof (e as PluginError).message === 'string' + ) +} + +export function toPluginError(e: unknown): PluginError { + if (isPluginError(e)) return e + if (e instanceof Error) + return { + code: PluginErrorCodes.PluginError, + message: e.message, + stack: e.stack, + } + return { code: PluginErrorCodes.PluginError, message: String(e) } +} + +export function formatPluginError(e: unknown): string { + const err = toPluginError(e) + return `[${err.code}] ${err.message}${err.stack ? `\n${err.stack}` : ''}` +} export const pList = new Map() const pMetadata = new WeakMap() @@ -142,6 +259,8 @@ async function applyPluginFlags(id: PluginManifest['id'], flags: number) { if (!plugin) return const meta = getInternalPluginMeta(plugin) + // Repersist JS-side flags + flags |= meta.flags & (Flag.PendingUpdate | Flag.Failed) if (meta.flags === flags) return const wasEnabled = meta.flags & Flag.Enabled @@ -162,7 +281,7 @@ async function applyPluginFlags(id: PluginManifest['id'], flags: number) { registerJSMethod( 'revenge.plugins.events.pluginErrored', - (id: string, errors: string[]) => { + (id: string, errors: PluginError[]) => { const plugin = pList.get(id) if (!plugin) return @@ -181,15 +300,42 @@ registerJSMethod( * @param manifest The manifest of the plugin. * @param options The options for the plugin. * @param defflags The default flags for the plugin. - * @param iflags The internal flags for the plugin. */ export function registerPlugin( manifest: PluginManifest, options: PluginOptions | PluginOptionsFactory, defflags: number, +) { + return register(manifest, options, defflags, 0) +} + +export type InternalPluginManifest = Omit< + PluginManifest, + 'version' | 'format' | 'dependencies' +> & + Partial> + +export function registerInternalPlugin( + manifest: InternalPluginManifest, + options: PluginOptions | PluginOptionsFactory, + defflags: number, + iflags = 0, +) { + manifest.version ??= InternalPluginVersion + manifest.format ??= 1 + manifest.dependencies ??= {} + manifest.dependencies[ApiDependencyId] ??= { version: '*' } + manifest.dependencies[DiscordDependencyId] ??= { version: '*' } + + return register(manifest as PluginManifest, options, defflags, iflags) +} + +function register( + manifest: PluginManifest, + options: PluginOptions | PluginOptionsFactory, + defflags: number, iflags: number, ) { - // TODO(plugins): verify plugin manifest if (pList.has(manifest.id)) throw new Error(`Plugin with ID "${manifest.id}" already registered`) @@ -226,7 +372,7 @@ export function registerPlugin( promises: [], iflags, apiLevel: PluginApiLevel.None, - dependents: [], + unsatisfiedOptionalDependencies: Object.freeze([]), handleError: e => handlePluginError(e, plugin), options: resolved ?? {}, optionsFactory: factory, @@ -252,41 +398,75 @@ export function registerPlugin( pEmitter.emit('register', plugin, meta.options) - return { id: manifest.id } satisfies PluginDependency + return manifest.id } -/** - * Gets dependencies for a plugin. - */ -export function getPluginDependencies(plugin: AnyPlugin): AnyPlugin[] { - const meta = getInternalPluginMeta(plugin)! - if (meta.dependencies) return meta.dependencies - +export function getPluginDependencies( + plugin: AnyPlugin, + throwOnMissing = true, +): AnyPlugin[] { const { dependencies, id } = plugin.manifest const deps: AnyPlugin[] = [] + const { unsatisfiedOptionalDependencies } = getInternalPluginMeta(plugin) - if (dependencies?.length) - for (const { id: depId } of dependencies) { + if (dependencies) + for (const [depId, spec] of Object.entries(dependencies)) { const dep = pList.get(depId) if (dep) { - if (isPluginEnabled(dep)) deps.push(dep) - else - throw new Error( - `Plugin "${id}" depends on disabled plugin "${depId}"`, - ) - } else { - // TODO: Once external plugins are implemented, we will have to check the external plugin registry here as well - // External plugin registry should ideally be Record - // Then we register the plugin here and do dep = pList.get(id) again - - throw new Error( - `Plugin "${id}" depends on unregistered plugin "${depId}"`, + if ( + !spec.optional || + (isPluginEnabled(dep) && + !unsatisfiedOptionalDependencies.includes(depId)) ) + deps.push(dep) + } else if (!spec.optional) { + if (throwOnMissing) { + throw new Error( + `Plugin "${id}" depends on unregistered plugin "${depId}"`, + ) + } } } - return (meta.dependencies = deps) + return deps +} + +export function getMissingPluginDependencies(plugin: AnyPlugin): string[] { + const { dependencies } = plugin.manifest + if (!dependencies) return [] + + const missing: string[] = [] + for (const [depId, spec] of Object.entries(dependencies)) { + if (!spec.optional && !pList.has(depId)) missing.push(depId) + } + return missing +} + +export function getPluginDependents( + plugin: AnyPlugin, + includeLinkedOptionals = false, +): AnyPlugin[] { + const { id } = plugin.manifest + const dependents: AnyPlugin[] = [] + const enabled = isPluginEnabled(plugin) + + for (const p of pList.values()) { + const spec = p.manifest.dependencies?.[id] + if (!spec) continue + + if (!spec.optional) dependents.push(p) + else if ( + includeLinkedOptionals && + enabled && + !getInternalPluginMeta(p).unsatisfiedOptionalDependencies.includes( + id, + ) + ) + dependents.push(p) + } + + return dependents } export function isPluginEnabled(plugin: AnyPlugin): boolean { @@ -321,18 +501,28 @@ export function isPluginPendingUpdate(plugin: AnyPlugin): boolean { return Boolean(meta && meta.flags & Flag.PendingUpdate) } +/** @see {@link Flag.Failed} */ +export function isPluginFailed(plugin: AnyPlugin): boolean { + const meta = getInternalPluginMeta(plugin) + return Boolean(meta && meta.flags & Flag.Failed) +} + /** - * Requires that the plugin is in a state where it can be started, all of: - * - Enabled - * - Not pending an update + * Requires that the plugin can be started: enabled, not pending a reload, and not failed at discovery. + * PendingUpdate doesn't block, it only means a newer version sits on disk for next reload. */ function requirePluginStartableState(plugin: AnyPlugin) { if (!isPluginEnabled(plugin)) throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`) - if (isPluginPendingUpdate(plugin)) + if (isPluginPendingReload(plugin)) throw new Error( - `Plugin "${plugin.manifest.id}" is pending an update and cannot be started`, + `Plugin "${plugin.manifest.id}" requires a reload before it can be started again`, + ) + + if (isPluginFailed(plugin)) + throw new Error( + `Plugin "${plugin.manifest.id}" failed to load this session (reload to retry)`, ) } @@ -357,9 +547,12 @@ export async function handlePluginError(e: unknown, plugin: AnyPlugin) { ;(plugin.errors as unknown[]).push(e) // TODO: Emit errored event so UI can update? + // Update: errored event removed (but status changes are still emitted), so UI should update fine nativeLoggingHook( - `\u001b[31mPlugin "${plugin.manifest.id}" encountered an error: ${getErrorStack(e)}\u001b[0m`, + `\u001b[31mPlugin "${plugin.manifest.id}" encountered an error: ${ + isPluginError(e) ? formatPluginError(e) : getErrorStack(e) + }\u001b[0m`, 2, ) @@ -367,6 +560,8 @@ export async function handlePluginError(e: unknown, plugin: AnyPlugin) { if ( !isPluginEssential(getInternalPluginMeta(plugin)) && + // Failed plugins keep their persisted enabled state untouched, recovering on a later boot + !isPluginFailed(plugin) && // Multiple errors may surface, but we only want to disable the plugin once isPluginEnabled(plugin) ) @@ -461,7 +656,9 @@ function tryPreparePluginStart(plugin: AnyPlugin) { * Disables a plugin, as well as all its dependents. */ export async function disablePlugin(plugin: AnyPlugin) { - requirePluginStartableState(plugin) + // PendingReload/PendingUpdate never block disabling + if (!isPluginEnabled(plugin)) + throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`) const meta = getInternalPluginMeta(plugin) @@ -470,10 +667,9 @@ export async function disablePlugin(plugin: AnyPlugin) { `Plugin "${plugin.manifest.id}" is essential and cannot be disabled`, ) - const { dependents } = meta - + // Disable dependents await Promise.all( - dependents.map(dep => { + getPluginDependents(plugin).map(dep => { if (getInternalPluginMeta(dep)!.flags & Flag.Enabled) return disablePlugin(dep) }), @@ -505,11 +701,24 @@ export async function enablePlugin(plugin: AnyPlugin) { }), ) - await callNativeMethod('revenge.plugins.setEnabled', [ + const result = await callNativeMethod('revenge.plugins.setEnabled', [ plugin.manifest.id, true, ]) + if (result?.code === 'DEPENDENCIES_UNSATISFIED') { + const details = result.problems + .map( + p => + `"${p.id}" (requires ${p.required}, installed: ${p.installed ?? 'none'}${p.installed && !p.enabled ? ', disabled' : ''})`, + ) + .join(', ') + + throw new Error( + `Cannot enable plugin "${plugin.manifest.id}": unsatisfied dependencies: ${details}`, + ) + } + const meta = getInternalPluginMeta(plugin) meta.flags |= Flag.Enabled @@ -686,7 +895,9 @@ export async function startPlugin(plugin: AnyPlugin) { * Stops a plugin by running its stop lifecycle and cleanup functions. */ export async function stopPlugin(plugin: AnyPlugin) { - requirePluginStartableState(plugin) + // PendingReload/PendingUpdate shouldn't block stopping + if (!isPluginEnabled(plugin)) + throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`) const { manifest: { id }, @@ -720,6 +931,15 @@ export async function stopPlugin(plugin: AnyPlugin) { ) throw new Error(`Plugin "${id}" is not running`) + // Stop dependents + await Promise.all( + getPluginDependents(plugin, true).map(dep => + dep.status && !(dep.status & Status.Stopping) + ? stopPlugin(dep) + : undefined, + ), + ) + plugin.status |= Status.Stopping try { @@ -786,7 +1006,11 @@ export async function deleteStorageForPlugin(plugin: Plugin) { if (await exists(dir)) await rm(dir) } -export { uninstallExternalPlugin } from './external-plugins' +export { + confirmInstall, + resyncPluginSources, + uninstallExternalPlugin, +} from './external-plugins' declare module '@revenge-mod/modules/native' { interface NativeMethods { @@ -794,11 +1018,23 @@ declare module '@revenge-mod/modules/native' { 'revenge.plugins.states.read': [[], PersistedPluginStates | null] 'revenge.plugins.setEnabled': [ [id: PluginManifest['id'], enabled: boolean], - null, + SetEnabledError | null, ] } } +type SetEnabledError = { + code: 'DEPENDENCIES_UNSATISFIED' + problems: Array<{ + id: PluginManifest['id'] + /** The declared range (`*` for any). */ + required: string + /** Installed version, or `null` when missing entirely. */ + installed: string | null + enabled: boolean + }> +} + interface PluginStateObject { enabled?: boolean pendingReload?: boolean diff --git a/lib/plugins/src/_internal/repositories.ts b/lib/plugins/src/_internal/repositories.ts new file mode 100644 index 0000000..869f487 --- /dev/null +++ b/lib/plugins/src/_internal/repositories.ts @@ -0,0 +1,296 @@ +import { TypedEventEmitter } from '@revenge-mod/discord/common/utils' +import { callNativeMethod, registerJSMethod } from '@revenge-mod/modules/native' +import { pEmitter } from '@revenge-mod/plugins/_' + +export interface DownloadProgressEvent { + id: string + version: string + /** The repository the artifact downloads from. */ + repo: string + /** Bytes received so far. */ + received: number + /** Total bytes, from the plan's size field. */ + total: number + /** 1-based position of this artifact in the plan. */ + index: number + /** Number of artifacts in the plan. */ + count: number +} + +export interface RepoStateEvent { + url: string + state: 'refreshing' | 'ready' | 'error' + error?: string +} + +export const repoEvents = new TypedEventEmitter<{ + downloadProgress: [DownloadProgressEvent] + repoState: [RepoStateEvent] +}>() + +export function registerRepositoryEvents() { + registerJSMethod( + 'revenge.plugins.events.downloadProgress', + (event: DownloadProgressEvent) => { + repoEvents.emit('downloadProgress', event) + }, + ) + + registerJSMethod( + 'revenge.plugins.events.repoStateUpdate', + (event: RepoStateEvent) => { + repoEvents.emit('repoState', event) + }, + ) +} + +/** + * A plugin repository as reported by native. + * + * The repository's URL is its identity. + * The hidden internal repository (serving internal plugins) is always first and cannot be modified or removed. + */ +export interface Repo { + /** Absolute URL of the repository root; also its identity. */ + url: string + enabled: boolean + internal: boolean + /** Display metadata from the cached index, if any. */ + name: string | null + description: string | null + /** A Discord-packaged asset name or a `data:` URL. Never a remote URL. */ + icon: string | null +} + +export interface RepoConfigEntry { + url: string + enabled?: boolean +} + +export function listRepos(): Promise { + return callNativeMethod('revenge.plugins.repos.list', []) +} + +export function setRepos(config: RepoConfigEntry[]): Promise { + return callNativeMethod('revenge.plugins.repos.set', [config]) +} + +export function refreshRepo(url: string): Promise { + return callNativeMethod('revenge.plugins.repos.refresh', [url]) +} + +/** + * Refreshes every enabled user repository in parallel. + * Per-repo failures are collected instead. + */ +export async function refreshAllRepos(): Promise<{ + refreshed: Repo[] + errors: { url: string; error: unknown }[] +}> { + const repos = await listRepos() + const refreshed: Repo[] = [] + const errors: { url: string; error: unknown }[] = [] + + await Promise.all( + repos + .filter(repo => !repo.internal && repo.enabled) + .map(repo => + refreshRepo(repo.url).then( + result => { + refreshed.push(result) + }, + error => { + errors.push({ url: repo.url, error }) + }, + ), + ), + ) + + pEmitter.emit('allReposRefreshed') + + return { refreshed, errors } +} + +export interface RepoPluginListing { + id: string + name: string + description: string + author: string + /** A Discord-packaged asset name or a `data:` URL. Never a remote URL. */ + icon: string | null + /** Channel pointers (eg. `latest`), each naming a key of {@link versions}. */ + channels: Record + versions: Record< + string, + { + /** Absolute artifact URL. `null` for the internal repository (nothing downloadable). */ + url: string | null + sha256: string | null + size: number + dependencies: Record< + string, + { + version: string + optional: boolean + } + > + } + > +} + +export interface InstallPlanAction { + id: string + version: string + url: string + sha256: string + size: number + /** The repository this action installs from (recorded as provenance). */ + repo: string + /** The channel followed for future update checks. */ + channel: string + /** The installed version being replaced, or `null` for a fresh install. */ + replaces: string | null +} + +export interface InstallPlan { + actions: InstallPlanAction[] + /** Non-blocking problems (skipped optionals, dependent-range conflicts). */ + warnings: string[] +} + +export interface RepoUpdate { + id: string + installed: string + available: string + channel: string +} + +/** + * Lists one repository's plugins from its cached index. + */ +export function listRepoPlugins(url: string): Promise { + return callNativeMethod('revenge.plugins.repos.listPlugins', [url]) +} + +/** + * Resolves an install of one plugin (+ unsatisfied dependencies) against cached indexes. + */ +export function planInstall( + id: string, + version?: string, + channel?: string, +): Promise { + return callNativeMethod('revenge.plugins.planInstall', [ + id, + version ?? null, + channel ?? null, + ]) +} + +/** + * Executes one confirmed install plan: download all, verify all, then apply on disk. + * + * - Fresh plugins (new IDs with live dependencies) load immediately (`installed`). + * - Updates, and fresh IDs depending on them, only land on disk and load at next reload (`pending`). + * - `skipped` lists actions an overlapping plan already satisfied. + */ +export function installFromRepo(plan: InstallPlan): Promise<{ + installed: string[] + pending: string[] + skipped: string[] +}> { + return callNativeMethod('revenge.plugins.install', [plan]) +} + +/** + * Lists available updates for plugins pinned to one repository, from its **cached index**. + * Call {@link refreshRepo} first to ensure the index is up-to-date. + */ +export function listUpdates(url: string): Promise { + return callNativeMethod('revenge.plugins.repos.listUpdates', [url]) +} + +/** + * Lists updates across every enabled user repository in parallel. + * Per-repo failures (eg. no cached index yet) are returned. + */ +export async function listAllUpdates(): Promise<{ + updates: RepoUpdate[] + errors: { url: string; error: unknown }[] +}> { + const repos = await listRepos() + const updates: RepoUpdate[] = [] + const errors: { url: string; error: unknown }[] = [] + + await Promise.all( + repos + .filter(repo => !repo.internal && repo.enabled) + .map(repo => + listUpdates(repo.url).then( + result => { + updates.push(...result) + }, + error => { + errors.push({ url: repo.url, error }) + }, + ), + ), + ) + + return { updates, errors } +} + +/** + * Resolves and installs updates for every entry of {@link listAllUpdates}. + * Failures are collected per plugin. Updates land on disk only (`pending`), a reload applies them. + */ +export async function updateAllPlugins(): Promise<{ + installed: string[] + pending: string[] + errors: { id: string; error: unknown }[] +}> { + const { updates } = await listAllUpdates() + const installed: string[] = [] + const pending: string[] = [] + const errors: { id: string; error: unknown }[] = [] + + await Promise.all( + updates.map(async update => { + try { + const plan = await planInstall( + update.id, + undefined, + update.channel, + ) + const result = await installFromRepo(plan) + installed.push(...result.installed) + pending.push(...result.pending) + } catch (error) { + errors.push({ id: update.id, error }) + } + }), + ) + + return { installed, pending, errors } +} + +declare module '@revenge-mod/modules/native' { + interface NativeMethods { + 'revenge.plugins.repos.list': [[], Repo[]] + 'revenge.plugins.repos.set': [[config: RepoConfigEntry[]], null] + 'revenge.plugins.repos.refresh': [[url: string], Repo] + 'revenge.plugins.repos.listPlugins': [ + [url: string], + RepoPluginListing[], + ] + 'revenge.plugins.repos.listUpdates': [[url: string], RepoUpdate[]] + 'revenge.plugins.planInstall': [ + [id: string, version: string | null, channel: string | null], + InstallPlan, + ] + 'revenge.plugins.install': [ + [plan: InstallPlan], + { installed: string[]; pending: string[]; skipped: string[] }, + ] + } +} diff --git a/lib/plugins/src/apis/plugins.ts b/lib/plugins/src/apis/plugins.ts index 78af575..4d9b5cb 100644 --- a/lib/plugins/src/apis/plugins.ts +++ b/lib/plugins/src/apis/plugins.ts @@ -1,9 +1,12 @@ import * as PluginsApiConstants from '@revenge-mod/plugins/constants' +import * as PluginsApiUtils from '@revenge-mod/plugins/utils' export interface PluginApiPlugins { + utils: typeof import('@revenge-mod/plugins/utils') constants: typeof import('@revenge-mod/plugins/constants') } export const plugins: PluginApiPlugins = { constants: PluginsApiConstants, + utils: PluginsApiUtils, } diff --git a/lib/plugins/src/constants.ts b/lib/plugins/src/constants.ts index 73794b1..665a6fe 100644 --- a/lib/plugins/src/constants.ts +++ b/lib/plugins/src/constants.ts @@ -21,5 +21,5 @@ const { storageRootPath } = callNativeMethodSync( /** * Per-plugin storage directory, relative to app data directory. */ -// TODO: is it best to share this with the native side, or should we let native side be the sole source of truth? +// TODO: is it best to dupe this logic with the native side??? export const pluginStorageDirFor = (id: string) => `${storageRootPath}/${id}` diff --git a/lib/plugins/src/init.ts b/lib/plugins/src/init.ts index 79d1c5b..5d49b7e 100644 --- a/lib/plugins/src/init.ts +++ b/lib/plugins/src/init.ts @@ -1,7 +1,7 @@ -import { initPlugin, isPluginEnabled } from './_internal' +import { initPlugin, isPluginStartable } from './_internal' import { computePendingNodes, pListOrdered } from './_internal/dependency-graph' computePendingNodes() for (const plugin of pListOrdered) - if (isPluginEnabled(plugin)) initPlugin(plugin) + if (isPluginStartable(plugin)) initPlugin(plugin) diff --git a/lib/plugins/src/preinit.ts b/lib/plugins/src/preinit.ts index 1756aef..48a6e0a 100644 --- a/lib/plugins/src/preinit.ts +++ b/lib/plugins/src/preinit.ts @@ -1,4 +1,4 @@ -import { isPluginEnabled, preInitPlugin } from './_internal' +import { isPluginStartable, preInitPlugin } from './_internal' import { computePendingNodes, pListOrdered } from './_internal/dependency-graph' import { registerExternalPlugins } from './_internal/external-plugins' @@ -6,4 +6,4 @@ registerExternalPlugins() computePendingNodes() for (const plugin of pListOrdered) - if (isPluginEnabled(plugin)) preInitPlugin(plugin) + if (isPluginStartable(plugin)) preInitPlugin(plugin) diff --git a/lib/plugins/src/start.ts b/lib/plugins/src/start.ts index 9ac8741..4adc68a 100644 --- a/lib/plugins/src/start.ts +++ b/lib/plugins/src/start.ts @@ -1,4 +1,4 @@ -import { isPluginEnabled, startPlugin } from './_internal' +import { isPluginStartable, startPlugin } from './_internal' import { computePendingNodes, pListOrdered } from './_internal/dependency-graph' computePendingNodes() @@ -10,4 +10,4 @@ if (__DEV__) ) for (const plugin of pListOrdered) - if (isPluginEnabled(plugin)) startPlugin(plugin) + if (isPluginStartable(plugin)) startPlugin(plugin) diff --git a/lib/plugins/src/types.ts b/lib/plugins/src/types.ts index b71757a..6417924 100644 --- a/lib/plugins/src/types.ts +++ b/lib/plugins/src/types.ts @@ -136,26 +136,14 @@ export interface PluginApi< plugin: Plugin } -// TODO(plugins): support plugin bundles -// export interface PluginBundle { -// /** -// * The unique identifier for the plugin bundle. -// */ -// id: string -// /** -// * The author of the plugin bundle. -// */ -// author: string -// /** -// * The URL of the plugin bundle. -// */ -// url: string -// } - /** * The plugin manifest. */ export interface PluginManifest { + /** + * The manifest format version. + */ + format: number /** * The unique identifier for the plugin. */ @@ -173,25 +161,36 @@ export interface PluginManifest { */ description: string /** - * The icon of the plugin. + * The icon of the plugin. An asset name, or a `data:` URL. */ icon?: string /** - * The dependencies of the plugin. + * The dependencies of the plugin, keyed by plugin ID. */ - dependencies?: PluginDependency[] + dependencies?: Record + /** + * The plugin's version. + */ + version: PluginVersion } +export interface PluginVersion { + nums: number[] + label?: string +} + +/** + * A dependency specification. The dependency's plugin ID is the key in {@link PluginManifest.dependencies}. + */ export interface PluginDependency { /** - * The ID of this dependency. + * Version range the dependency must satisfy. */ - id: string - // TODO(plugins): support plugin bundles - // /** - // * The bundle of this dependency. - // */ - // bundle: PluginBundle + version?: string + /** + * Whether this dependency can be optionally linked. + */ + optional?: boolean } export interface PluginOptions< @@ -249,11 +248,6 @@ export interface Plugin< S extends keyof PluginApiInLifecycleMap = keyof PluginApiInLifecycleMap, > { - // TODO(plugins): support plugin bundles - // /** - // * The plugin bundle this plugin belongs to. - // */ - // bundle: PluginBundle manifest: PluginManifest lifecycles: PluginLifecycles diff --git a/lib/plugins/src/utils.ts b/lib/plugins/src/utils.ts new file mode 100644 index 0000000..cace47c --- /dev/null +++ b/lib/plugins/src/utils.ts @@ -0,0 +1,7 @@ +import type { PluginVersion } from '@revenge-mod/plugins/types' + +/** + * Formats a plugin version for display. + */ +export const formatVersion = (version: PluginVersion) => + version.nums.join('.') + (version.label ? `-${version.label}` : '') diff --git a/src/plugins/preinit/api.discord/index.ts b/src/plugins/preinit/api.discord/index.ts index bc3331d..69d9c2c 100644 --- a/src/plugins/preinit/api.discord/index.ts +++ b/src/plugins/preinit/api.discord/index.ts @@ -1,11 +1,11 @@ import { InternalPluginFlags, PluginFlags, - registerPlugin, + registerInternalPlugin, } from '@revenge-mod/plugins/_' import { defineLazyProperty } from '@revenge-mod/utils/object' -registerPlugin( +registerInternalPlugin( { id: 'revenge.api.discord', name: 'Discord API', diff --git a/src/plugins/preinit/api.json-storage/index.ts b/src/plugins/preinit/api.json-storage/index.ts index 62d5804..5e9b522 100644 --- a/src/plugins/preinit/api.json-storage/index.ts +++ b/src/plugins/preinit/api.json-storage/index.ts @@ -4,7 +4,7 @@ import { exists, rm } from '@revenge-mod/modules/native/fs' import { InternalPluginFlags, PluginFlags, - registerPlugin, + registerInternalPlugin, } from '@revenge-mod/plugins/_' import { pluginStorageDirFor } from '@revenge-mod/plugins/constants' import { defineLazyProperty } from '@revenge-mod/utils/object' @@ -18,7 +18,7 @@ type PluginJsonStorageOptions = JsonStorageOptions & { const storageOptions = new WeakMap, PluginJsonStorageOptions>() -registerPlugin( +registerInternalPlugin( { id: 'revenge.api.json-storage', name: 'JSON Storage API', diff --git a/src/plugins/preinit/api.utils/index.ts b/src/plugins/preinit/api.utils/index.ts index ba95910..badf6c6 100644 --- a/src/plugins/preinit/api.utils/index.ts +++ b/src/plugins/preinit/api.utils/index.ts @@ -1,7 +1,7 @@ import { InternalPluginFlags, PluginFlags, - registerPlugin, + registerInternalPlugin, } from '@revenge-mod/plugins/_' import defer * as UtilsCallback from '@revenge-mod/utils/callback' import defer * as UtilsDiscord from '@revenge-mod/utils/discord' @@ -12,7 +12,7 @@ import defer * as UtilsProxy from '@revenge-mod/utils/proxy' import defer * as UtilsReact from '@revenge-mod/utils/react' import defer * as UtilsTree from '@revenge-mod/utils/tree' -registerPlugin( +registerInternalPlugin( { id: 'revenge.api.utils', name: 'Utils API', diff --git a/src/plugins/preinit/logging/index.ts b/src/plugins/preinit/logging/index.ts index a5e021a..59f6b03 100644 --- a/src/plugins/preinit/logging/index.ts +++ b/src/plugins/preinit/logging/index.ts @@ -2,7 +2,7 @@ import { AppStartPerformance } from '@revenge-mod/discord/preinit' import { InternalPluginFlags, PluginFlags, - registerPlugin, + registerInternalPlugin, } from '@revenge-mod/plugins/_' import { BuildEnvironment, FullVersion } from '~/constants' @@ -13,7 +13,7 @@ if (__DEV__) { let tsPreInit: number let tsInit: number - registerPlugin( + registerInternalPlugin( { id: 'revenge.logging', name: 'Logging', diff --git a/src/plugins/preinit/no-track/index.ts b/src/plugins/preinit/no-track/index.ts index 1832def..04611c7 100644 --- a/src/plugins/preinit/no-track/index.ts +++ b/src/plugins/preinit/no-track/index.ts @@ -6,7 +6,7 @@ import { InternalPluginFlags, isPluginEnabledLate, PluginFlags, - registerPlugin, + registerInternalPlugin, } from '@revenge-mod/plugins/_' import { noop } from '@revenge-mod/utils/callback' import { getCurrentStack } from '@revenge-mod/utils/error' @@ -39,7 +39,7 @@ const fakeSentryCarrier = new Proxy( const getFakeCarrier = () => fakeSentryCarrier -registerPlugin( +registerInternalPlugin( { id: 'revenge.no-track', name: 'No Track', diff --git a/src/plugins/start/developer-kit/index.ts b/src/plugins/start/developer-kit/index.ts index 184b4dd..6d7c17e 100644 --- a/src/plugins/start/developer-kit/index.ts +++ b/src/plugins/start/developer-kit/index.ts @@ -7,7 +7,7 @@ import { InternalPluginFlags, isPluginEnabledLate, PluginFlags, - registerPlugin, + registerInternalPlugin, } from '@revenge-mod/plugins/_' import pluginSettings from '../settings' import * as dt from './devtools' @@ -37,14 +37,14 @@ const defaultStorage: Storage = { } // TODO(PalmDevs): only register in development builds once updates can be made automatic -registerPlugin<{ jsonStorage: Storage }>( +registerInternalPlugin<{ jsonStorage: Storage }>( { id: 'revenge.developer-kit', name: 'Developer Kit', description: 'Tools assisting Revenge developers.', author: 'Revenge', icon: 'WrenchIcon', - dependencies: [pluginSettings], + dependencies: { [pluginSettings]: {} }, }, { jsonStorage: { diff --git a/src/plugins/start/developer-kit/utils.ts b/src/plugins/start/developer-kit/utils.ts index 28c81c2..14ca775 100644 --- a/src/plugins/start/developer-kit/utils.ts +++ b/src/plugins/start/developer-kit/utils.ts @@ -29,6 +29,6 @@ export function register() { [Setting.AssetBrowser]: AssetBrowserSetting, [Setting.TestErrorBoundary]: TestErrorBoundarySetting, }), - addSettingsItemToSection('REVENGE', Setting.RevengeDeveloper), + addSettingsItemToSection('REVENGE', Setting.RevengeDeveloper, -1), ) } diff --git a/src/plugins/start/error-boundary/index.tsx b/src/plugins/start/error-boundary/index.tsx index 542dab0..d419ab9 100644 --- a/src/plugins/start/error-boundary/index.tsx +++ b/src/plugins/start/error-boundary/index.tsx @@ -4,12 +4,12 @@ import { instead } from '@revenge-mod/patcher' import { InternalPluginFlags, PluginFlags, - registerPlugin, + registerInternalPlugin, } from '@revenge-mod/plugins/_' import ErrorBoundaryScreen from './components/ErrorBoundaryScreen' import type { Component, ReactNode } from 'react' -registerPlugin( +registerInternalPlugin( { id: 'revenge.error-boundary', name: 'Error Boundary', diff --git a/src/plugins/start/settings.plugins/components/BrowseFilterAndSortActionSheet.tsx b/src/plugins/start/settings.plugins/components/BrowseFilterAndSortActionSheet.tsx new file mode 100644 index 0000000..7ceed9f --- /dev/null +++ b/src/plugins/start/settings.plugins/components/BrowseFilterAndSortActionSheet.tsx @@ -0,0 +1,109 @@ +import { getAssetIdByName } from '@revenge-mod/assets' +import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon' +import { Design } from '@revenge-mod/discord/design' +import { useState } from 'react' +import type { AssetId } from '@revenge-mod/assets/types' + +const { + ActionSheet, + BottomSheetTitleHeader, + TableCheckboxRow, + TableRadioGroup, + TableRadioRow, + TableRowGroup, + TableSwitchRow, +} = Design + +export const BrowseSortLabels = { + name: 'Name (A-Z)', + size: 'Size (smallest first)', +} as const + +export type BrowseSortKey = keyof typeof BrowseSortLabels + +const SortIcons: Record = { + name: getAssetIdByName('IdIcon')!, + size: getAssetIdByName('DownloadIcon')!, +} + +export interface BrowseFilterAndSortActionSheetProps { + /** Repositories that currently have entries to browse. */ + repos: Array<{ url: string; name: string | null }> + /** Checked repository URLs. */ + checked: string[] + setChecked: (urls: string[]) => void + sort: BrowseSortKey + setSort: (sort: BrowseSortKey) => void + reverse: boolean + setReverse: (reverse: boolean) => void +} + +export default function BrowseFilterAndSortActionSheet({ + repos, + checked, + setChecked, + sort, + setSort, + reverse, + setReverse, +}: BrowseFilterAndSortActionSheetProps) { + const [checked_, setChecked_] = useState(checked) + const [reverse_, setReverse_] = useState(reverse) + + return ( + + + + {repos.map(repo => { + const isChecked = checked_.includes(repo.url) + + return ( + { + const v = isChecked + ? checked_.filter(url => url !== repo.url) + : [...checked_, repo.url] + + setChecked(v) + setChecked_(v) + }} + /> + ) + })} + + setSort(v as BrowseSortKey)} + > + {Object.entries(BrowseSortLabels).map(([key, label]) => ( + + } + label={label} + value={key} + /> + ))} + + + { + setReverse(v) + setReverse_(v) + }} + /> + + + ) +} diff --git a/src/plugins/start/settings.plugins/components/BrowsePluginActionSheet.tsx b/src/plugins/start/settings.plugins/components/BrowsePluginActionSheet.tsx new file mode 100644 index 0000000..46523ab --- /dev/null +++ b/src/plugins/start/settings.plugins/components/BrowsePluginActionSheet.tsx @@ -0,0 +1,68 @@ +import { getAssetIdByName } from '@revenge-mod/assets' +import { ActionSheetActionCreators } from '@revenge-mod/discord/actions' +import { Design } from '@revenge-mod/discord/design' +import { PluginInfo } from './PluginCard' +import { IdRow, RepositoryRow } from './PluginOptionsActionSheet' + +const { ActionSheet, Button, Stack, TableRowGroup } = Design + +const DownloadIcon = getAssetIdByName('DownloadIcon', 'png')! + +export interface BrowsePluginActionSheetProps { + name: string + author: string + description: string + version: string + icon?: string + id: string + /** Display text for the Repository row, eg. `Name (url)`. */ + repositoryText: string + onInstall: () => void + sheetKey: string +} + +/** + * Sheet for a plugin that isn't installed yet, opened from the Browse screen. + */ +export default function BrowsePluginActionSheet({ + name, + author, + description, + version, + icon, + id, + repositoryText, + onInstall, + sheetKey, +}: BrowsePluginActionSheetProps) { + return ( + + + { + ActionSheetActionCreators.hideActionSheet( + sheetKey, + ) + onInstall() + }} + /> + } + /> + + + + + + + ) +} diff --git a/src/plugins/start/settings/components/FilterAndSortActionSheet.tsx b/src/plugins/start/settings.plugins/components/FilterAndSortActionSheet.tsx similarity index 100% rename from src/plugins/start/settings/components/FilterAndSortActionSheet.tsx rename to src/plugins/start/settings.plugins/components/FilterAndSortActionSheet.tsx diff --git a/src/plugins/start/settings/components/NavigatorHeaderWithIcon.tsx b/src/plugins/start/settings.plugins/components/NavigatorHeaderWithIcon.tsx similarity index 100% rename from src/plugins/start/settings/components/NavigatorHeaderWithIcon.tsx rename to src/plugins/start/settings.plugins/components/NavigatorHeaderWithIcon.tsx diff --git a/src/plugins/start/settings/components/PluginCard.tsx b/src/plugins/start/settings.plugins/components/PluginCard.tsx similarity index 72% rename from src/plugins/start/settings/components/PluginCard.tsx rename to src/plugins/start/settings.plugins/components/PluginCard.tsx index 9b55a0c..b5ecd60 100644 --- a/src/plugins/start/settings/components/PluginCard.tsx +++ b/src/plugins/start/settings.plugins/components/PluginCard.tsx @@ -1,18 +1,22 @@ import { getAssetIdByName } from '@revenge-mod/assets' 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, isPluginPendingUpdate, isPluginStartable, } from '@revenge-mod/plugins/_' +import { formatVersion } from '@revenge-mod/plugins/utils' import { memo } from 'react' -import { Image, Pressable } from 'react-native' +import { Pressable } from 'react-native' import { handleDisablePlugin, handleEnablePlugin } from '../utils/actions' import { openPluginSettings } from '../utils/alerts' -import { showPluginOptionsActionSheet } from '../utils/sheets' +import { + showBrowsePluginActionSheet, + showPluginOptionsActionSheet, +} from '../utils/sheets' +import { PluginIcon } from './PluginIcon' import { usePluginEnabled } from './PluginStateProvider' import { useClickOutsideTooltip, @@ -21,48 +25,42 @@ import { } from './TooltipProvider' import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_' -const { Card, Text, Stack, IconButton, createStyles } = Design +const { Card, Text, Stack, IconButton, Button, createStyles } = Design -const PuzzlePieceIcon = getAssetIdByName('PuzzlePieceIcon', 'png')! const SettingsIcon = getAssetIdByName('SettingsIcon', 'png')! const MoreVerticalIcon = getAssetIdByName('MoreVerticalIcon', 'png')! +const DownloadIcon = getAssetIdByName('DownloadIcon', 'png')! export const PLUGIN_CARD_ESTIMATED_SIZE = 116 +export const PLUGIN_CARD_HALF_GUTTER = 6 + export const PluginCard = memo(function PluginCard({ name, description, + version, author, icon, actions, - rowEnd, - columnEnd, }: { name: string description: string + version: string author: string icon?: string actions?: React.ReactNode - rowEnd?: boolean - columnEnd?: boolean }) { const styles_ = usePluginCardStyles() return ( - + @@ -73,6 +71,7 @@ export const PluginInfo = memo(function PluginInfo({ name, description, author, + version, icon, actions, aligned, @@ -80,6 +79,7 @@ export const PluginInfo = memo(function PluginInfo({ name: string description: string author: string + version: string icon?: string actions?: React.ReactNode aligned?: boolean @@ -97,10 +97,7 @@ export const PluginInfo = memo(function PluginInfo({ spacing={8} style={[styles_.topContainer, styles.flex]} > - + by {author} + {version ? ` \u2022 ${version}` : ''} {description} @@ -133,18 +131,14 @@ export const PluginInfo = memo(function PluginInfo({ export const InstalledPluginCard = memo(function InstalledPluginCard({ plugin, meta, - rowEnd, - columnEnd, }: { plugin: AnyPlugin meta: InternalPluginMeta - rowEnd?: boolean - columnEnd?: boolean }) { const enabled = usePluginEnabled(plugin) const { - manifest: { name, description, author, icon }, + manifest: { name, description, version, author, icon }, } = plugin const essential = isPluginEssential(meta) @@ -167,10 +161,9 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({ void +}) { + return ( + + { + showBrowsePluginActionSheet({ + name, + author, + description, + version, + icon, + id, + repositoryText, + onInstall, + }) + }} + /> +