feat!: plugins page, tons of API changes, and bug fixes

Plugins
- Settings: Plugins page is now semi-functional
- No Track: Added new plugin
- Developer Settings: Clean up properly
- Logging: Log in preInit stage
- Staff Settings: Add ReloadRequired flags when late-running

@revenge-mod/init
- Moved to the top-level src/index.ts instead

@revenge-mod/modules
- Switch `exports` and `id` parameters in finders APIs
- Lookups now return an array of exports and ID instead of just exports
  - Lookup ID APIs have been removed
- Add `finders.get` helper, a new alternative for `finders.find`
- Corrected documentation for subscription APIs

@revenge-mod/plugins
- Added new APIs for plugins to allow running during pre-init stages
- Added `unscoped.modules.finders.get*`
- Added `unscoped.react.native`

@revenge-mod/react
- native: Added `onRunApplication` and `onRunApplicationFinished`

@revenge-mod/utils
- callbacks: Add `noopFalse`
- promises: Fix `allSettled`
- promises: Add `sleep`
This commit is contained in:
PalmDevs 2025-05-18 22:24:48 +07:00
parent 4d31b7016c
commit a43cd5d564
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
56 changed files with 921 additions and 441 deletions

View file

@ -12,7 +12,7 @@ export let AssetRegistry: ReactNative.AssetsRegistry
export let AssetRegistryModuleId: number
// Tracking/caching assets
const unsubAR = waitForModules(byProps<ReactNative.AssetsRegistry>('registerAsset'), (id, exports) => {
const unsubAR = waitForModules(byProps<ReactNative.AssetsRegistry>('registerAsset'), (exports, id) => {
AssetRegistryModuleId = id
AssetRegistry = exports as ReactNative.AssetsRegistry
@ -64,7 +64,7 @@ const unsubRAS = waitForModules(
byName<{
addCustomSourceTransformer: (transformer: (arg: { asset: Asset }) => ReactNativeAsset) => void
}>('resolveAssetSource'),
(_, rAS) => {
rAS => {
unsubRAS()
// Custom assets

View file

@ -18,7 +18,7 @@ import type { DiscordModules } from './types'
export let ActionSheetActionCreators: DiscordModules.Actions.ActionSheetActionCreators = proxify(
() => {
const module = lookupModule(
const [module] = lookupModule(
preferExports(
byProps<DiscordModules.Actions.ActionSheetActionCreators>('hideActionSheet'),
byDependencies([
@ -45,7 +45,7 @@ export let ActionSheetActionCreators: DiscordModules.Actions.ActionSheetActionCr
export let AlertActionCreators: DiscordModules.Actions.AlertActionCreators = proxify(
() => {
const module = lookupModule(
const [module] = lookupModule(
preferExports(
byProps<DiscordModules.Actions.AlertActionCreators>('openAlert', 'dismissAlert'),
byDependencies([
@ -81,5 +81,5 @@ export let ToastActionCreators: DiscordModules.Actions.ToastActionCreators = pro
},
)
for (const module of generator) if (module.open.length === 1) return (ToastActionCreators = module)
for (const [module] of generator) if (module.open.length === 1) return (ToastActionCreators = module)
})!

View file

@ -1,12 +1,13 @@
import { byDependencies, byProps, preferExports, relativeDep } from '@revenge-mod/modules/finders/filters'
import { lookupModuleId } from '@revenge-mod/modules/finders/lookup'
import { lookupModule } from '@revenge-mod/modules/finders/lookup'
import { waitForModules } from '@revenge-mod/modules/finders/wait'
import type { DiscordModules } from '../types'
import type { Metro } from '@revenge-mod/modules/types'
// ../discord_common/js/packages/flux
export const DispatcherModuleId = lookupModuleId(
export const [Dispatcher, DispatcherModuleId] = lookupModule(
preferExports(
byProps<DiscordModules.Flux.Dispatcher>('_interceptors'),
byDependencies([relativeDep(1), undefined, undefined, undefined, undefined, 2]),
@ -14,11 +15,9 @@ export const DispatcherModuleId = lookupModuleId(
{
includeUninitialized: true,
},
)
) as [DiscordModules.Flux.Dispatcher, Metro.ModuleID]
export const Dispatcher = __r(DispatcherModuleId!) as DiscordModules.Flux.Dispatcher
waitForModules(byProps<DiscordModules.Flux.Store>('_dispatchToken'), (_, store) => {
waitForModules(byProps<DiscordModules.Flux.Store>('_dispatchToken'), store => {
const name = store.getName()
_stores[name] = store
})
@ -27,5 +26,5 @@ const _stores: Record<string, any> = {}
export const Stores = new Proxy(_stores, {
ownKeys: () => Reflect.ownKeys(_stores),
get: (_, name) => Stores[name as string],
get: (_, name) => _stores[name as string],
})

View file

@ -1,15 +1,15 @@
import { byName, byProps } from '@revenge-mod/modules/finders/filters'
import { lookupModule, lookupModuleId } from '@revenge-mod/modules/finders/lookup'
import { lookupModule } from '@revenge-mod/modules/finders/lookup'
import type { DiscordModules } from '../types'
import type { Metro } from '@revenge-mod/modules/types'
export { AppStartPerformance } from '../preinit'
export * as Flux from './flux'
// ../discord_common/js/shared/Logger.tsx
export const Logger = lookupModule(byName<DiscordModules.Logger>('Logger'))!
export const [Logger] = lookupModule(byName<DiscordModules.Logger>('Logger')) as [DiscordModules.Logger, Metro.ModuleID]
// ../discord_common/js/packages/tokens/native.tsx
export const TokensModuleId = lookupModuleId(byProps('RawColor'))!
export const Tokens = __r(TokensModuleId)
export const [Tokens, TokensModuleId] = lookupModule(byProps('RawColor')) as [any, Metro.ModuleID]

View file

@ -19,7 +19,7 @@ export let Design: Design = proxify(
() => {
// ID: 3236
// [3237, 1366, 3238, 3239, 2, ...];
const module = lookupModule(
const [module] = lookupModule(
preferExports(
byProps<Design>('TableRow', 'Button'),
byDependencies(looseDeps([relativeDep(1), undefined, relativeDep(2), relativeDep(3), 2])),
@ -43,7 +43,7 @@ export let FormSwitch: DiscordModules.Components.FormSwitch = proxify(() => {
// ID: 46
// Deps: [47, 48, 35, 49]
const module = lookupModule(
const [module] = lookupModule(
preferExports(
bySingleProp<{ FormSwitch: DiscordModules.Components.FormSwitch }>('FormSwitch'),
byDependencies([

View file

@ -22,7 +22,7 @@ export function isSettingsModulesLoaded() {
* @returns A function to unsubscribe from the event.
* @throws Throws an error if the settings modules are already loaded. Check with {@link isSettingsModulesLoaded} first.
*/
export function onceSettingsModulesLoaded(subcription: SettingsModulesLoadedSubscription) {
export function onSettingsModulesLoaded(subcription: SettingsModulesLoadedSubscription) {
if (_data[2]) throw new Error('Settings modules already loaded')
_subs.add(subcription)

View file

@ -11,7 +11,7 @@ export type SettingListRenderer = DiscordModules.Modules.Settings.SettingListRen
export let SettingListRenderer: SettingListRenderer = proxify(
() => {
const module = lookupModule(
const [module] = lookupModule(
preferExports(
byProps<SettingListRenderer>('SettingsList'),
byDependencies(

View file

@ -1,10 +1,11 @@
import { byDependencies, byProps, preferExports, relativeDep } from '@revenge-mod/modules/finders/filters'
import { lookupModule } from '@revenge-mod/modules/finders/lookup'
import type { Metro } from '@revenge-mod/modules/types'
import type { DiscordModules } from './types'
// ../discord_common/js/shared/AppStartPerformance.tsx
export const AppStartPerformance = lookupModule(
export const [AppStartPerformance] = lookupModule(
preferExports(
byProps<DiscordModules.AppStartPerformance>('markAndLog'),
byDependencies([relativeDep(-1), undefined, undefined, 2]),
@ -12,6 +13,6 @@ export const AppStartPerformance = lookupModule(
{
includeUninitialized: true,
},
)!
) as [DiscordModules.AppStartPerformance, Metro.ModuleID]
AppStartPerformance.mark('👊', 'Preinit')
AppStartPerformance.mark('👊', 'Pre-init')

View file

@ -262,8 +262,8 @@ export namespace DiscordModules {
onBlur?: () => void
onFocus?: () => void
leadingIcon?: () => JSX.Element
trailingIcon?: () => JSX.Element
leadingIcon?: FC
trailingIcon?: FC
leadingText?: string
trailingText?: string
description?: string

View file

@ -4,7 +4,7 @@ import { proxify } from '@revenge-mod/utils/proxy'
export let nodeUtil: typeof import('node:util') = proxify(
() => {
const module = lookupModule(
const [module] = lookupModule(
preferExports(byProps<typeof nodeUtil>('inspect'), byDependencies([relativeDep(1), [], []])),
{
includeUninitialized: true,

View file

@ -16,7 +16,7 @@ let ClipboardModule: typeof import('@react-native-clipboard/clipboard') = proxif
// r6 = 4792;
// r5 = [27, 4793];
const module = lookupModule(
const [module] = lookupModule(
preferExports(
byProps<typeof ClipboardModule>('useClipboard'),
every(

View file

@ -1,13 +1,13 @@
import { byDependencies, byName, byProps, looseDeps, preferExports } from '@revenge-mod/modules/finders/filters'
import { lookupModule, lookupModuleId } from '@revenge-mod/modules/finders/lookup'
import { lookupModule } from '@revenge-mod/modules/finders/lookup'
import { proxify } from '@revenge-mod/utils/proxy'
const _createClassModuleId = lookupModuleId(byName('_createClass'))
const _classCallCheckModuleId = lookupModuleId(byName('_classCallCheck'))
const [, _createClassModuleId] = lookupModule(byName('_createClass'))
const [, _classCallCheckModuleId] = lookupModule(byName('_classCallCheck'))
export let ReactNavigationNative: typeof import('@react-navigation/native') = proxify(
() => {
const module = lookupModule(
const [module] = lookupModule(
preferExports(
byProps<typeof ReactNavigationNative>('useLinkTo'),
byDependencies(looseDeps([[], looseDeps([[_createClassModuleId, _classCallCheckModuleId]])])),
@ -26,7 +26,7 @@ export let ReactNavigationNative: typeof import('@react-navigation/native') = pr
export let ReactNavigationStack: typeof import('@react-navigation/stack') = proxify(
() => {
const module = lookupModule(
const [module] = lookupModule(
preferExports(
byProps<typeof ReactNavigationStack>('createStackNavigator'),
byDependencies(looseDeps([[], looseDeps([[_createClassModuleId, _classCallCheckModuleId]])])),

View file

@ -6,7 +6,7 @@ import { ReactJsxRuntimeModuleId, ReactModuleId, ReactNativeModuleId } from '@re
export let FlashList: typeof import('@shopify/flash-list') = proxify(
() => {
const module = lookupModule(
const [module] = lookupModule(
preferExports(
byProps<typeof FlashList>('FlashList'),
byDependencies([

View file

@ -1,10 +0,0 @@
{
"name": "@revenge-mod/init",
"private": true,
"type": "module",
"exports": {
".": {
"default": "./src/index.ts"
}
}
}

View file

@ -1,52 +0,0 @@
// Note that both Object.freeze and Proxy patches are implicitly run by requiring the two libraries below.
import { patchMetroDefine } from '@revenge-mod/modules/_/metro'
import { interceptProperty } from '@revenge-mod/utils/objects'
// By doing this, we are assuming __METRO_GLOBAL_PREFIX__ is an empty string and globalThis.__d isn't already set.
// But both should be true either way.
const unintercept = interceptProperty('__d', (_, mDefine) => (unintercept(), patchMetroDefine(mDefine)))
import { onModuleFirstRequired, onModuleInitialized } from '@revenge-mod/modules/metro/subscriptions'
import { getErrorStack } from '@revenge-mod/utils/errors'
onModuleFirstRequired(0, function onceIndexRequired() {
runCatching(() => {
if (__BUILD_FLAG_LOG_PROMISE_REJECTIONS__) require('./patches/log-promise-rejections')
// Initialize preinit libraries
require('@revenge-mod/modules/preinit')
require('@revenge-mod/react/preinit')
require('@revenge-mod/assets/preinit')
require('@revenge-mod/discord/preinit')
onModuleInitialized(0, function onceIndexInitialized() {
runCatching(() => {
// Register all plugins
require('~/plugins/all')
// Initialize init libraries
require('@revenge-mod/plugins/init')
})
})
})
})
function runCatching(fn: () => void) {
try {
fn()
} catch (e) {
// TODO(init): Move to use native provided alert function, which will accept a string stack trace
// Above will reduce the need for runCatching() to exist, as the code won't be able to be deduped any further
const { ClientInfoModule, DeviceModule } = require('@revenge-mod/discord/native') as
// biome-ignore format: Don't format this please
typeof import('@revenge-mod/discord/native')
alert(
// biome-ignore lint/style/useTemplate: I want this to be readable, thank you
`Failed to load Revenge (${__BUILD_VERSION__}-${__BUILD_COMMIT__}-${__BUILD_BRANCH__} (${__BUILD_ENV__}))\n` +
`Discord: ${ClientInfoModule.Version} (${ClientInfoModule.Build})\n` +
`Device: ${DeviceModule.deviceManufacturer} ${DeviceModule.deviceModel}\n\n` +
getErrorStack(e),
)
}
}

View file

@ -6,6 +6,9 @@
"./finders/filters": {
"default": "./src/finders/filters.ts"
},
"./finders/get": {
"default": "./src/finders/get.ts"
},
"./finders/lookup": {
"default": "./src/finders/lookup.ts"
},

View file

@ -0,0 +1,101 @@
import { noopFalse } from '@revenge-mod/utils/callbacks'
import { type LookupModulesOptions, lookupModule, lookupModuleByImportedPath } from './lookup'
import { type WaitForModulesOptions, waitForModuleByImportedPath, waitForModules } from './wait'
import type { MaybeDefaultExportMatched } from '../types'
import type { Metro } from '../types'
import type { RunFilterReturnExportsOptions } from './_internal'
import type { Filter, FilterResult } from './filters'
export type GetModuleOptions<
ReturnNamespace extends boolean = boolean,
IncludeUninitialized extends boolean = boolean,
> = WaitForModulesOptions<ReturnNamespace> & LookupModulesOptions<ReturnNamespace, IncludeUninitialized>
export type GetModuleResult<
F extends Filter,
O extends GetModuleOptions,
> = O extends RunFilterReturnExportsOptions<true> ? MaybeDefaultExportMatched<FilterResult<F>> : FilterResult<F>
export type GetModuleCallback<T> = (exports: T, id: Metro.ModuleID) => any
export type GetModuleUnsubscribeFunction = () => boolean
/**
* Get a module matching the filter.
*
* This is a combination of {@link lookupModule} and {@link waitForModules}.
*
* @param filter The filter to use to find the module.
* @param options The options to use for the find.
* @returns A promise that resolves to the module's exports or rejects if the find is aborted before a module is found.
*
* @example
* ```ts
* getModule(byProps<typeof import('react')>('createElement'), React => {
* // Immediately called because React is always initialized when plugins are loaded
* })
*
* getModule(byProps<typeof import('@shopify/flash-list')>('FlashList'), FlashList => {
* // Called when the module is initialized
* })
* ```
*/
export function getModule<F extends Filter>(
filter: F,
callback: GetModuleCallback<FilterResult<F>>,
): GetModuleUnsubscribeFunction
export function getModule<
F extends O extends GetModuleOptions<boolean, true> ? Filter<any, false> : Filter,
O extends GetModuleOptions,
>(filter: F, callback: GetModuleCallback<FilterResult<F>>, options: O): GetModuleUnsubscribeFunction
export function getModule(filter: Filter, callback: GetModuleCallback<any>, options?: GetModuleOptions) {
const [exports, id] = lookupModule(filter, options!)
if (id != null) {
callback(exports, id)
return noopFalse
}
const unsub = waitForModules(
filter,
(id, exports) => {
unsub()
callback(exports, id)
},
options!,
)
return unsub
}
/**
* Get a module by its imported path.
*
* @param path The path to find the module by.
* @param options The options to use for the find.
* @returns A promise that resolves to the module's exports or rejects if the find is aborted before the module is found.
*
* @example
* ```ts
* getModuleByImportedPath('modules/main_tabs_v2/native/settings/SettingsConstants.tsx', SettingsConstants => {
* console.log('Settings page opened') // Logs once the module is initialized
* })
* ```
*/
export function getModuleByImportedPath<T>(path: string, callback: GetModuleCallback<T>): GetModuleUnsubscribeFunction {
const [exports, id] = lookupModuleByImportedPath(path)
if (id != null) {
callback(exports, id)
return noopFalse
}
const unsub = waitForModuleByImportedPath(path, (exports, id) => {
unsub()
callback(exports, id)
})
return unsub
}

View file

@ -37,10 +37,12 @@ export type LookupModulesOptions<
IncludeUninitialized extends boolean = boolean,
> = RunFilterReturnExportsOptions<ReturnNamespace> & BaseLookupModulesOptions<IncludeUninitialized>
export type LookupModulesResult<
F extends Filter,
O extends LookupModulesOptions,
> = O extends RunFilterReturnExportsOptions<true> ? MaybeDefaultExportMatched<FilterResult<F>> : FilterResult<F>
export type LookupModulesResult<F extends Filter, O extends LookupModulesOptions> = [
exports: O extends RunFilterReturnExportsOptions<true>
? MaybeDefaultExportMatched<FilterResult<F>>
: FilterResult<F>,
id: Metro.ModuleID,
]
export type LookupModuleIdsOptions<
IncludeUninitialized extends boolean = boolean,
@ -63,47 +65,6 @@ export type LookupModuleIdsOptions<
} & BaseLookupModulesOptions<false>)
)
/**
* Lookup module IDs.
*
* You can lookup uninitialized module IDs by passing `options.includeUninitialized` when filtering via without-exports filters (eg. `byDependencies`).
* Use the `moduleStateAware` helper to filter dynamically based on whether the module is initialized or not.
*
* You can also lookup all modules (even ones with bad exports) by passing `options.includeAll`.
* Use the `preferExports` helper to filter dynamically based on whether the module has good exports or not.
*
* @param filter The filter to use.
* @param options The options to use for the lookup.
* @returns A generator that yields the module exports that match the filter.
*
* @example
* ```ts
* const lookup = lookupModuleIds(byProps('createElement'))
* // Log all module IDs that export React
* for (const id of lookup) console.log(id)
* ```
*
* @example Lookup uninitialized modules
* ```ts
* const lookup = lookupModuleIds(byDependencies([...]), { includeUninitialized: true })
* // Log all module IDs that has those dependencies
* for (const id of lookup) console.log(id)
* ```
*/
export function* lookupModuleIds<O extends LookupModuleIdsOptions>(
filter: O extends LookupModuleIdsOptions<true> ? Filter<any, false> : Filter,
options?: O,
): Generator<Metro.ModuleID, undefined> {
if (options?.includeAll) {
for (const id of _metas.keys()) if (runFilter(filter, id, getInitializedModuleExports(id), options)) yield id
} else {
if (options?.includeInitialized ?? true)
for (const id of _inits) if (runFilter(filter, id, getInitializedModuleExports(id), options)) yield id
if (options?.includeUninitialized) for (const id of _uninits) if (runFilter(filter, id)) yield id
}
}
/**
* Lookup modules.
*
@ -121,7 +82,7 @@ export function* lookupModuleIds<O extends LookupModuleIdsOptions>(
* for (const exports of lookup) console.log(exports)
* ```
*/
export function lookupModules<F extends Filter>(filter: F): Generator<FilterResult<F>, undefined>
export function lookupModules<F extends Filter>(filter: F): Generator<LookupModulesResult<F, never>, undefined>
export function lookupModules<
F extends O extends BaseLookupModulesOptions<true> ? Filter<any, false> : Filter,
@ -133,7 +94,7 @@ export function* lookupModules(filter: Filter, options?: LookupModulesOptions) {
for (const id of _inits) {
const exports = getInitializedModuleExports(id)
const flag = runFilter(filter, id, exports, options)
if (flag) yield exportsFromFilterResultFlag(flag, exports, options)
if (flag) yield [exportsFromFilterResultFlag(flag, exports, options), id]
}
if (options?.includeUninitialized)
@ -142,48 +103,11 @@ export function* lookupModules(filter: Filter, options?: LookupModulesOptions) {
// Run the filter again to ensure we have the correct exports
const exports = __r(id)
const flag = runFilter(filter, id, exports, options)
if (flag) yield exportsFromFilterResultFlag(flag, exports, options)
warnDeveloperAboutAPartialFilterMatch(id, filter.key)
if (flag) yield [exportsFromFilterResultFlag(flag, exports, options), id]
else warnDeveloperAboutAPartialFilterMatch(id, filter.key)
}
}
/**
* Lookup a module ID. Skipping creating a `Generator`.
*
* @see {@link lookupModuleIds} for more documentation.
*
* @param filter The filter to use.
* @param options The options to use for the lookup.
* @returns A generator that yields the module exports that match the filter.
*
* @example
* ```ts
* const id = lookupModuleId(byProps('createElement'))
* // Log the first initialized module ID that exports React
* console.log(id)
* ```
*
* @example Lookup uninitialized modules
* ```ts
* const id = lookupModuleId(byDependencies([...]), { includeUninitialized: true })
* // Log the first initialized module ID that has those dependencies
* console.log(id)
* ```
*/
export function lookupModuleId<O extends LookupModuleIdsOptions>(
filter: O extends BaseLookupModulesOptions<true> ? Filter<any, false> : Filter,
options?: O,
): Metro.ModuleID | undefined {
if (options?.includeAll) {
for (const id of _metas.keys()) if (runFilter(filter, id, getInitializedModuleExports(id), options)) return id
} else {
if (options?.includeInitialized ?? true)
for (const id of _inits) if (runFilter(filter, id, getInitializedModuleExports(id), options)) return id
if (options?.includeUninitialized) for (const id of _uninits) if (runFilter(filter, id)) return id
}
}
/**
* Lookup a module. Skipping creating a `Generator`.
*
@ -198,19 +122,19 @@ export function lookupModuleId<O extends LookupModuleIdsOptions>(
* const React = lookupModule(byProps<typeof import('react')>('createElement'))
* ```
*/
export function lookupModule<F extends Filter>(filter: F): FilterResult<F> | undefined
export function lookupModule<F extends Filter>(filter: F): LookupModulesResult<F, never> | []
export function lookupModule<F extends Filter, O extends LookupModulesOptions>(
filter: F,
options: O,
): LookupModulesResult<F, O> | undefined
): LookupModulesResult<F, O> | []
export function lookupModule(filter: Filter, options?: LookupModulesOptions) {
if (options?.includeInitialized ?? true)
for (const id of _inits) {
const exports = getInitializedModuleExports(id)
const flag = runFilter(filter, id, exports, options)
if (flag) return exportsFromFilterResultFlag(flag, exports, options)
if (flag) return [exportsFromFilterResultFlag(flag, exports, options), id]
}
if (options?.includeUninitialized)
@ -219,24 +143,11 @@ export function lookupModule(filter: Filter, options?: LookupModulesOptions) {
const exports = __r(id)
// Run the filter again to ensure we have the correct exports
const flag = runFilter(filter, id, exports, options)
if (flag) return exportsFromFilterResultFlag(flag, exports, options)
if (flag) return [exportsFromFilterResultFlag(flag, exports, options), id]
warnDeveloperAboutAPartialFilterMatch(id, filter.key)
}
}
/**
* Lookup a module ID by its imported path. **The module may have bad exports.**
*
* @param path The path to lookup the module ID by.
* @returns The module ID if the module is initialized, or `undefined` if the module is not found or not initialized.
*
* @example
* ```ts
* const LoggerId = lookupModuleIdByImportedPath('modules/debug/Logger.tsx')
* ```
*/
export function lookupModuleIdByImportedPath(path: string): Metro.ModuleID | undefined {
return _paths.get(path)
return []
}
/**
@ -249,12 +160,13 @@ export function lookupModuleIdByImportedPath(path: string): Metro.ModuleID | und
*
* @example
* ```ts
* const { default: Logger } = lookupModuleByImportedPath<{ default: typeof DiscordModules.Logger }>('modules/debug/Logger.tsx')
* const [{ default: Logger }] = lookupModuleByImportedPath<{ default: typeof DiscordModules.Logger }>('modules/debug/Logger.tsx')
* ```
*/
export function lookupModuleByImportedPath<T = any>(path: string): T | undefined {
// undefined will also return undefined either way, so we can just non-nullish assert it
return getInitializedModuleExports(lookupModuleIdByImportedPath(path)!)
export function lookupModuleByImportedPath<T = any>(path: string): [exports: T, id: Metro.ModuleID] | [] {
const id = _paths.get(path)
if (id == null) return []
return [getInitializedModuleExports(id), id]
}
/**

View file

@ -15,6 +15,10 @@ export interface BaseWaitForModulesOptions<IncludeAll extends boolean = boolean>
includeAll?: IncludeAll
}
export type WaitForModulesUnsubscribeFunction = () => boolean
export type WaitForModulesCallback<T> = (exports: T, id: Metro.ModuleID) => any
export type WaitForModulesOptions<
ReturnNamespace extends boolean = boolean,
IncludeBadExports extends boolean = boolean,
@ -38,7 +42,7 @@ export type WaitForModulesResult<
* const unsub = waitForModules(
* byName<typeof import('@shopify/flash-list')>('FlashList'),
* // (exports: typeof import('@shopify/flash-list'), id: Metro.ModuleID) => any
* (id, exports) => {
* (exports, id) => {
* unsub()
* // Do something with the module...
* }
@ -47,29 +51,29 @@ export type WaitForModulesResult<
*/
export function waitForModules<F extends Filter>(
filter: F,
callback: (id: Metro.ModuleID, exports: FilterResult<F>) => any,
): () => void
callback: WaitForModulesCallback<WaitForModulesResult<F, object>>,
): WaitForModulesUnsubscribeFunction
export function waitForModules<
F extends O extends WaitForModulesOptions<boolean, true> ? Filter<any, false> : Filter,
O extends WaitForModulesOptions,
>(filter: F, callback: (id: Metro.ModuleID, exports: WaitForModulesResult<F, O>) => any, options: O): () => void
>(
filter: F,
callback: WaitForModulesCallback<WaitForModulesResult<F, O>>,
options: O,
): WaitForModulesUnsubscribeFunction
export function waitForModules(
filter: Filter,
callback: (id: Metro.ModuleID, exports: Metro.ModuleExports) => any,
options?: WaitForModulesOptions,
) {
export function waitForModules(filter: Filter, callback: WaitForModulesCallback<any>, options?: WaitForModulesOptions) {
return onAnyModuleInitialized(
options?.includeAll
? (id, exports) => {
const flag = runFilter(filter, id, exports, options)
if (flag) callback(id, exportsFromFilterResultFlag(flag, exports, options))
if (flag) callback(exportsFromFilterResultFlag(flag, exports, options), id)
}
: (id, exports) => {
if (initializedModuleHasBadExports(id)) return
const flag = runFilter(filter, id, exports, options)
if (flag) callback(id, exportsFromFilterResultFlag(flag, exports, options))
if (flag) callback(exportsFromFilterResultFlag(flag, exports, options), id)
},
)
}
@ -90,7 +94,7 @@ export function waitForModules(
* ```ts
* waitForModuleByImportedPath(
* 'utils/PlatformUtils.tsx',
* (id, exports) => {
* (exports, id) => {
* // Do something with the module...
* }
* )
@ -98,7 +102,7 @@ export function waitForModules(
*/
export function waitForModuleByImportedPath<T = any>(
path: string,
callback: (id: Metro.ModuleID, exports: T) => any,
callback: WaitForModulesCallback<T>,
options?: BaseWaitForModulesOptions,
) {
const unsub = onModuleFinishedImporting(
@ -106,14 +110,14 @@ export function waitForModuleByImportedPath<T = any>(
? (id, cmpPath) => {
if (path === cmpPath) {
unsub()
callback(id, getInitializedModuleExports(id))
callback(getInitializedModuleExports(id), id)
}
}
: (id, cmpPath) => {
if (path === cmpPath) {
unsub()
if (initializedModuleHasBadExports(id)) return
callback(id, getInitializedModuleExports(id))
callback(getInitializedModuleExports(id), id)
}
},
)

View file

@ -5,7 +5,7 @@ import { _execPathSubs } from './subscriptions/_internal'
import { byProps } from '../finders/filters'
import { waitForModules } from '../finders/wait'
const unsubFFI = waitForModules(byProps('fileFinishedImporting'), (_, exports) => {
const unsubFFI = waitForModules(byProps('fileFinishedImporting'), exports => {
unsubFFI()
const orig = exports.fileFinishedImporting

View file

@ -1,15 +1,11 @@
import type {
ModuleFirstRequiredCallback,
ModuleInitializedCallback,
ModuleWithImportedPathInitializedCallback,
} from '.'
import type { ModuleFirstRequiredCallback, ModuleInitializedCallback, ModuleFinishedImportingCallback } from '.'
import type { Metro } from '../../types'
export const _reqAll = new Set<ModuleFirstRequiredCallback>()
export const _req = new Map<Metro.ModuleID, Set<ModuleFirstRequiredCallback>>()
export const _initAll = new Set<ModuleInitializedCallback>()
export const _init = new Map<Metro.ModuleID, Set<ModuleInitializedCallback>>()
export const _path = new Set<ModuleWithImportedPathInitializedCallback>()
export const _path = new Set<ModuleFinishedImportingCallback>()
export function _execReqSubs(id: Metro.ModuleID) {
for (const cb of _reqAll) cb(id)

View file

@ -5,11 +5,13 @@ import type { initializedModuleHasBadExports } from '../utils'
export type ModuleFirstRequiredCallback = (id: Metro.ModuleID) => void
export type ModuleInitializedCallback = (id: Metro.ModuleID, exports: Metro.ModuleExports) => void
export type ModuleWithImportedPathInitializedCallback = (id: Metro.ModuleID, path: string) => void
export type ModuleFinishedImportingCallback = (id: Metro.ModuleID, path: string) => void
/**
* Registers a callback to be called when any module is initialized.
* During this time, the module factory is not yet executed.
*
* This runs after the module factory has been executed, but before the module is considered initialized by Metro.
* However, Revenge APIs will consider the module initialized at this point.
*
* @see {@link initializedModuleHasBadExports} to avoid bad module exports.
*
@ -25,7 +27,7 @@ export function onAnyModuleInitialized(callback: ModuleInitializedCallback) {
* Registers a callback to be called when a specific module is initialized.
*
* This runs after the module factory has been executed, but before the module is considered initialized by Metro.
* However, Revenge APIs will consider the module initialized at this poi
* However, Revenge APIs will consider the module initialized at this point.
*
* @see {@link initializedModuleHasBadExports} to avoid bad module exports.
*
@ -52,16 +54,15 @@ export function onModuleInitialized(id: Metro.ModuleID, callback: ModuleInitiali
* @param callback The callback to be called.
* @returns A function that unregisters the callback.
*/
export function onModuleFinishedImporting(callback: ModuleWithImportedPathInitializedCallback) {
export function onModuleFinishedImporting(callback: ModuleFinishedImportingCallback) {
_path.add(callback)
return () => _path.delete(callback)
}
/**
* Registers a callback to be called when any module is required the first time.
* Registers a callback to be called when any module is being initialized.
*
* This runs after the module factory has been executed, but before the module is considered initialized by Metro.
* However, Revenge APIs will consider the module initialized at this point.
* This runs before the module factory is executed.
*
* @see {@link initializedModuleHasBadExports} to avoid bad module exports.
*
@ -74,8 +75,9 @@ export function onAnyModuleFirstRequired(callback: ModuleFirstRequiredCallback)
}
/**
* Registers a callback to be called when a specific module is required the first time.
* During this time, the module factory is not yet executed.
* Registers a callback to be called when a specific module is being initialized.
*
* This runs before the module factory is executed.
*
* @see {@link initializedModuleHasBadExports} to avoid bad module exports.
*

View file

@ -9,6 +9,9 @@
"./init": {
"default": "./src/init.ts"
},
"./preinit": {
"default": "./src/preinit.ts"
},
"./types": {
"default": "./src/types.ts"
},

View file

@ -1,6 +1,7 @@
import { Logger } from '@revenge-mod/discord/common'
import { allSettled, sleep } from '@revenge-mod/utils/promises'
import { _uapi } from './apis'
import { PluginFlags as Flag, PluginStatus as Status } from './constants'
import type {
@ -8,23 +9,23 @@ import type {
Plugin,
PluginApi,
PluginCleanup,
PluginFlags,
PluginLifecycles,
PluginManifest,
PreInitPluginApi,
} from './types'
const _plugins = new Map<PluginManifest['id'], InternalPlugin>()
const _metas = new Map<
export const _plugins = new Map<PluginManifest['id'], InternalPlugin>()
export const _metas = new Map<
PluginManifest['id'],
[api: InitPluginApi | PluginApi, promises: Promise<void>[], iflags: InternalPluginFlags]
[
api: PreInitPluginApi | InitPluginApi | PluginApi | undefined,
promises: Promise<void>[],
iflags: number,
apiLevel: number,
]
>()
export function registerPlugin(
manifest: PluginManifest,
lifecycles: PluginLifecycles,
flags: PluginFlags,
iflags: InternalPluginFlags,
) {
export function registerPlugin(manifest: PluginManifest, lifecycles: PluginLifecycles, flags: number, iflags: number) {
// TODO(plugins): verify plugin manifest
if (_plugins.has(manifest.id)) throw new Error(`Plugin with ID "${manifest.id}" already registered`)
@ -35,64 +36,124 @@ export function registerPlugin(
lifecycles,
status: 0,
flags,
async disable() {
// If plugin is not stopped, and is also not stopping, we need to stop it
if (plugin.status && !(plugin.status & Status.Stopping)) await stopPlugin(plugin)
plugin.flags &= ~Flag.Enabled
},
disable: () => disablePlugin(plugin),
stop: () => stopPlugin(plugin),
}
_metas.set(manifest.id, [
{
cleanup: (...items) => {
plugin.cleanups.push(...items)
},
get logger() {
return (this.logger = new Logger(`Revenge > ${manifest.name}`))
},
plugin,
unscoped: _uapi,
},
[],
iflags,
])
_metas.set(manifest.id, [undefined, [], iflags, PluginApiLevel.None])
_plugins.set(manifest.id, plugin)
}
function handlePluginError(e: unknown, plugin: InternalPlugin) {
plugin.errors.push(e)
plugin.flags |= Flag.Errored
if (plugin.flags & Flag.Enabled) return plugin.disable()
const [api, , iflags] = _metas.get(plugin.manifest.id)!
const log = (api as InitPluginApi).logger ?? console
log.error('Plugin encountered an error', e)
if (!(iflags & InternalPluginFlags.Essential)) return plugin.disable()
}
// function preparePluginStart(id: PluginManifest['id']) {
// const api = _metas.get(id)?.[1]!
// api.settings ??= ...
// }
function preparePluginPreInit(id: PluginManifest['id']) {
const plugin = _plugins.get(id)!
const meta = _metas.get(id)!
export function enablePlugin(plugin: InternalPlugin) {
// Clear errors from previous runs
plugin.errors = []
plugin.status &= ~Flag.Errored
meta[0] = {
cleanup: (...items) => {
plugin.cleanups.push(...items)
},
plugin,
unscoped: _uapi,
}
meta[3] = PluginApiLevel.PreInit
}
function preparePluginInit(id: PluginManifest['id']) {
const meta = _metas.get(id)!
const api = meta[0] as InitPluginApi
api.logger = new api.unscoped.discord.common.Logger(`Revenge > Plugins (${id})`)
meta[3] = PluginApiLevel.Init
}
function preparePluginStart(id: PluginManifest['id']) {
const meta = _metas.get(id)!
// const api = meta[0] as PluginApi
// api.settings = ...
meta[3] = PluginApiLevel.Start
}
async function disablePlugin(plugin: InternalPlugin) {
const iflags = _metas.get(plugin.manifest.id)?.[2] ?? 0
if (iflags & InternalPluginFlags.Essential)
throw new Error(`Plugin "${plugin.manifest.id}" is essential and cannot be disabled`)
// If plugin is not stopped, and is also not stopping, we need to stop it
if (plugin.status && !(plugin.status & Status.Stopping)) await stopPlugin(plugin)
// TODO(plugins): write to storage
plugin.flags &= ~Flag.Enabled
}
export function enablePlugin(plugin: InternalPlugin, late: boolean) {
// TODO(plugins): write to storage
plugin.flags |= Flag.Enabled
if (late) plugin.flags |= Flag.EnabledLate
}
export async function preInitPlugin(plugin: InternalPlugin) {
const { manifest, lifecycles } = plugin
const meta = _metas.get(manifest.id)!
const [, promises] = meta
preparePluginPreInit(manifest.id)
if (!(plugin.flags & Flag.Enabled)) throw new Error(`Plugin "${manifest.id}" is not enabled`)
if (plugin.status & (Status.PreIniting | Status.PreInited))
throw new Error(`Plugin preInit lifecycle for "${manifest.id}" is already running`)
plugin.status |= Status.PreIniting
try {
const prom = lifecycles.preInit?.(meta[0] as PreInitPluginApi)
promises.push(prom)
await prom
// plugin.disable() already handles, so it's in the try block
plugin.status |= Status.PreInited
plugin.status &= ~Status.PreIniting
} catch (e) {
await handlePluginError(e, plugin)
}
}
export async function initPlugin(plugin: InternalPlugin) {
const { manifest, lifecycles } = plugin
const [api, promises] = _metas.get(manifest.id)!
const meta = _metas.get(manifest.id)!
const [, promises, , apiLevel] = meta
if (!(plugin.flags & Flag.Enabled)) throw new Error(`Plugin "${manifest.id}" is not enabled`)
if (plugin.status & (Status.Initing | Status.Inited))
throw new Error(`Plugin "${manifest.id}" is initializing or already initialized`)
throw new Error(`Plugin init lifecycle for "${manifest.id}" is already running`)
plugin.status |= Status.Initing
if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(manifest.id)
if (apiLevel < PluginApiLevel.Init) preparePluginInit(manifest.id)
try {
const prom = lifecycles.init?.(api)
const prom = lifecycles.init?.(meta[0] as InitPluginApi)
promises.push(prom)
await prom
// plugin.disable() already handles, so it's the try block
// plugin.disable() already handles, so it's in the try block
plugin.status |= Status.Inited
plugin.status &= ~Status.Initing
} catch (e) {
@ -102,22 +163,21 @@ export async function initPlugin(plugin: InternalPlugin) {
export async function startPlugin(plugin: InternalPlugin) {
const { manifest, lifecycles } = plugin
const [api, promises] = _metas.get(manifest.id)!
const meta = _metas.get(manifest.id)!
const [, promises, , apiLevel] = meta
if (!(plugin.flags & Flag.Enabled)) throw new Error(`Plugin "${manifest.id}" is not enabled`)
if (plugin.status & (Status.Starting | Status.Started))
throw new Error(`Plugin "${manifest.id}" is starting or already started`)
// Clear errors from previous runs
plugin.errors = []
plugin.status &= ~Flag.Errored
throw new Error(`Plugin start lifecycle for "${manifest.id}" is already running`)
plugin.status |= Status.Starting
// preparePluginStart(manifest.id)
if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(manifest.id)
if (apiLevel < PluginApiLevel.Init) preparePluginInit(manifest.id)
if (apiLevel < PluginApiLevel.Start) preparePluginStart(manifest.id)
try {
const prom = lifecycles.start?.(api as PluginApi)
const prom = lifecycles.start?.(meta[0] as PluginApi)
promises.push(prom)
await prom
@ -129,31 +189,42 @@ export async function startPlugin(plugin: InternalPlugin) {
}
}
async function stopPlugin(plugin: InternalPlugin) {
const MaxLifecycleWaitTime = 5000
export async function stopPlugin(plugin: InternalPlugin) {
const { manifest, lifecycles } = plugin
const meta = _metas.get(manifest.id)!
const [api, promises] = meta
const [, promises, iflags, apiLevel] = meta
if (iflags & InternalPluginFlags.Essential)
throw new Error(`Plugin "${manifest.id}" is essential and cannot be stopped`)
if (!(plugin.flags & Flag.Enabled)) throw new Error(`Plugin "${manifest.id}" is not enabled`)
if (plugin.status & Status.Stopping) throw new Error(`Plugin "${manifest.id}" is stopping`)
// If the plugin is initializing or starting, we need to wait for it to finish, then we'll stop it
if (plugin.status & (Status.Initing | Status.Starting)) await Promise.all(promises)
if (plugin.status & (Status.Inited | Status.Started))
throw new Error(`Plugin "${manifest.id}" is not initialized started`)
// If the plugin is running its lifecycles, we need to wait for it to finish, then we'll stop it
// We want to wait at max 5 seconds for the lifecycles to finish
if (plugin.status & (Status.PreIniting | Status.Initing | Status.Starting))
await Promise.race([Promise.all(promises), sleep(MaxLifecycleWaitTime)]).then(finished => {
// If the lifecycles don't finish in 5 seconds, a reload is probably required to unapply the changes
if (!finished) plugin.flags |= Flag.ReloadRequired
})
else if (!(plugin.status & (Status.PreInited | Status.Inited | Status.Started)))
throw new Error(`Plugin "${manifest.id}" is not running`)
// In case the plugin only has init() and stop()
// preparePluginStart(manifest.id)
if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(manifest.id)
if (apiLevel < PluginApiLevel.Init) preparePluginInit(manifest.id)
if (apiLevel < PluginApiLevel.Start) preparePluginStart(manifest.id)
plugin.status |= Status.Stopping
try {
await lifecycles.stop?.(api as PluginApi)
await lifecycles.stop?.(meta[0] as PluginApi)
} catch (e) {
await handlePluginError(e, plugin)
} finally {
// Run cleanups
const results = await Promise.allSettled(plugin.cleanups.map(cleanup => cleanup()))
const results = await allSettled(plugin.cleanups.map(cleanup => cleanup()))
for (const result of results)
if (result.status === 'rejected') {
await handlePluginError(result.reason, plugin)
@ -161,33 +232,19 @@ async function stopPlugin(plugin: InternalPlugin) {
plugin.flags |= Flag.ReloadRequired
}
// Clear unnecessary data
// Reset APIs
meta[0] = undefined
meta[3] = PluginApiLevel.None
// Clear temp data
meta[1] = []
plugin.cleanups = []
// Reset status
plugin.status = 0
}
}
export function getEnabledPluginsCount() {
let count = 0
for (const plugin of _plugins.values()) if (plugin.flags & Flag.Enabled) count++
return count
}
export async function initPlugins() {
const promises: Promise<void>[] = []
for (const plugin of _plugins.values()) if (plugin.flags & Flag.Enabled) promises.push(initPlugin(plugin))
// These Promises never throw, so it is fine
await Promise.all(promises)
}
export async function startPlugins() {
const promises: Promise<void>[] = []
for (const plugin of _plugins.values()) if (plugin.flags & Flag.Enabled) promises.push(startPlugin(plugin))
// These Promises never throw, so it is fine
await Promise.all(promises)
}
export interface InternalPlugin extends Plugin {
cleanups: PluginCleanup[]
}
@ -203,4 +260,9 @@ export const InternalPluginFlags = {
Essential: 1 << 1,
}
export type InternalPluginFlags = (typeof InternalPluginFlags)[keyof typeof InternalPluginFlags]
const PluginApiLevel = {
None: 0,
PreInit: 1,
Init: 2,
Start: 3,
} as const

View file

@ -1,11 +1,8 @@
import * as PatcherLibrary from '@revenge-mod/patcher'
import type { UnscopedInitPluginApi, UnscopedPluginApi } from '../types'
import type { UnscopedInitPluginApi, UnscopedPluginApi, UnscopedPreInitPluginApi } from '../types'
export const _uapi: UnscopedInitPluginApi | UnscopedPluginApi = {
discord: require('./discord').discord,
externals: require('./externals').externals,
export const _uapi: UnscopedPreInitPluginApi | UnscopedInitPluginApi | UnscopedPluginApi = {
modules: require('./modules').modules,
plugins: require('./plugins').plugins,
patcher: PatcherLibrary,
react: require('./react').react,
}

View file

@ -13,6 +13,7 @@ export type PluginApiModulesMetro =
export type PluginApiModulesFinders =
// biome-ignore format: Don't
typeof import('@revenge-mod/modules/finders/get') &
typeof import('@revenge-mod/modules/finders/lookup') &
typeof import('@revenge-mod/modules/finders/wait') & {
filters: typeof import('@revenge-mod/modules/finders/filters')
@ -20,6 +21,7 @@ export type PluginApiModulesFinders =
export const modules: PluginApiModules = {
finders: {
...require('@revenge-mod/modules/finders/get'),
...require('@revenge-mod/modules/finders/lookup'),
...require('@revenge-mod/modules/finders/wait'),
filters: require('@revenge-mod/modules/finders/filters'),

View file

@ -1,8 +1,10 @@
export type PluginApiReact = typeof import('@revenge-mod/react') & {
jsxRuntime: typeof import('@revenge-mod/react/jsx-runtime')
native: typeof import('@revenge-mod/react/native')
}
export const react: PluginApiReact = {
...require('@revenge-mod/react'),
jsxRuntime: require('@revenge-mod/react/jsx-runtime'),
native: require('@revenge-mod/react/native'),
}

View file

@ -11,12 +11,19 @@ export const PluginFlags = {
* 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,
}
export const PluginStatus = {
Initing: 1 << 0,
Inited: 1 << 1,
Starting: 1 << 2,
Started: 1 << 3,
Stopping: 1 << 4,
PreIniting: 1 << 0,
PreInited: 1 << 1,
Initing: 1 << 2,
Inited: 1 << 3,
Starting: 1 << 4,
Started: 1 << 5,
Stopping: 1 << 6,
}

View file

@ -1,25 +1,32 @@
import * as AssetsLibrary from '@revenge-mod/assets'
// import * as UiLibrary from '@revenge-mod/ui'
import { onRunApplication } from '@revenge-mod/react/native'
import { after } from '@revenge-mod/patcher'
import { ReactNative } from '@revenge-mod/react'
import { initPlugins, startPlugins } from './_internal'
import { _uapi } from './apis'
import type { UnscopedPluginApi } from './types'
import * as AssetsLibrary from '@revenge-mod/assets'
import { discord } from './apis/discord'
import { externals } from './apis/externals'
import { react } from './apis/react'
// Setup non-limited APIs
const uapi = _uapi as UnscopedPluginApi
import { _plugins, initPlugin, startPlugin } from './_internal'
import { PluginFlags } from './constants'
import type { UnscopedInitPluginApi } from './types'
// Setup init plugin APIs
const uapi = _uapi as UnscopedInitPluginApi
uapi.assets = AssetsLibrary
// uapi.ui = UiLibrary
uapi.discord = discord
uapi.externals = externals
uapi.react = react
initPlugins()
for (const plugin of _plugins.values()) if (plugin.flags & PluginFlags.Enabled) initPlugin(plugin)
// TODO(plugins/init): move to @revenge-mod/react/native onRunApplicationCalled
const unpatch = after(ReactNative.AppRegistry, 'runApplication', ret => {
unpatch()
// Don't block rendering
setImmediate(startPlugins)
return ret
const unsub = onRunApplication(() => {
unsub()
// TODO(plugins/start): init start APIs
// const UILibrary = require('@revenge-mod/ui')
// uapi.ui = UiLibrary
for (const plugin of _plugins.values()) if (plugin.flags & PluginFlags.Enabled) startPlugin(plugin)
})

View file

@ -0,0 +1,4 @@
import { _plugins, preInitPlugin } from './_internal'
import { PluginFlags } from './constants'
for (const plugin of _plugins.values()) if (plugin.flags & PluginFlags.Enabled) preInitPlugin(plugin)

View file

@ -1,6 +1,6 @@
import type { DiscordModules } from '@revenge-mod/discord/types'
import type { PluginStatus, PluginFlags } from './constants'
import type { PluginFlags as PF, PluginStatus as PS } from './constants'
import type { DiscordModules } from '@revenge-mod/discord/types'
import type { PluginApiDiscord } from './apis/discord'
import type { PluginApiExternals } from './apis/externals'
@ -8,21 +8,26 @@ import type { PluginApiModules } from './apis/modules'
import type { PluginApiReact } from './apis/react'
import type { PluginApiPlugins } from './apis/plugins'
export type PluginFlags = (typeof PF)[keyof typeof PF]
export type PluginStatus = (typeof PS)[keyof typeof PS]
/**
* The unscoped plugin API (very limited). This API is available as a global for plugins.
* Available in the `preInit` phase.
*/
export interface UnscopedPreInitPluginApi {
modules: PluginApiModules
patcher: typeof import('@revenge-mod/patcher')
plugins: PluginApiPlugins
// utils: PluginApiUtils
}
/**
* The unscoped plugin API (limited). This API is available as a global for plugins.
* Available in the `init` phase.
*/
export interface UnscopedInitPluginApi {
export interface UnscopedInitPluginApi extends UnscopedPreInitPluginApi {
assets: typeof import('@revenge-mod/assets')
discord: PluginApiDiscord
externals: PluginApiExternals
modules: PluginApiModules
patcher: typeof import('@revenge-mod/patcher')
plugins: PluginApiPlugins
react: PluginApiReact
// utils: PluginApiUtils
}
/**
@ -30,22 +35,29 @@ export interface UnscopedInitPluginApi {
* Available in the `start` and `stop` phase.
*/
export interface UnscopedPluginApi extends UnscopedInitPluginApi {
assets: typeof import('@revenge-mod/assets')
// ui: typeof import('@revenge-mod/ui')
}
export type PluginCleanup = () => any
export type PluginCleanupApi = (...fns: PluginCleanup[]) => void
/**
* The plugin API (very limited).
* Available in the `preInit` phase.
*/
export interface PreInitPluginApi {
unscoped: UnscopedPreInitPluginApi
cleanup: PluginCleanupApi
plugin: Plugin
}
/**
* The plugin API (limited).
* Available in the `init` phase.
*/
export interface InitPluginApi {
plugin: Plugin
logger: InstanceType<DiscordModules.Logger>
export interface InitPluginApi extends PreInitPluginApi {
unscoped: UnscopedInitPluginApi
cleanup: PluginCleanupApi
logger: InstanceType<DiscordModules.Logger>
}
/**
@ -98,24 +110,29 @@ export interface PluginManifest {
export interface PluginLifecycles {
/**
* Runs immediately as soon as possible with limited APIs.
* Runs as soon as possible with very limited APIs.
* Before the index module (module 0)'s factory is run.
*
* @param api Plugin API (very limited).
*/
preInit?: (api: PreInitPluginApi) => any
/**
* Runs as soon as all important modules are initialized.
* After the index module (module 0)'s factory is run.
*
* @param api Plugin API (limited).
* @param context Plugin context.
*/
init?: (api: InitPluginApi) => any
/**
* Runs when the plugin can be started with all APIs available.
*
* @param api Plugin API.
* @param context Plugin context.
*/
start?: (api: PluginApi) => any
/**
* Runs when the plugin is stopped.
*
* @param api Plugin API.
* @param context Plugin context.
*/
stop?: (api: PluginApi) => any
}
@ -137,12 +154,14 @@ export interface Plugin {
/**
* The plugin flags.
* @see {@link PluginFlags}
*/
flags: PluginFlags
flags: number
/**
* The plugin status.
* @see {@link PluginStatus}
*/
status: PluginStatus
status: number
/**
* Errors encountered during the plugin lifecycles.
*/
@ -152,4 +171,8 @@ export interface Plugin {
* Disable the plugin. This will also stop the plugin if it is running.
*/
disable(): Promise<void>
/**
* Stop the plugin.
*/
stop(): Promise<void>
}

View file

@ -9,6 +9,9 @@
"./jsx-runtime": {
"default": "./src/jsx-runtime/index.ts"
},
"./native": {
"default": "./src/native/index.ts"
},
"./preinit": {
"default": "./src/preinit.ts"
},

View file

@ -1,11 +1,37 @@
import { byProps } from '@revenge-mod/modules/finders/filters'
import { lookupModuleId, lookupModuleIds } from '@revenge-mod/modules/finders/lookup'
import { yielded } from '@revenge-mod/utils/generators'
import { waitForModules } from '@revenge-mod/modules/finders/wait'
export const ReactModuleId = yielded(lookupModuleIds(byProps<typeof import('react')>('useState')), 1)!
export const ReactNativeModuleId = lookupModuleId(byProps<typeof import('react-native')>('AppRegistry'))!
export const ReactJsxRuntimeModuleId = yielded(lookupModuleIds(byProps<typeof import('react/jsx-runtime')>('jsx'))!, 1)
import type { Metro } from '@revenge-mod/modules/types'
export const React = __r(ReactModuleId) as typeof import('react')
export const ReactNative = __r(ReactNativeModuleId) as typeof import('react-native')
export const ReactJsxRuntime = __r(ReactJsxRuntimeModuleId) as typeof import('react/jsx-runtime')
export let ReactModuleId: Metro.ModuleID
export let ReactNativeModuleId: Metro.ModuleID
export let ReactJsxRuntimeModuleId: Metro.ModuleID
export let React: typeof import('react')
export let ReactNative: typeof import('react-native')
export let ReactJsxRuntime: typeof import('react/jsx-runtime')
let RtCount = 0
const unsubRt = waitForModules(byProps<typeof React>('useState'), (exports, id) => {
if (RtCount++ === 2) return unsubRt()
ReactModuleId = id
React = exports
})
const unsubRN = waitForModules(byProps<typeof ReactNative>('AppRegistry'), (exports, id) => {
unsubRN()
ReactNativeModuleId = id
ReactNative = exports
})
let RJsxRCount = 0
const unsubRJsxR = waitForModules(byProps<typeof ReactJsxRuntime>('jsxs'), (exports, id) => {
if (RJsxRCount++ === 2) return unsubRJsxR()
ReactJsxRuntimeModuleId = id
ReactJsxRuntime = exports
})

View file

@ -0,0 +1,27 @@
import { _ara, _bra } from './patches'
import type { AppRegistry } from 'react-native'
/**
* Registers a callback to be run when a call to {@link AppRegistry.runApplication} is made.
*
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
export function onRunApplication(callback: RunApplicationCallback) {
_bra.add(callback)
return () => _bra.delete(callback)
}
/**
* Registers a callback to be run when a call to {@link AppRegistry.runApplication} is finished.
*
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
export function onRunApplicationFinished(callback: RunApplicationCallback) {
_ara.add(callback)
return () => _ara.delete(callback)
}
export type RunApplicationCallback = () => any

View file

@ -0,0 +1,24 @@
import { after, before } from '@revenge-mod/patcher'
import { ReactNative } from '..'
import { waitForModules } from '@revenge-mod/modules/finders/wait'
import { byProps } from '@revenge-mod/modules/finders/filters'
import type { RunApplicationCallback } from '.'
export const _bra = new Set<RunApplicationCallback>()
export const _ara = new Set<RunApplicationCallback>()
const unsub = waitForModules(byProps('AppRegistry'), () => {
unsub()
before(ReactNative.AppRegistry, 'runApplication', args => {
for (const cb of _bra) cb()
return args
})
after(ReactNative.AppRegistry, 'runApplication', res => {
for (const cb of _ara) cb()
return res
})
})

View file

@ -1 +1,5 @@
// Initialize the wait immediately, so we don't miss the actual modules before the re-exports
import '.'
import './native/patches'
import './jsx-runtime/patches'

6
lib/storage/package.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "@revenge-mod/storage",
"private": true,
"type": "module",
"exports": {}
}

1
lib/storage/src/index.ts Normal file
View file

@ -0,0 +1 @@
// TODO(storage): Implement from NativeModules interop instead

View file

@ -8,3 +8,5 @@ export function debounce<F extends (...args: any[]) => any>(func: F, timeout: nu
}, timeout)
})
}
export const noopFalse = () => false

View file

@ -25,7 +25,7 @@ const MultiIconComponentFilterBase = [
// TODO(utils/discord/lookupGeneratedIconComponent): Make this into a filter?
export function lookupGeneratedIconComponent<N extends string>(compName: N, ...assetNames: string[]) {
const icon = lookupModule(byProps(compName))?.[compName]
const icon = lookupModule(byProps(compName))[0]?.[compName]
if (icon) return icon
let filter = IconComponentFilter
@ -51,5 +51,5 @@ export function lookupGeneratedIconComponent<N extends string>(compName: N, ...a
return lookupModule(byDependencies<{ [K in N]: FC }>(filter), {
includeInitialized: false,
includeUninitialized: true,
})?.[compName]
})[0]?.[compName]
}

View file

@ -1,21 +1,30 @@
export function allSettled(promises: Promise<any>[]): Promise<PromiseSettledResult<any>[]> {
const mapped = promises.map(p =>
p
?.then(
value =>
({
status: 'fulfilled',
value,
}) as const,
)
.catch(
reason =>
({
status: 'rejected',
reason,
}) as const,
),
p instanceof Promise
? p
.then(
value =>
({
status: 'fulfilled',
value,
}) as const,
)
.catch(
reason =>
({
status: 'rejected',
reason,
}) as const,
)
: ({
status: 'fulfilled',
value: p,
} as const),
)
return Promise.all(mapped)
}
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

View file

@ -0,0 +1,21 @@
import { FormSwitch as Switch } from '@revenge-mod/discord/design'
import { ReactNative } from '@revenge-mod/react'
import type { DiscordModules } from '@revenge-mod/discord/types'
/**
* A switch component that is styled to match Discord's configuration
*/
export default function FormSwitch(props: DiscordModules.Components.FormSwitchProps) {
return (
<ReactNative.View style={props.disabled && styles.disabled}>
<Switch {...props} />
</ReactNative.View>
)
}
const styles = ReactNative.StyleSheet.create({
disabled: {
opacity: 0.5,
},
})

View file

@ -1 +1,58 @@
import '@revenge-mod/init'
// Note that both Object.freeze and Proxy patches are implicitly run by requiring the two libraries below.
import { patchMetroDefine } from '@revenge-mod/modules/_/metro'
import { interceptProperty } from '@revenge-mod/utils/objects'
// By doing this, we are assuming __METRO_GLOBAL_PREFIX__ is an empty string and globalThis.__d isn't already set.
// But both should be true either way.
const unintercept = interceptProperty('__d', (_, mDefine) => (unintercept(), patchMetroDefine(mDefine)))
import { onModuleFirstRequired, onModuleInitialized } from '@revenge-mod/modules/metro/subscriptions'
import { getErrorStack } from '@revenge-mod/utils/errors'
const IndexModuleId = 0
onModuleFirstRequired(IndexModuleId, function onIndexRequired() {
runCatching(() => {
if (__BUILD_FLAG_LOG_PROMISE_REJECTIONS__) require('./patches/log-promise-rejections')
// Initialize preinit libraries
require('@revenge-mod/modules/preinit')
require('@revenge-mod/react/preinit')
require('@revenge-mod/assets/preinit')
require('@revenge-mod/discord/preinit')
// Run all preinit plugins
require('~/plugins/_preinit')
require('@revenge-mod/plugins/preinit')
onModuleInitialized(IndexModuleId, function onIndexInitialized() {
runCatching(() => {
// Initialize init libraries
// Run all init plugins
require('~/plugins/_all')
require('@revenge-mod/plugins/init')
})
})
})
})
function runCatching(fn: () => void) {
try {
fn()
} catch (e) {
// TODO(init): Move to use native provided alert function, which will accept a string stack trace
// Above will reduce the need for runCatching() to exist, as the code won't be able to be deduped any further
const { ClientInfoModule, DeviceModule } = require('@revenge-mod/discord/native') as
// biome-ignore format: Don't format this please
typeof import('@revenge-mod/discord/native')
alert(
// biome-ignore lint/style/useTemplate: I want this to be readable, thank you
`Failed to load Revenge (${__BUILD_VERSION__}-${__BUILD_COMMIT__}-${__BUILD_BRANCH__} (${__BUILD_ENV__}))\n` +
`Discord: ${ClientInfoModule.Version} (${ClientInfoModule.Build})\n` +
`Device: ${DeviceModule.deviceManufacturer} ${DeviceModule.deviceModel}\n\n` +
getErrorStack(e),
)
}
}

View file

@ -8,7 +8,7 @@
import { getErrorStack } from '@revenge-mod/utils/errors'
import { createLogger } from '@revenge-mod/utils/logger'
const logger = createLogger('revenge.init.promise-rejections')
const logger = createLogger('revenge.promise-rejections')
const ErrorTypeWhitelist = [ReferenceError, TypeError, RangeError]

View file

@ -1,4 +1,3 @@
import './logging'
import './staff-settings'
import './settings'
import './developer-settings'

2
src/plugins/_preinit.ts Normal file
View file

@ -0,0 +1,2 @@
import './logging'
import './no-track'

View file

@ -9,7 +9,7 @@ import { getErrorStack } from '@revenge-mod/utils/errors'
import TableRowAssetIcon from '~/components/TableRowAssetIcon'
import { pluginApi } from '..'
import { api } from '..'
import { MobileSetting } from '../constants'
import type { SettingsItem } from '@revenge-mod/discord/modules/settings'
@ -98,14 +98,14 @@ function EvaluateJavaScriptAlert() {
setLoading(true)
try {
if (!pluginApi) {
if (!api) {
alert('Unable to provide plugin API. Running snippet in a second...')
await new Promise(rs => setTimeout(rs, 1000))
} else {
// @ts-expect-error
globalThis.revenge = pluginApi.unscoped
globalThis.revenge = api.unscoped
// @ts-expect-error
globalThis.api = pluginApi
globalThis.api = api
}
// Do a no-local-scope-access eval

View file

@ -2,7 +2,7 @@ import { ToastActionCreators } from '@revenge-mod/discord/actions'
import { React } from '@revenge-mod/react'
import { getErrorStack } from '@revenge-mod/utils/errors'
import { useReRender } from '@revenge-mod/utils/react'
import { pluginApi } from '.'
import { api } from '.'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
export const DevToolsContext: {
@ -26,9 +26,7 @@ subscriptions.add((e, err) => {
if (e === 3) {
const actualError = (err as { message: string }).message ?? getErrorStack(err)
pluginApi.logger.error('React DevTools error:', actualError)
pluginApi.logger.log(CircleXIcon)
api.logger.error('React DevTools error:', actualError)
ToastActionCreators.open({
key: 'DEVTOOLS_ERROR',

View file

@ -2,7 +2,7 @@ import { PluginFlags } from '@revenge-mod/plugins/constants'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
import { _data } from '@revenge-mod/discord/_/modules/settings'
import { onceSettingsModulesLoaded } from '@revenge-mod/discord/modules/settings'
import { isSettingsModulesLoaded, onSettingsModulesLoaded } from '@revenge-mod/discord/modules/settings'
import type { PluginApi } from '@revenge-mod/plugins/types'
@ -15,9 +15,10 @@ registerPlugin(
icon: 'WrenchIcon',
},
{
start(api) {
pluginApi = api
onceSettingsModulesLoaded(() => require('./register'))
start(api_) {
api = api_
if (isSettingsModulesLoaded()) require('./register').register()
else onSettingsModulesLoaded(() => require('./register').register())
},
},
PluginFlags.Enabled,
@ -25,4 +26,4 @@ registerPlugin(
)
// Expose to EvaluateJavaScriptSetting
export let pluginApi: PluginApi
export let api: PluginApi

View file

@ -9,13 +9,18 @@ import ReactDevToolsVersionSetting from './definitions/ReactDevToolsVersionSetti
import RevengeDeveloperSetting from './definitions/RevengeDeveloperSetting'
import AssetBrowserSetting from './definitions/AssetBrowserSetting'
registerSettingsItems({
[MobileSetting.REVENGE_DEVELOPER]: RevengeDeveloperSetting,
[MobileSetting.REACT_DEVTOOLS_VERSION]: ReactDevToolsVersionSetting,
[MobileSetting.REACT_DEVTOOLS_CONNECT]: ReactDevToolsConnectSetting,
[MobileSetting.REACT_DEVTOOLS_DISCONNECT]: ReactDevToolsDisconnectSetting,
[MobileSetting.EVALUATE_JAVASCRIPT]: EvaluateJavaScriptSetting,
[MobileSetting.ASSET_BROWSER]: AssetBrowserSetting,
})
import { api } from '.'
addSettingsItemToSection('REVENGE', MobileSetting.REVENGE_DEVELOPER)
export function register() {
api.cleanup(
registerSettingsItems({
[MobileSetting.REVENGE_DEVELOPER]: RevengeDeveloperSetting,
[MobileSetting.REACT_DEVTOOLS_VERSION]: ReactDevToolsVersionSetting,
[MobileSetting.REACT_DEVTOOLS_CONNECT]: ReactDevToolsConnectSetting,
[MobileSetting.REACT_DEVTOOLS_DISCONNECT]: ReactDevToolsDisconnectSetting,
[MobileSetting.EVALUATE_JAVASCRIPT]: EvaluateJavaScriptSetting,
[MobileSetting.ASSET_BROWSER]: AssetBrowserSetting,
}),
addSettingsItemToSection('REVENGE', MobileSetting.REVENGE_DEVELOPER),
)
}

View file

@ -1,4 +1,4 @@
import { AppStartPerformance } from '@revenge-mod/discord/common'
import { AppStartPerformance } from '@revenge-mod/discord/preinit'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
@ -6,6 +6,7 @@ import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
AppStartPerformance.mark('👊', 'Plugins register')
const tsReg = performance.now()
let tsPreInit: number
let tsInit: number
registerPlugin(
@ -17,9 +18,13 @@ registerPlugin(
icon: 'PaperIcon',
},
{
preInit() {
tsPreInit = performance.now()
AppStartPerformance.mark('👊', 'Plugins preInit', tsPreInit - tsReg)
},
init() {
tsInit = performance.now()
AppStartPerformance.mark('👊', 'Plugins init', tsInit - tsReg)
AppStartPerformance.mark('👊', 'Plugins init', tsInit - tsPreInit)
},
start({ logger }) {
AppStartPerformance.mark('👊', 'Plugins start', performance.now() - tsInit)

View file

@ -0,0 +1,69 @@
import { byProps } from '@revenge-mod/modules/finders/filters'
import { waitForModules } from '@revenge-mod/modules/finders/wait'
import { instead } from '@revenge-mod/patcher'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
// TODO(plugins/no-track): Block Sentry native-side
registerPlugin(
{
id: 'revenge.no-track',
name: 'No Track',
description: "Disables Discord's analytics/tracking, and Sentry.",
author: 'Revenge',
icon: 'AnalyticsIcon',
},
{
preInit({ cleanup }) {
// modules/errors/native/SentryInitUtils.tsx
const unsubSIU = waitForModules(byProps('initSentry'), SentryInitUtils => {
unsubSIU()
console.log('Patching SentryInitUtils...')
cleanup(instead(SentryInitUtils, 'initSentry', () => {}))
cleanup(() => SentryInitUtils.initSentry())
})
},
init({ cleanup, logger }) {
// utils/AnalyticsUtils.tsx
const unsubAU = waitForModules(
byProps<{
trackNetworkAction: () => void
default: {
track: () => void
AnalyticsActionHandlers: Record<string, () => void>
}
}>('trackNetworkAction'),
AnalyticsUtils => {
unsubAU()
logger.info('Patching AnalyticsUtils...')
cleanup(instead(AnalyticsUtils.default, 'track', () => {}))
cleanup(instead(AnalyticsUtils, 'trackNetworkAction', () => {}))
for (const key in AnalyticsUtils.default.AnalyticsActionHandlers)
cleanup(instead(AnalyticsUtils.default.AnalyticsActionHandlers, key, () => {}))
},
)
// modules/app_analytics/useTrackImpression.tsx
const unsubTI = waitForModules(
byProps<{
default: () => void
trackImpression: () => void
}>('trackImpression'),
useTrackImpression => {
unsubTI()
logger.info('Patching useTrackImpression...')
cleanup(instead(useTrackImpression, 'trackImpression', () => {}))
cleanup(instead(useTrackImpression, 'default', () => {}))
},
)
},
},
PluginFlags.Enabled,
// TODO(plugins/no-track): is it essential?
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
)

View file

@ -1,4 +1,5 @@
import { getEnabledPluginsCount } from '@revenge-mod/plugins/_'
import { _plugins } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import TableRowAssetIcon from '~/components/TableRowAssetIcon'
@ -11,7 +12,11 @@ const RevengePluginsSetting: SettingsItem = {
type: 'route',
IconComponent: () => <TableRowAssetIcon name="PuzzlePieceIcon" />,
title: () => 'Plugins',
useTrailing: () => `${getEnabledPluginsCount()} enabled`,
useTrailing: () => {
let count = 0
for (const plugin of _plugins.values()) if (plugin.flags & PluginFlags.Enabled) count++
return `${count} enabled`
},
screen: {
route: UserSettingsSections[MobileSetting.REVENGE_PLUGINS],
getComponent: () => require('../screens/RevengePluginsSettingScreen').default,

View file

@ -5,7 +5,7 @@ import { byName, byProps } from '@revenge-mod/modules/finders/filters'
import { waitForModules } from '@revenge-mod/modules/finders/wait'
import { _data, _subs } from '@revenge-mod/discord/_/modules/settings'
import { type SettingsItem, onceSettingsModulesLoaded } from '@revenge-mod/discord/modules/settings'
import { type SettingsItem, onSettingsModulesLoaded } from '@revenge-mod/discord/modules/settings'
import { after } from '@revenge-mod/patcher'
@ -21,13 +21,13 @@ registerPlugin(
},
{
start({ logger }) {
onceSettingsModulesLoaded(() => require('./register'))
onSettingsModulesLoaded(() => require('./register'))
const unsubForRendererConfig = waitForModules(
byProps<{
SETTING_RENDERER_CONFIG: Record<string, SettingsItem>
}>('SETTING_RENDERER_CONFIG'),
(_, SettingRendererConfig) => {
SettingRendererConfig => {
unsubForRendererConfig()
logger.info('Settings modules loaded, running subscriptions and patching...')
@ -52,16 +52,22 @@ registerPlugin(
const unsubForSettingsOverviewScreen = waitForModules(
byName('SettingsOverviewScreen'),
(_, exports) => {
exports => {
unsubForSettingsOverviewScreen()
after(exports as { default: FC }, 'default', tree => {
const sections = (tree as Extract<typeof tree, { props: unknown }>).props.sections as Array<{
label: string
settings: string[]
}>
const customSections = _data[0]
const customSections = _data[0]
after(exports as { default: FC }, 'default', tree => {
const {
props: { sections },
} = tree as {
props: {
sections: Array<{
label: string
settings: string[]
}>
}
}
// Check if we even have custom sections
const firstCustomSection = customSections[Object.keys(customSections)[0]]

View file

@ -1,15 +1,154 @@
import { SettingListRenderer } from '@revenge-mod/discord/modules/settings/renderer'
import { Design } from '@revenge-mod/discord/design'
import { FlashList } from '@revenge-mod/externals/shopify'
import { React, ReactNative } from '@revenge-mod/react'
import {
_metas,
_plugins,
enablePlugin,
initPlugin,
InternalPluginFlags,
preInitPlugin,
startPlugin,
type InternalPlugin,
} from '@revenge-mod/plugins/_'
import { MobileSetting } from '../constants'
import FormSwitch from '~/components/FormSwitch'
import Page from '~/components/Page'
import SearchInput from '~/components/SearchInput'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { Tokens } from '@revenge-mod/discord/common'
import { getAssetByName, getAssetId } from '@revenge-mod/assets'
import { useReRender } from '@revenge-mod/utils/react'
const { Image, useWindowDimensions } = ReactNative
const { Card, Text, Stack } = Design
export default function RevengePluginsSettingScreen() {
const [search, setSearch] = React.useState('')
const { width } = useWindowDimensions()
const numColumns = Math.floor((width - 16) / 448)
const plugins = React.useMemo(
() => [..._plugins.values()].map(plugin => [plugin, _metas.get(plugin.manifest.id)![2]] as const),
[_plugins],
)
const filteredPlugins = React.useMemo(
() =>
plugins.filter(([plugin]) => {
const { name, description, author } = plugin.manifest
const query = search.toLowerCase()
return (
name.toLowerCase().includes(query) ||
description.toLowerCase().includes(query) ||
author.toLowerCase().includes(query)
)
}),
[plugins, search],
)
export default function RevengeFontsSettingScreen() {
return (
<SettingListRenderer.SettingsList
sections={[
{
settings: [MobileSetting.REVENGE_NOT_IMPLEMENTED],
},
]}
/>
<Page spacing={16}>
<SearchInput size="md" onChange={(v: string) => setSearch(v)} />
<FlashList.MasonryFlashList
data={filteredPlugins}
numColumns={numColumns}
estimatedItemSize={108}
renderItem={({ item: [plugin, iflags], columnIndex }) => (
<InstalledPluginCard
key={plugin.manifest.id}
plugin={plugin}
iflags={iflags}
rightGap={columnIndex + 1 < numColumns}
/>
)}
/>
</Page>
)
}
const usePluginCardStyles = Design.createStyles({
icon: {
width: 20,
height: 20,
tintColor: Tokens.default.colors.TEXT_NORMAL,
},
card: {
flexGrow: 1,
marginBottom: 12,
paddingVertical: 12,
paddingHorizontal: 12,
gap: 4,
},
rightGap: {
marginRight: 12,
},
topContainer: {
alignItems: 'center',
},
alignedContainer: {
paddingLeft: 28,
},
grow: {
flexGrow: 1,
},
autoSize: {
flex: 1,
},
})
function InstalledPluginCard({
plugin,
iflags,
rightGap,
}: { plugin: InternalPlugin; iflags: number; rightGap?: boolean }) {
const {
manifest: { name, description, author, icon },
flags,
} = plugin
const reRender = useReRender()
const essential = Boolean(iflags & InternalPluginFlags.Essential)
const enabled = Boolean(flags & PluginFlags.Enabled)
const styles = usePluginCardStyles()
return (
<Card style={[styles.card, rightGap && styles.rightGap]}>
<Stack direction="horizontal" style={[styles.grow, styles.topContainer]}>
<Stack spacing={8} direction="horizontal" style={[styles.topContainer, styles.autoSize]}>
<Image source={getAssetId(getAssetByName(icon ?? 'PuzzlePieceIcon')!)!} style={styles.icon} />
<Text variant="heading-lg/semibold">{name}</Text>
</Stack>
<FormSwitch
disabled={essential}
value={enabled}
// biome-ignore lint/complexity/useArrowFunction: Async arrows are not supported
onValueChange={async function (v) {
if (v) {
await enablePlugin(plugin, true)
await preInitPlugin(plugin)
await initPlugin(plugin)
await startPlugin(plugin)
} else await plugin.disable()
reRender()
// TODO(plugins/settings): show ReloadRequired modal
// make an event based system for this, so we can register a listener for when plugins are disabled
// and check its flags afterwards
}}
/>
</Stack>
<Stack spacing={4} style={[styles.alignedContainer, styles.grow]}>
<Text style={styles.grow} variant="heading-md/medium" color="text-muted">
by {author}
</Text>
<Text style={styles.grow} variant="text-md/medium">
{description}
</Text>
</Stack>
</Card>
)
}

View file

@ -1,7 +1,7 @@
import { byProps } from '@revenge-mod/modules/finders/filters'
import { waitForModules } from '@revenge-mod/modules/finders/wait'
import { after } from '@revenge-mod/patcher'
import { instead } from '@revenge-mod/patcher'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
@ -16,10 +16,18 @@ registerPlugin(
},
{
init({ cleanup }) {
const unsub = waitForModules(byProps('isStaffEnv'), (_, UserStoreUtils) => {
const unsub = waitForModules(byProps('isStaffEnv'), UserStoreUtils => {
unsub()
cleanup(after(UserStoreUtils, 'isStaffEnv', () => true))
cleanup(instead(UserStoreUtils, 'isStaffEnv', () => true))
})
cleanup(unsub)
},
start({ plugin }) {
if (plugin.flags & PluginFlags.EnabledLate) plugin.stop()
},
stop({ plugin }) {
plugin.flags |= PluginFlags.ReloadRequired
},
},
PluginFlags.Enabled,