feat: native bridge (#4)

* feat(lib/modules/native): types for calls

* feat(lib): proper caching for `assets` and `modules` lib

* fix(lib/modules/metro/runtime): add flag once `importedAll` or `importedDefault` exports cached

* feat(lib/modules/native): add `fs`

* feat(plugins/settings): add Loader info in Revenge page

* feat: use native bridge for alerting errors

* chore(lib/modules/native): remove useless `String()` call on error

* feat(lib/modules/native/fs): rename `deleteFile` to `rm`

* feat(lib/plugins): save plugin states

* chore(preinit): correct arg name for native method
This commit is contained in:
Palm 2025-09-16 01:06:04 +07:00 • committed by GitHub
commit f89d523c0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 317 additions and 171 deletions

View file

@ -1,51 +1,50 @@
// TODO(lib/assets): do not depend on Discord's ClientInfoModule for versioning, use native interop import {
import { ClientInfoModule } from '@revenge-mod/discord/native' callBridgeMethod,
import { getStorage } from '@revenge-mod/storage' callBridgeMethodSync,
} from '@revenge-mod/modules/native'
import { debounce } from '@revenge-mod/utils/callback' import { debounce } from '@revenge-mod/utils/callback'
import { mergeDeep } from '@revenge-mod/utils/object'
import type { Metro } from '@revenge-mod/modules/types' import type { Metro } from '@revenge-mod/modules/types'
import type { Asset } from './types' import type { Asset } from './types'
const Version = 1 const ExpectedCacheVersion = 2
const Key = `${Version}.${ClientInfoModule.getConstants().Build}`
export const Uncached: Cache = {
data: {},
version: ExpectedCacheVersion,
}
// In-memory cache // In-memory cache
export const cache: Cache = {} export let cache: Cache =
callBridgeMethodSync('revenge.caches.assets.read', []) ?? Uncached
const CacheStorage = getStorage<Cache>(`revenge/assets.${Key}`, { if (cache.version !== ExpectedCacheVersion) {
default: cache, // TODO: Alert to user to update build
directory: 'cache', cache = Uncached
}) }
// TODO(lib/assets/caches): This loads way too late and requires native interop to load earlier
export const cached = CacheStorage.get().then(cache_ => {
const cached = cache_ !== cache
// If we are not using the default value, merge it into the in-memory cache, then point the storage cache to the in-memory cache.
if (cached) {
Object.assign(cache, cache_)
CacheStorage.cache = cache
}
return cached
})
export interface Cache { export interface Cache {
[key: Asset['name']]: { data: {
[key: Asset['type']]: Metro.ModuleID [key: Asset['name']]: {
[key: Asset['type']]: Metro.ModuleID
}
} }
version: number
} }
const save = debounce(() => { const save = debounce(() => {
CacheStorage.set({}) callBridgeMethod('revenge.caches.assets.write', [cache.data])
}, 1000) }, 1000)
export function cacheAsset(asset: Asset, moduleId: Metro.ModuleID) { export function cacheAsset(asset: Asset, moduleId: Metro.ModuleID) {
// Merge directly into the cache, only debouncing actual writes const reg = (cache.data[asset.name] ??= {})
mergeDeep(cache, { reg[asset.type] = moduleId
[asset.name]: {
[asset.type]: moduleId,
},
})
save() save()
} }
declare module '@revenge-mod/modules/native' {
interface Methods {
'revenge.caches.assets.read': [[], Cache | null]
'revenge.caches.assets.write': [[data: Cache['data']], void]
}
}

View file

@ -48,7 +48,7 @@ export function* getCustomAssets(): Generator<CustomAsset> {
* Yields all registered packager assets, including ones with same name but different types. * Yields all registered packager assets, including ones with same name but different types.
*/ */
export function* getPackagerAssets(): Generator<PackagerAsset> { export function* getPackagerAssets(): Generator<PackagerAsset> {
for (const reg of Object.values(cache)) for (const reg of Object.values(cache.data))
for (const moduleId of Object.values(reg)) for (const moduleId of Object.values(reg))
yield AssetsRegistry.getAssetByID(metroRequire(moduleId)) yield AssetsRegistry.getAssetByID(metroRequire(moduleId))
} }
@ -77,7 +77,7 @@ export function getAssetByName(
export function getAssetsByName( export function getAssetsByName(
name: string, name: string,
): Record<Asset['type'], Asset> | undefined { ): Record<Asset['type'], Asset> | undefined {
const reg = cache[name] const reg = cache.data[name]
if (!reg) return if (!reg) return
return Object.entries(reg).reduce( return Object.entries(reg).reduce(
@ -106,7 +106,7 @@ export function getAssetIdByName(
name: string, name: string,
type?: Asset['type'], type?: Asset['type'],
): AssetId | undefined { ): AssetId | undefined {
const reg = cache[name] const reg = cache.data[name]
if (!reg) return if (!reg) return
if (type !== undefined) { if (type !== undefined) {
@ -127,7 +127,7 @@ export function getAssetIdByName(
* @returns The asset ID. * @returns The asset ID.
*/ */
export function registerAsset(asset: RegisterableAsset): AssetId { export function registerAsset(asset: RegisterableAsset): AssetId {
if (cache[asset.name]?.[asset.type] !== undefined) if (cache.data[asset.name]?.[asset.type] !== undefined)
throw new Error( throw new Error(
`Asset with name ${asset.name} and type ${asset.type} already exists!`, `Asset with name ${asset.name} and type ${asset.type} already exists!`,
) )

View file

@ -15,7 +15,7 @@ import {
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils' import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
import { proxify } from '@revenge-mod/utils/proxy' import { proxify } from '@revenge-mod/utils/proxy'
import { aOverrides } from './_internal' import { aOverrides } from './_internal'
import { cacheAsset, cached } from './caches' import { cache, cacheAsset, Uncached } from './caches'
import type { Metro } from '@revenge-mod/modules/types' import type { Metro } from '@revenge-mod/modules/types'
import type { ReactNative } from '@revenge-mod/react/types' import type { ReactNative } from '@revenge-mod/react/types'
import type { Asset, PackagerAsset } from './types' import type { Asset, PackagerAsset } from './types'
@ -52,24 +52,20 @@ const unsubAR = waitForModules(
if (getModuleDependencies(id)!.length) { if (getModuleDependencies(id)!.length) {
unsubAR() unsubAR()
// TODO(lib/assets/caches): Ideally we should not wait for the cache promise here, see the promise impl for more info. if (cache === Uncached) {
cached.then(cached => { // Resolve reference once and keep in closure
if (!cached) { const metroRequire = __r
// Resolve reference once and keep in closure
const metroRequire = __r
// More fragile way, but also more performant: // More fragile way, but also more performant:
// There is exactly one asset before the reexported asset registry :/ // There is exactly one asset before the reexported asset registry :/
const firstAssetModuleId = id - 1 const firstAssetModuleId = id - 1
for (const mId of mUninitialized) { for (const mId of mUninitialized) {
if (mId < firstAssetModuleId) continue if (mId < firstAssetModuleId) continue
const deps = getModuleDependencies(mId)! const deps = getModuleDependencies(mId)!
if (deps.length === 1 && deps[0] === id) if (deps.length === 1 && deps[0] === id) metroRequire(mId)
metroRequire(mId)
}
} }
}) }
// We already patched the original asset registry, so we don't need to patch again. // We already patched the original asset registry, so we don't need to patch again.
return return

View file

@ -9,7 +9,7 @@ import {
} from '@revenge-mod/modules/finders/filters' } from '@revenge-mod/modules/finders/filters'
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils' import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
import { asap, noop } from '@revenge-mod/utils/callback' import { asap, noop } from '@revenge-mod/utils/callback'
import { cached, cacheFilterResultForId } from '#modules/src/caches' import { cache, cacheFilterResultForId, Uncached } from '#modules/src/caches'
import { FilterResultFlags } from '#modules/src/finders/_internal' import { FilterResultFlags } from '#modules/src/finders/_internal'
import { FilterFlag } from '#modules/src/finders/filters/constants' import { FilterFlag } from '#modules/src/finders/filters/constants'
import type { import type {
@ -140,13 +140,11 @@ waitForModules(withStore(), (store, id) => {
Stores[name] = store Stores[name] = store
}) })
cached.then(cached => { if (cache === Uncached)
if (!cached) asap(() => {
asap(() => { const lookup = lookupModules(withStore(), {
const lookup = lookupModules(withStore(), { uninitialized: true,
uninitialized: true,
})
while (lookup.next().done);
}) })
})
while (lookup.next().done);
})

View file

@ -19,7 +19,10 @@
"default": "./src/metro/utils.ts" "default": "./src/metro/utils.ts"
}, },
"./native": { "./native": {
"default": "./src/native.ts" "default": "./src/native/index.ts"
},
"./native/fs": {
"default": "./src/native/fs.ts"
}, },
"./types": { "./types": {
"types": "./src/types.ts" "types": "./src/types.ts"

View file

@ -1,59 +1,61 @@
// TODO(lib/modules/caches): do not depend on Discord's ClientInfoModule for versioning, use native interop import {
import { ClientInfoModule } from '@revenge-mod/discord/native' callBridgeMethod,
import { getStorage } from '@revenge-mod/storage' callBridgeMethodSync,
} from '@revenge-mod/modules/native'
import { debounce } from '@revenge-mod/utils/callback' import { debounce } from '@revenge-mod/utils/callback'
import type { Metro } from '@revenge-mod/modules/types' import type { Metro } from '@revenge-mod/modules/types'
import type { FilterResultFlag } from './finders/_internal' import type { FilterResultFlag } from './finders/_internal'
import type { Filter } from './finders/filters' import type { Filter } from './finders/filters'
const Version = 2 const ExpectedCacheVersion = 3
const Key = `${Version}.${ClientInfoModule.getConstants().Build}`
export const Uncached: Cache = {
blacklist: [],
finds: {},
version: ExpectedCacheVersion,
}
// In-memory cache // In-memory cache
const cache: Cache = { b: [], f: {} } export let cache: Cache =
callBridgeMethodSync('revenge.caches.modules.read', []) ?? Uncached
const CacheStorage = getStorage<Cache>(`revenge/modules.${Key}`, { if (cache.version !== ExpectedCacheVersion) {
default: cache, // TODO: Alert to user to update build
directory: 'cache', cache = Uncached
}) }
// TODO(lib/modules/caches): This loads way too late and requires native interop to load earlier export type Blacklist = Metro.ModuleID[]
export const cached = CacheStorage.get().then(cache_ => { export type Finds = Record<
const cached = cache_ !== cache Filter['key'],
// If we are not using the default value, merge it into the in-memory cache, then point the storage cache to the in-memory cache. Record<Metro.ModuleID, FilterResultFlag> | null
if (cached) { >
Object.assign(cache, cache_)
CacheStorage.cache = cache
}
return cached
})
export interface Cache { export interface Cache {
b: Metro.ModuleID[] blacklist: Blacklist
f: Record< finds: Finds
Filter['key'], version: number
Record<Metro.ModuleID, FilterResultFlag> | null | undefined
>
} }
const save = debounce(() => { const save = debounce(() => {
CacheStorage.set({}) callBridgeMethod('revenge.caches.modules.write', [
cache.blacklist,
cache.finds,
])
}, 1000) }, 1000)
export function cacheBlacklistedModule(id: Metro.ModuleID): boolean { export function cacheBlacklistedModule(id: Metro.ModuleID): boolean {
cache.b.push(id) cache.blacklist.push(id)
save() save()
return true return true
} }
export function cacheFilterResultForId( export function cacheFilterResultForId(
key: Filter['key'], key: keyof Finds,
id: Metro.ModuleID, id: Metro.ModuleID,
flag: FilterResultFlag, flag: FilterResultFlag,
): FilterResultFlag { ): FilterResultFlag {
const reg = (cache.f[key] ??= {}) const reg = (cache.finds[key] ??= {})
reg[id] = flag reg[id] = flag
save() save()
@ -61,12 +63,22 @@ export function cacheFilterResultForId(
return flag return flag
} }
export function cacheFilterNotFound(key: Filter['key']) { export function cacheFilterNotFound(key: keyof Finds) {
cache.f[key] = null cache.finds[key] = null
} }
export const getCachedFilterRegistry = ( export const getFilterMatches = (
key: Filter['key'], key: keyof Finds,
): Cache['f'][Filter['key']] | undefined => cache.f[key] ): Finds[keyof Finds] | undefined => cache.finds[key]
export const getCachedBlacklistedModules = () => cache.b export const getBlacklistedModules = () => cache.blacklist
declare module '@revenge-mod/modules/native' {
interface Methods {
'revenge.caches.modules.read': [[], Cache | null]
'revenge.caches.modules.write': [
[blacklist: Blacklist, finds: Finds],
void,
]
}
}

View file

@ -1,7 +1,7 @@
import { getCurrentStack } from '@revenge-mod/utils/error' import { getCurrentStack } from '@revenge-mod/utils/error'
import { cacheFilterResultForId } from '../caches' import { cacheFilterResultForId } from '../caches'
import { metroRequire } from '../metro/custom'
import { mInitialized } from '../metro/patches' import { mInitialized } from '../metro/patches'
import { metroRequire } from '../metro/runtime'
import { isModuleExportBad } from '../metro/utils' import { isModuleExportBad } from '../metro/utils'
import { FilterFlag } from './filters' import { FilterFlag } from './filters'
import type { If } from '@revenge-mod/utils/types' import type { If } from '@revenge-mod/utils/types'

View file

@ -1,13 +1,13 @@
import { getCurrentStack } from '@revenge-mod/utils/error' import { getCurrentStack } from '@revenge-mod/utils/error'
import { proxify } from '@revenge-mod/utils/proxy' import { proxify } from '@revenge-mod/utils/proxy'
import { cacheFilterNotFound, getCachedFilterRegistry } from '../caches' import { cacheFilterNotFound, getFilterMatches } from '../caches'
import { metroRequire } from '../metro/custom'
import { import {
mImportedPaths, mImportedPaths,
mInitialized, mInitialized,
mList, mList,
mUninitialized, mUninitialized,
} from '../metro/patches' } from '../metro/patches'
import { metroRequire } from '../metro/runtime'
import { import {
getInitializedModuleExports, getInitializedModuleExports,
isModuleInitialized, isModuleInitialized,
@ -163,7 +163,7 @@ export function* lookupModules(filter: Filter, options?: LookupModulesOptions) {
if (options?.cached ?? true) { if (options?.cached ?? true) {
const notInit = !(options?.initialize ?? true) const notInit = !(options?.initialize ?? true)
const reg = getCachedFilterRegistry(filter.key) const reg = getFilterMatches(filter.key)
// Return early if previous lookup was a full lookup and no modules were found // Return early if previous lookup was a full lookup and no modules were found
if (reg === null) return if (reg === null) return
@ -307,7 +307,7 @@ export function lookupModule(filter: Filter, options?: LookupModulesOptions) {
if (options?.cached ?? true) { if (options?.cached ?? true) {
const notInit = !(options?.initialize ?? true) const notInit = !(options?.initialize ?? true)
const reg = getCachedFilterRegistry(filter.key) const reg = getFilterMatches(filter.key)
// Return early if previous lookup was a full lookup and no modules were found // Return early if previous lookup was a full lookup and no modules were found
if (reg === null) return NotFoundResult if (reg === null) return NotFoundResult

View file

@ -1,5 +1,5 @@
import { noop } from '@revenge-mod/utils/callback' import { noop } from '@revenge-mod/utils/callback'
import { getCachedFilterRegistry } from '../caches' import { getFilterMatches } from '../caches'
import { mInitialized } from '../metro/patches' import { mInitialized } from '../metro/patches'
import { import {
onAnyModuleInitialized, onAnyModuleInitialized,
@ -97,13 +97,13 @@ export function waitForModules(
options?: WaitForModulesOptions, options?: WaitForModulesOptions,
): WaitForModulesUnsubscribeFunction { ): WaitForModulesUnsubscribeFunction {
if (options?.cached) { if (options?.cached) {
const reg = getCachedFilterRegistry(filter.key) const reg = getFilterMatches(filter.key)
if (reg === null) return noop if (reg === null) return noop
if (reg) { if (reg) {
if (__BUILD_FLAG_DEBUG_MODULE_WAITS__) if (__BUILD_FLAG_DEBUG_MODULE_WAITS__)
nativeLoggingHook( nativeLoggingHook(
`\u001b[32mUsing cached results for wait \u001b[33m${filter.key}\u001b[0m`, `\u001b[32mUsing cached results for wait: \u001b[33m${filter.key}\u001b[0m`,
1, 1,
) )

View file

@ -1,14 +1,10 @@
import { import { cache, cacheBlacklistedModule, Uncached } from '../caches'
cacheBlacklistedModule,
cached,
getCachedBlacklistedModules,
} from '../caches'
import { import {
global, global,
metroImportAll, metroImportAll,
metroImportDefault, metroImportDefault,
metroRequire, metroRequire,
} from './custom' } from './runtime'
import { onModuleInitialized } from './subscriptions' import { onModuleInitialized } from './subscriptions'
import { import {
executeImportedPathSubscriptions, executeImportedPathSubscriptions,
@ -144,11 +140,8 @@ function handleFactoryCall(
/// MODULE PATCHES AND BLACKLISTS /// MODULE PATCHES AND BLACKLISTS
// Restore blacklists // Restore blacklists
cached.then(cached => { if (cache !== Uncached)
if (cached) for (const id of cache.blacklist) mUninitialized.delete(id)
for (const id of getCachedBlacklistedModules())
mUninitialized.delete(id)
})
const ImportTrackerModuleId = 2 const ImportTrackerModuleId = 2

View file

@ -1,6 +1,8 @@
/** /**
* A more performant implementation of Metro's core functions. * A minimal implementation of Metro's runtime with little overhead.
* Making initialization faster and use less resources. * Making initialization faster and use less resources.
*
* Also avoids cloning exports, allowing for patches to be applied directly without checking for clones.
*/ */
import { mList } from './patches' import { mList } from './patches'
@ -58,6 +60,9 @@ export const metroImportDefault: Metro.RequireFn = moduleId => {
if (mod.flags & HasImportedDefault) return mod.importedDefault if (mod.flags & HasImportedDefault) return mod.importedDefault
const exports = metroRequire(moduleId) const exports = metroRequire(moduleId)
mod.flags |= HasImportedDefault
return (mod.importedDefault = exports?.__esModule return (mod.importedDefault = exports?.__esModule
? exports.default ? exports.default
: exports) : exports)
@ -68,7 +73,11 @@ export const metroImportAll: Metro.RequireFn = moduleId => {
if (mod.flags & HasImportedAll) return mod.importedAll if (mod.flags & HasImportedAll) return mod.importedAll
const exports = metroRequire(moduleId) const exports = metroRequire(moduleId)
// Our implementation doesn't match Metro's because we modify the exports directly instead of cloning
// But this is why ours is superior, it allows patching the exports without needing to do it more than a single time
if (!exports?.__esModule) exports.default = exports if (!exports?.__esModule) exports.default = exports
mod.flags |= HasImportedAll
return (mod.importedAll = exports) return (mod.importedAll = exports)
} }

View file

@ -1,6 +1,6 @@
import { isProxy } from '@revenge-mod/utils/proxy' import { isProxy } from '@revenge-mod/utils/proxy'
import { Initialized } from './custom'
import { mDeps, mList } from './patches' import { mDeps, mList } from './patches'
import { Initialized } from './runtime'
import type { Metro } from '../types' import type { Metro } from '../types'
/** /**

View file

@ -0,0 +1,45 @@
import {
callBridgeMethod,
callBridgeMethodSync,
} from '@revenge-mod/modules/native'
export function readFile(path: string) {
return callBridgeMethod('revenge.fs.read', [path])
}
export function writeFile(path: string, data: string) {
return callBridgeMethod('revenge.fs.write', [path, data])
}
export function exists(path: string) {
return callBridgeMethod('revenge.fs.exists', [path])
}
export function rm(path: string) {
return callBridgeMethod('revenge.fs.delete', [path])
}
export function readFileSync(path: string) {
return callBridgeMethodSync('revenge.fs.read', [path])
}
export function writeFileSync(path: string, data: string) {
return callBridgeMethodSync('revenge.fs.write', [path, data])
}
export function rmSync(path: string) {
return callBridgeMethodSync('revenge.fs.exists', [path])
}
export function deleteFileSync(path: string) {
return callBridgeMethodSync('revenge.fs.delete', [path])
}
declare module '@revenge-mod/modules/native' {
export interface Methods {
'revenge.fs.read': [[path: string], string]
'revenge.fs.write': [[path: string, data: string], void]
'revenge.fs.exists': [[path: string], boolean]
'revenge.fs.delete': [[path: string], boolean]
}
}

View file

@ -45,16 +45,22 @@ function makePayload(name: string, args: any[]): object {
* @param args The arguments to pass to the native method. * @param args The arguments to pass to the native method.
* @returns A promise that resolves with the result of the native method call. * @returns A promise that resolves with the result of the native method call.
*/ */
export async function callMethod<T>(name: string, args: any[]): Promise<T> { export async function callBridgeMethod<N extends MethodName>(
const result = await BridgePromise.readAsDataURL(makePayload(name, args)) name: N,
args: MethodArgs<N>,
): Promise<MethodResult<N>> {
try {
const result = await BridgePromise.readAsDataURL(
makePayload(name, args),
)
if ('error' in result) if ('error' in result) throw result.error
throw new Error(`Call failed: ${result.error as string}`) if ('result' in result) return result.result as MethodResult<N>
if ('result' in result) return result.result as T
throw new Error( throw 'The module did not return a valid result. The native hook must have failed.'
'Call failed: The module did not return a valid result. The native hook must have failed.', } catch (error) {
) throw new Error(`Call failed: ${error}`)
}
} }
/** /**
@ -66,17 +72,46 @@ export async function callMethod<T>(name: string, args: any[]): Promise<T> {
* @param args The arguments to pass to the native method. * @param args The arguments to pass to the native method.
* @returns The result of the native method call. * @returns The result of the native method call.
*/ */
export function callMethodSync<T>(name: string, args: any[]): T { export function callBridgeMethodSync<N extends MethodName>(
name: N,
args: MethodArgs<N>,
): MethodResult<N> {
try { try {
const result = Bridge.getBBox(0, makePayload(name, args)) const result = Bridge.getBBox(0, makePayload(name, args))
if ('error' in result) throw result.error if ('error' in result) throw result.error
if ('result' in result) return result.result as T if ('result' in result) return result.result as MethodResult<N>
throw 'The module did not return a valid result. The native hook must have failed.' throw 'The module did not return a valid result. The native hook must have failed.'
} catch (error) { } catch (error) {
throw new Error( throw new Error(`Call failed: ${error}`)
`Call failed: ${error instanceof Error ? error.message : String(error)}`,
)
} }
} }
/**
* Get the bridge information.
*/
export function getBridgeInfo(): BridgeInfo | null {
try {
return callBridgeMethodSync('revenge.info', [])
} catch (e) {
nativeLoggingHook(
`\u001b[31mFailed to get native bridge info: ${e}\u001b[0m`,
2,
)
return null
}
}
export interface BridgeInfo {
name: string
version: number
}
export type MethodName = Extract<keyof Methods, string>
export type MethodArgs<T extends MethodName> = Methods[T][0]
export type MethodResult<T extends MethodName> = Methods[T][1]
export interface Methods {
'revenge.info': [[], BridgeInfo]
}

View file

@ -1,8 +1,16 @@
import { TypedEventEmitter } from '@revenge-mod/discord/common/utils' import { TypedEventEmitter } from '@revenge-mod/discord/common/utils'
import {
callBridgeMethod,
callBridgeMethodSync,
} from '@revenge-mod/modules/native'
import { getErrorStack } from '@revenge-mod/utils/error' import { getErrorStack } from '@revenge-mod/utils/error'
import { sleepReject } from '@revenge-mod/utils/promise' import { sleepReject } from '@revenge-mod/utils/promise'
import { pUnscopedApi as uapi } from '../apis' import { pUnscopedApi as uapi } from '../apis'
import { PluginFlags as Flag, PluginStatus as Status } from '../constants' import {
PluginFlags as Flag,
PersistentPluginFlags,
PluginStatus as Status,
} from '../constants'
import { import {
addPluginApiDecorator, addPluginApiDecorator,
decoratePluginApi, decoratePluginApi,
@ -83,13 +91,30 @@ export const pEmitter = new TypedEventEmitter<{
export const pList = new Map<PluginManifest['id'], AnyPlugin>() export const pList = new Map<PluginManifest['id'], AnyPlugin>()
export const pMetadata = new WeakMap<AnyPlugin, InternalPluginMeta>() export const pMetadata = new WeakMap<AnyPlugin, InternalPluginMeta>()
const { flags: PersistedFlags }: PersistedPluginStates = callBridgeMethodSync(
'revenge.plugins.states.read',
[],
) ?? {
flags: {},
}
pEmitter.on('flagUpdate', plugin => {
PersistedFlags[plugin.manifest.id] = plugin.flags & PersistentPluginFlags
callBridgeMethod('revenge.plugins.states.write', [PersistedFlags])
})
/** /**
* Registers a new plugin with the system. * Registers a new plugin with the system.
*
* @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<O extends PluginApiExtensionsOptions>( export function registerPlugin<O extends PluginApiExtensionsOptions>(
manifest: PluginManifest, manifest: PluginManifest,
options: PluginOptions<O>, options: PluginOptions<O>,
flags: number, defflags: number,
iflags: number, iflags: number,
) { ) {
// TODO(plugins): verify plugin manifest // TODO(plugins): verify plugin manifest
@ -127,7 +152,7 @@ export function registerPlugin<O extends PluginApiExtensionsOptions>(
dependents: [], dependents: [],
handleError: e => handlePluginError(e, plugin), handleError: e => handlePluginError(e, plugin),
options, options,
flags, flags: PersistedFlags[manifest.id] ?? defflags,
} }
pMetadata.set(plugin, meta) pMetadata.set(plugin, meta)
@ -299,7 +324,6 @@ export async function disablePlugin(plugin: AnyPlugin) {
if (plugin.status && !(plugin.status & Status.Stopping)) if (plugin.status && !(plugin.status & Status.Stopping))
await stopPlugin(plugin) await stopPlugin(plugin)
// TODO(plugins): write to storage
plugin.flags &= ~Flag.Enabled plugin.flags &= ~Flag.Enabled
pEmitter.emit('disabled', plugin) pEmitter.emit('disabled', plugin)
} }
@ -317,9 +341,7 @@ export async function enablePlugin(plugin: AnyPlugin) {
}), }),
) )
// TODO(plugins): write to storage
plugin.flags |= Flag.Enabled plugin.flags |= Flag.Enabled
pEmitter.emit('enabled', plugin) pEmitter.emit('enabled', plugin)
} }
@ -557,3 +579,18 @@ async function cleanupPlugin(plugin: AnyPlugin, meta: InternalPluginMeta) {
await Promise.all(proms) await Promise.all(proms)
} }
declare module '@revenge-mod/modules/native' {
interface Methods {
'revenge.plugins.states.read': [[], PersistedPluginStates | null]
'revenge.plugins.states.write': [[statuses: PersistedPluginFlags], void]
}
}
interface PersistedPluginStates {
flags: PersistedPluginFlags
}
interface PersistedPluginFlags {
[id: PluginManifest['id']]: number
}

View file

@ -3,6 +3,7 @@ import * as PluginApiModulesFindersFilters from '@revenge-mod/modules/finders/fi
import * as PluginApiModulesMetroSubscriptions from '@revenge-mod/modules/metro/subscriptions' import * as PluginApiModulesMetroSubscriptions from '@revenge-mod/modules/metro/subscriptions'
import * as PluginApiModulesMetroUtils from '@revenge-mod/modules/metro/utils' import * as PluginApiModulesMetroUtils from '@revenge-mod/modules/metro/utils'
import * as PluginApiModulesNative_ from '@revenge-mod/modules/native' import * as PluginApiModulesNative_ from '@revenge-mod/modules/native'
import * as PluginApiModulesNativeFileSystem from '@revenge-mod/modules/native/fs'
import { spreadDescriptors } from '.' import { spreadDescriptors } from '.'
export interface PluginApiModules { export interface PluginApiModules {
@ -11,7 +12,9 @@ export interface PluginApiModules {
native: PluginApiModulesNative native: PluginApiModulesNative
} }
export type PluginApiModulesNative = typeof PluginApiModulesNative_ export type PluginApiModulesNative = typeof PluginApiModulesNative_ & {
fs: typeof PluginApiModulesNativeFileSystem
}
export type PluginApiModulesMetro = export type PluginApiModulesMetro =
// biome-ignore format: Don't // biome-ignore format: Don't
@ -30,5 +33,7 @@ export const modules: PluginApiModules = {
PluginApiModulesMetroUtils, PluginApiModulesMetroUtils,
spreadDescriptors(PluginApiModulesMetroSubscriptions, {}), spreadDescriptors(PluginApiModulesMetroSubscriptions, {}),
), ),
native: PluginApiModulesNative_, native: spreadDescriptors(PluginApiModulesNative_, {
fs: PluginApiModulesNativeFileSystem,
}),
} }

View file

@ -21,6 +21,11 @@ export const PluginFlags = {
EnabledLate: 1 << 3, EnabledLate: 1 << 3,
} }
/**
* A bitmask of {@link PluginFlags} that are persisted to storage.
*/
export const PersistentPluginFlags = PluginFlags.Enabled
/** /**
* The plugin status. * The plugin status.
*/ */

View file

@ -3,7 +3,7 @@ import '@revenge-mod/react/init'
import '@revenge-mod/storage/init' import '@revenge-mod/storage/init'
import { onRunApplication } from '@revenge-mod/react/native' import { onRunApplication } from '@revenge-mod/react/native'
import { onError } from './preinit' import { onError } from '~preinit'
const unsub = onRunApplication(() => { const unsub = onRunApplication(() => {
unsub() unsub()

View file

@ -15,6 +15,7 @@ export const Setting = {
ReactVersion: 'ReactVersion', ReactVersion: 'ReactVersion',
ReactNativeVersion: 'ReactNativeVersion', ReactNativeVersion: 'ReactNativeVersion',
HermesVersion: 'HermesVersion', HermesVersion: 'HermesVersion',
LoaderVersion: 'LoaderVersion',
} as const } as const
export const RouteNames = { export const RouteNames = {

View file

@ -0,0 +1,19 @@
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { getBridgeInfo } from '@revenge-mod/modules/native'
import { Setting } from '../constants'
import { CopyableSetting } from './shared'
import type { SettingsItem } from '@revenge-mod/discord/modules/settings'
const bridgeInfo = getBridgeInfo()
const LoaderVersionSetting: SettingsItem = CopyableSetting(
{
parent: Setting.Revenge,
IconComponent: () => <TableRowAssetIcon name="SendMessageIcon" />,
title: () => 'Loader',
usePredicate: () => Boolean(bridgeInfo),
},
() => `${bridgeInfo!.name} (${bridgeInfo!.version})`,
)
export default LoaderVersionSetting

View file

@ -4,6 +4,7 @@ import {
} from '@revenge-mod/discord/modules/settings' } from '@revenge-mod/discord/modules/settings'
import { Setting } from './constants' import { Setting } from './constants'
import HermesVersionSetting from './definitions/HermesVersionSetting' import HermesVersionSetting from './definitions/HermesVersionSetting'
import LoaderVersionSetting from './definitions/LoaderVersionSetting'
import ReactNativeVersionSetting from './definitions/ReactNativeVersionSetting' import ReactNativeVersionSetting from './definitions/ReactNativeVersionSetting'
import ReactVersionSetting from './definitions/ReactVersionSetting' import ReactVersionSetting from './definitions/ReactVersionSetting'
import ReloadSetting from './definitions/ReloadSetting' import ReloadSetting from './definitions/ReloadSetting'
@ -25,6 +26,7 @@ registerSettingsItems({
[Setting.ReactVersion]: ReactVersionSetting, [Setting.ReactVersion]: ReactVersionSetting,
[Setting.ReactNativeVersion]: ReactNativeVersionSetting, [Setting.ReactNativeVersion]: ReactNativeVersionSetting,
[Setting.HermesVersion]: HermesVersionSetting, [Setting.HermesVersion]: HermesVersionSetting,
[Setting.LoaderVersion]: LoaderVersionSetting,
}) })
registerSettingsSection('REVENGE', { registerSettingsSection('REVENGE', {

View file

@ -1,4 +1,3 @@
import { Design } from '@revenge-mod/discord/design'
import { SettingListRenderer } from '@revenge-mod/discord/modules/settings/renderer' import { SettingListRenderer } from '@revenge-mod/discord/modules/settings/renderer'
import { Setting } from '../constants' import { Setting } from '../constants'
@ -10,24 +9,11 @@ export default function RevengeSettingScreen() {
label: 'Revenge', label: 'Revenge',
settings: [ settings: [
Setting.RevengeVersion, Setting.RevengeVersion,
Setting.LoaderVersion,
Setting.RevengeDiscord, Setting.RevengeDiscord,
Setting.RevengeSourceRepository, Setting.RevengeSourceRepository,
Setting.RevengeLicense, Setting.RevengeLicense,
], ],
subLabel: (
<>
<Design.Text variant="text-xs/medium">
You are using the next version of Revenge!
</Design.Text>
<Design.Text
color="text-danger"
variant="text-xs/semibold"
>
This version is experimental and may be
unstable.
</Design.Text>
</>
),
}, },
{ {
label: 'Versions', label: 'Versions',

View file

@ -5,8 +5,9 @@ import {
onModuleFirstRequired, onModuleFirstRequired,
onModuleInitialized, onModuleInitialized,
} from '@revenge-mod/modules/metro/subscriptions' } from '@revenge-mod/modules/metro/subscriptions'
import { callBridgeMethod } from '@revenge-mod/modules/native'
import { getErrorStack } from '@revenge-mod/utils/error' import { getErrorStack } from '@revenge-mod/utils/error'
import { BuildEnvironment, FullVersion } from './constants' import { BuildEnvironment, FullVersion } from '~constants'
const IndexModuleId = 0 const IndexModuleId = 0
@ -49,19 +50,19 @@ onModuleFirstRequired(IndexModuleId, function onIndexRequired() {
} }
}) })
export function onError(e: unknown) { export function onError(error: unknown) {
// TODO(init): Move to use native provided alert function, which will accept a string stack trace const stack = getErrorStack(error) ?? String(error)
// 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 typeof import('@revenge-mod/discord/native')
const Client = ClientInfoModule.getConstants() callBridgeMethod('revenge.alertError', [
const Device = DeviceModule.getConstants() stack,
`${FullVersion} (${BuildEnvironment})`,
])
alert( nativeLoggingHook(`\u001b[31m${stack}\u001b[0m`, 2)
`Failed to load Revenge (${FullVersion} (${BuildEnvironment}))\n` + }
`Discord: ${Client.Version} (${Client.Build})\n` +
`Device: ${Device.deviceManufacturer} ${Device.deviceModel}\n\n` + declare module '@revenge-mod/modules/native' {
getErrorStack(e), export interface Methods {
) 'revenge.alertError': [[error: string, version: string], void]
}
} }