feat(lib/assets)!: modify cache struct, change how overrides work, cleanup APIs

This commit is contained in:
PalmDevs 2025-06-02 15:56:07 +07:00
parent 3cc039ae73
commit a1db2dbb2c
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
7 changed files with 171 additions and 91 deletions

View file

@ -1,6 +1,6 @@
import type { Metro } from '@revenge-mod/modules/types'
import type { Asset, AssetId } from './types'
export const _assets = new Map<string, [first: Asset, all: Record<Asset['type'], Asset>]>()
export const _overrides = new Map<string, Asset>()
export const _customs = new Set<Asset>()
export const _overrides = new WeakMap<Asset, Asset>()
export const _metas = new WeakMap<Asset, [assetId: AssetId, moduleId: Metro.ModuleID]>()

60
lib/assets/src/caches.ts Normal file
View file

@ -0,0 +1,60 @@
// TODO(lib/assets): do not depend on Discord's ClientInfoModule for versioning, use native interop
import { ClientInfoModule } from '@revenge-mod/discord/native'
import { getStorage } from '@revenge-mod/storage'
// import { debounce } from '@revenge-mod/utils/callbacks'
import { createLogger } from '@revenge-mod/utils/logger'
import { mergeDeep } from '@revenge-mod/utils/objects'
import { proxify } from '@revenge-mod/utils/proxy'
import type { Metro } from '@revenge-mod/modules/types'
import type { Asset } from './types'
const logger = createLogger('revenge.assets.caches')
const Version = 1
const CurrentKey = `${Version}.${ClientInfoModule.Build}`
const CacheStorage = getStorage<Cache>('revenge/assets', {
default: { k: CurrentKey, c: {} },
directory: 'cache',
})
export const cache: Cache['c'] = proxify(
() => {
if (CacheStorage.cache) return CacheStorage.cache.c
},
{ hint: 'object' },
)!
// biome-ignore lint/complexity/useArrowFunction: Arrow functions are not supported
CacheStorage.get().then(async function (cache) {
// TODO(lib/assets/caches): This loads way too late and requires native interop to load earlier
logger.log(`Loaded cache version: ${cache.k}`)
if (cache.k !== CurrentKey) {
await CacheStorage.delete()
await CacheStorage.set({ k: CurrentKey, c: {} })
}
})
export interface Cache {
k: string
c: {
[key: Asset['name']]: {
[key: Asset['type']]: Metro.ModuleID
}
}
}
// const save = debounce(() => CacheStorage.set({}), 1000)
export async function cacheAsset(asset: Asset, moduleId: Metro.ModuleID) {
// Merge directly into the cache, only debouncing actual writes
mergeDeep(CacheStorage.cache!.c, {
[asset.name]: {
[asset.type]: moduleId,
},
})
// Commented out because caching currently does not work properly because it is loaded too late
// await save()
}

View file

@ -1,8 +1,10 @@
import { ReactNative } from '@revenge-mod/react'
import { _assets, _metas, _overrides } from './_internal'
import { _customs, _metas, _overrides } from './_internal'
import { cache } from './caches'
import { AssetRegistry } from './preinit'
import type { Metro } from '@revenge-mod/modules/types'
import type { Asset, AssetId, RegisterableAsset } from './types'
export { AssetRegistry, AssetRegistryModuleId } from './preinit'
@ -10,63 +12,6 @@ export { AssetRegistry, AssetRegistryModuleId } from './preinit'
// iOS cannot display SVGs
let _preferredType: Asset['type'] = ReactNative.Platform.OS === 'ios' ? 'png' : 'svg'
/**
* Yields all registered assets, including ones with same name but different types, custom assets, and overriden assets.
*/
export function* getAllAssets() {
for (const name of _assets.keys()) for (const asset of Object.values(_assets.get(name)![1])) yield asset
}
/**
* Get an asset by its ID. **This may throw an error if the asset-registry has not been initialized.**
*
* @param id The asset ID.
*/
export function getAssetById(id: AssetId): Asset | undefined {
return AssetRegistry.getAssetByID(id)
}
/**
* Get the ID of an asset.
*
* @param asset The asset to get the ID for.
*/
export function getAssetId(asset: Asset) {
return _metas.get(asset)?.[0]
}
/**
* Get the module ID that registered the asset. Returns `-1` if the asset is a custom asset.
*
* @param asset The asset to get the module ID for.
*/
export function getAssetModuleId(asset: Asset) {
return _metas.get(asset)?.[1]
}
/**
* Get an asset by its name.
* If more than one asset is registered with the same name, this will return the one with the preferred type, or the first registered one.
*
* @param name The asset name.
*/
export function getAssetByName(name: string): Asset | undefined {
const reg = _assets.get(name)
if (!reg) return
return _overrides.get(name) ?? reg[1][_preferredType] ?? reg[0]
}
/**
* Gets all assets matching the name.
*
* @param name The asset name.
* @returns A record keyed by the type of the asset, with the value being the asset itself.
*/
export function getAssetsByName(name: string): Record<Asset['type'], Asset> | undefined {
return _assets.get(name)?.[1]
}
/**
* Set the preferred asset type. This is used to determine which asset to use when multiple types are available.
*
@ -76,31 +21,110 @@ export function setPreferredAssetType(type: Asset['type']) {
_preferredType = type
}
/**
* Yields all registered assets, including ones with same name but different types, custom assets, and overriden assets.
*/
export function* getAssets(): Generator<Asset> {
for (const name of Object.keys(cache!))
for (const mid of Object.values(cache[name])) yield AssetRegistry.getAssetByID(__r(mid)) as Asset
for (const asset of _customs) yield asset
}
/**
* Get an asset by its name.
* If more than one asset is registered with the same name, this will return the one with the preferred type, or the first registered one.
*
* @param name The asset name.
*/
export function getAssetByName(name: string): Asset | undefined {
const id = getAssetIdByName(name)
if (id !== undefined) return AssetRegistry.getAssetByID(id)
}
/**
* Gets all assets matching the name.
*
* @param name The asset name.
* @returns A record keyed by the type of the asset, with the value being the asset itself.
*/
export function getAssetsByName(name: string): Record<Asset['type'], Asset> | undefined {
const reg = cache[name]
if (!reg) return
return Object.entries(reg).reduce(
(acc, [type, mid]) => {
acc[type as Asset['type']] = AssetRegistry.getAssetByID(__r(mid))!
return acc
},
{} as Record<Asset['type'], Asset>,
)
}
/**
* Get the ID of an asset.
*
* @param asset The asset to get the ID for.
*/
export function getAssetId(asset: Asset): AssetId | undefined {
return _metas.get(asset)?.[0]
}
/**
* Get an asset ID by its name.
* If more than one asset is registered with the same name, this will return the one with the preferred type, or the first registered one.
*
* @param name The asset name.
*/
export function getAssetIdByName(name: string): AssetId | undefined {
const reg = cache[name]
if (!reg) return
const mid = reg[_preferredType] ?? reg[Object.keys(reg)[0]!]
if (mid !== undefined) return __r(mid)
}
/**
* Get the module ID that registered the asset. Returns `-1` if the asset is a custom asset.
*
* @param asset The asset to get the module ID for.
*/
export function getAssetModuleId(asset: Asset): Metro.ModuleID | -1 | undefined {
return _metas.get(asset)?.[1]
}
/**
* Register an asset with the given name.
* If an asset with the same name already exists, it will be overridden if `override` is set to true.
*
* @param asset The asset to register.
* @param override Whether to override the existing asset if one exists. `false` by default.
* @throws An error if an asset with the same name and type already exists and `override` is not set to true.
* @returns The asset ID.
*/
export function registerAsset(asset: RegisterableAsset, override?: boolean) {
const { name, type } = asset
export function registerAsset(asset: RegisterableAsset): AssetId {
if (cache[asset.name]?.[asset.type] !== undefined)
throw new Error(`Asset with name ${asset.name} and type ${asset.type} already exists!`)
if (override) _overrides.set(name, asset)
// Make sure to not override the asset if it already exists, unless explicitly set to override
else if (_assets.get(name)?.[1]?.[type]) throw new Error(`Asset "${name}" with type "${type}" already exists`)
_customs.add(asset)
// @ts-expect-error
return AssetRegistry.registerAsset(asset)
}
/**
* Override an asset with a custom asset.
*
* @param asset The asset to override.
* @param override The custom asset to override with.
*/
export function addAssetOverride(asset: Asset, override: Asset) {
_overrides.set(asset, override)
}
/**
* Remove an asset override.
*
* @param name The name of the asset to remove the override for.
* @returns Whether the override was removed.
* @param asset The asset to remove the override for.
* @returns The asset that was removed.
*/
export function removeAssetOverride(name: string) {
return _overrides.delete(name)
export function removeAssetOverride(asset: Asset) {
return _overrides.delete(asset)
}

View file

@ -3,7 +3,8 @@ import { byName, byProps } from '@revenge-mod/modules/finders/filters'
import { waitForModules } from '@revenge-mod/modules/finders/wait'
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
import { _assets, _metas, _overrides } from './_internal'
import { _metas, _overrides } from './_internal'
import { cacheAsset } from './caches'
import type { ReactNative } from '@revenge-mod/react/types'
import type { Asset, ReactNativeAsset } from './types'
@ -43,18 +44,13 @@ const unsubAR = waitForModules(byProps<ReactNative.AssetsRegistry>('registerAsse
exports.registerAsset = (asset: Asset) => {
const result = orig(asset as ReactNativeAsset)
const { name, type } = asset
let reg = _assets.get(name)
if (!reg) {
reg = [asset, {}]
_assets.set(name, reg)
// Cache packager assets only
if ((asset as ReactNativeAsset).__packager_asset) {
cacheAsset(asset, _initing)
// In-memory cache
_metas.set(asset, [result, _initing])
}
// In-memory cache
reg[1][type] = asset
_metas.set(asset, [result, _initing])
return result
}
})
@ -68,7 +64,7 @@ const unsubRAS = waitForModules(
unsubRAS()
// Custom assets
// Why do we need to do this? Because RN/Dicord (unsure which) will attempt to resolve the asset via its path, which we don't provide via custom assets.
// Why do we need to do this? Because RN/Discord (unsure which) will attempt to resolve the asset via its path, which we don't provide via custom assets.
// This will result in a crash, because it tries to read the path and run checks on it, but the path is undefined.
// @ts-expect-error
rAS.addCustomSourceTransformer(({ asset }) => {
@ -78,6 +74,6 @@ const unsubRAS = waitForModules(
// Asset overrides
// @ts-expect-error
rAS.addCustomSourceTransformer(({ asset }) => _overrides.get(asset.name))
rAS.addCustomSourceTransformer(({ asset }) => _overrides.get(asset))
},
)

View file

@ -8,7 +8,7 @@ import Page from '~/components/Page'
import SearchInput from '~/components/SearchInput'
import TableRowAssetIcon from '~/components/TableRowAssetIcon'
import { getAllAssets, getAssetId, getAssetModuleId } from '@revenge-mod/assets'
import { getAssetId, getAssetModuleId, getAssets } from '@revenge-mod/assets'
import type { Asset, AssetId } from '@revenge-mod/assets/types'
import type { Metro } from '@revenge-mod/modules/types'
@ -28,8 +28,8 @@ const UndisplayableFallback = {
export default function AssetBrowserSettingScreen() {
const [search, setSearch] = React.useState('')
const assets = React.useMemo(
() => [...getAllAssets()].map(asset => [getAssetId(asset)!, getAssetModuleId(asset)!, asset] as const),
[getAllAssets],
() => [...getAssets()].map(asset => [getAssetId(asset)!, getAssetModuleId(asset)!, asset] as const),
[getAssets],
)
const filteredAssets = React.useMemo(

View file

@ -1,7 +1,7 @@
import { ToastActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { SettingListRenderer } from '@revenge-mod/discord/modules/settings/renderer'
import { React, ReactNative } from '@revenge-mod/react'
import { ToastActionCreators } from '@revenge-mod/discord/actions'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { api } from '..'

View file

@ -16,7 +16,7 @@ import FormSwitch from '~/components/FormSwitch'
import Page from '~/components/Page'
import SearchInput from '~/components/SearchInput'
import { getAssetByName, getAssetId } from '@revenge-mod/assets'
import { getAssetIdByName } from '@revenge-mod/assets'
import { Tokens } from '@revenge-mod/discord/common'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { useReRender } from '@revenge-mod/utils/react'
@ -118,7 +118,7 @@ function InstalledPluginCard({
<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} />
<Image source={getAssetIdByName(icon ?? 'PuzzlePieceIcon')!} style={styles.icon} />
<Text variant="heading-lg/semibold">{name}</Text>
</Stack>
<FormSwitch