diff --git a/lib/discord/src/native.ts b/lib/discord/src/native.ts index 05f2eff..1014f7c 100644 --- a/lib/discord/src/native.ts +++ b/lib/discord/src/native.ts @@ -1,3 +1,4 @@ +import { proxify } from '@revenge-mod/utils/proxy' import type { DiscordNativeModules } from './types/native' /** @@ -9,22 +10,52 @@ import type { DiscordNativeModules } from './types/native' * - If the module name ends with "Manager", include it */ +export let CacheModule: DiscordNativeModules.CacheModule = proxify(() => { + const module = __turboModuleProxy('NativeCacheModule') as typeof CacheModule + + if (module) return (CacheModule = module) +})! + +export let FileModule: DiscordNativeModules.FileModule = proxify(() => { + const module = __turboModuleProxy('NativeFileModule') as typeof FileModule + + if (module) return (FileModule = module) +})! + +export let ClientInfoModule: DiscordNativeModules.ClientInfoModule = proxify( + () => { + const module = __turboModuleProxy( + 'NativeClientInfoModule', + ) as typeof ClientInfoModule + + if (module) return (ClientInfoModule = module) + }, +)! + +export let DeviceModule: DiscordNativeModules.DeviceModule = proxify(() => { + const module = __turboModuleProxy( + 'NativeDeviceModule', + ) as typeof DeviceModule + + if (module) return (DeviceModule = module) +})! + +export let ThemeModule: DiscordNativeModules.ThemeModule = proxify(() => { + const module = __turboModuleProxy('NativeThemeModule') as typeof ThemeModule + + if (ThemeModule) return (ThemeModule = module) +})! + const nmp = nativeModuleProxy -export const CacheModule = __turboModuleProxy( - 'NativeCacheModule', -) as DiscordNativeModules.CacheModule -export const FileModule = __turboModuleProxy( - 'NativeFileModule', -) as DiscordNativeModules.FileModule -export const ClientInfoModule = __turboModuleProxy( - 'NativeClientInfoModule', -) as DiscordNativeModules.ClientInfoModule -export const DeviceModule = __turboModuleProxy( - 'NativeDeviceModule', -) as DiscordNativeModules.DeviceModule -export const BundleUpdaterManager = - nmp.BundleUpdaterManager as DiscordNativeModules.BundleUpdaterManager -export const ThemeModule = __turboModuleProxy( - 'NativeThemeModule', -) as DiscordNativeModules.ThemeModule +export let BundleUpdaterManager: DiscordNativeModules.BundleUpdaterManager = + proxify(() => { + // Need try-catch here because if you access this before RN sets it up, it will throw an error + try { + const module = + nmp.BundleUpdaterManager as typeof BundleUpdaterManager + return (BundleUpdaterManager = module) + } catch { + return null + } + })! diff --git a/lib/storage/src/index.ts b/lib/storage/src/index.ts index c0dda0d..a4534b5 100644 --- a/lib/storage/src/index.ts +++ b/lib/storage/src/index.ts @@ -1,12 +1,81 @@ // TODO(storage): Implement using NativeModules interop instead import { FileModule } from '@revenge-mod/discord/native' -// This file is usually imported way too early, so we can't import our shim -import { React } from '@revenge-mod/react' +import { getErrorStack } from '@revenge-mod/utils/errors' import { mergeDeep } from '@revenge-mod/utils/objects' -import { useReRender } from '@revenge-mod/utils/react' import type { AnyObject, DeepPartial } from '@revenge-mod/utils/types' +export type StorageSubscription = ( + v: DeepPartial, +) => void + +export function Storage( + this: Storage, + path: string, + options?: StorageOptions, +) { + const { CacheDirPath, DocumentsDirPath } = FileModule.getConstants() + + const directory = options?.directory ?? 'documents' + const dirPath = directory === 'cache' ? CacheDirPath : DocumentsDirPath + const fullPath = `${dirPath}/${path}` + + this._s = new Set>() + + this.loaded = false + this.cache = options?.default + + this.exists = () => FileModule.fileExists(fullPath) + this.delete = () => FileModule.removeFile(directory, path) + + this.get = async function () { + if (!(await this.exists())) { + this.cache = options?.default ?? {} + await this.set({}) + this.loaded = true + return this.cache + } + + const contents = await FileModule.readFile(fullPath, 'utf8') + if (contents) { + this.loaded = true + try { + const cache = (this.cache = JSON.parse(contents)) + for (const sub of this._s) sub(cache) + return cache + } catch (e) { + nativeLoggingHook( + `Failed to parse storage (<${directory}>/${path}): ${getErrorStack(e)}`, + 2, + ) + } + } + } + + this.set = async function (value) { + if (!this.cache) await this.get() + mergeDeep(this.cache!, value) + + try { + const contents = JSON.stringify(this.cache) + await FileModule.writeFile(directory, path, contents, 'utf8') + + for (const sub of this._s) sub(value) + } catch (e) { + nativeLoggingHook( + `Failed to write storage (<${directory}>/${path}): ${getErrorStack(e)}`, + 2, + ) + } + } +} + +// React is only initialized right before the init stage, so this is a dummy method +// See init.ts for the actual implementation +Storage.prototype.use = () => { + throw new Error('Storage#use can only be called after the init stage!') +} + /** * Get a storage object for a given path and directory. * @@ -17,91 +86,8 @@ export function getStorage( path: string, options?: StorageOptions, ): Storage { - const { CacheDirPath, DocumentsDirPath } = FileModule.getConstants() - - const directory = options?.directory ?? 'documents' - const dirPath = directory === 'cache' ? CacheDirPath : DocumentsDirPath - const fullPath = `${dirPath}/${path}` - - type Subscription = (v: DeepPartial) => void - const subs = new Set() - - const storage: Storage = { - loaded: false, - cache: options?.default, - use(filter) { - if (!this.cache) this.get() - - const reRender = useReRender() - - React.useEffect(() => { - const sub = ( - filter - ? v => { - if (filter(v)) reRender() - } - : reRender - ) as Subscription - - subs.add(sub) - - return () => { - subs.delete(sub) - } - }, []) - - return this.cache - }, - exists() { - return FileModule.fileExists(fullPath) - }, - delete() { - return FileModule.removeFile(directory, path) - }, - async get() { - if (!(await this.exists())) { - this.cache = options?.default ?? {} - await this.set({}) - this.loaded = true - return this.cache - } - - const contents = await FileModule.readFile(fullPath, 'utf8') - if (contents) { - this.loaded = true - try { - const cache = (this.cache = JSON.parse(contents)) - for (const sub of subs) sub(cache) - return cache - } catch (e) { - console.error( - 'Failed to parse storage file (most likely corrupted)', - directory, - path, - e, - ) - } - } - }, - async set(value) { - if (!this.cache) await this.get() - mergeDeep(this.cache!, value) - - try { - const contents = JSON.stringify(this.cache) - await FileModule.writeFile(directory, path, contents, 'utf8') - - for (const sub of subs) sub(value) - } catch (e) { - console.error( - 'Failed to write storage file', - directory, - path, - e, - ) - } - }, - } + const storage: Storage = Object.create(Storage.prototype) + Storage.call(storage, path, options) if (options?.load) storage.get() @@ -128,6 +114,10 @@ export type UseStorageFilter = ( ) => any export interface Storage { + /** + * @internal + */ + _s: Set> /** * Whether the storage has been loaded. If the storage is not loaded, `storage.cache` may be `undefined`. * If you have `options.default` set, you can use this property to check if `storage.cache` is the default value or not. @@ -141,6 +131,8 @@ export interface Storage { /** * Use the storage in a React component. The component will re-render when the storage is updated. * + * This can only be used in the `init` stage or later, as it requires React to be initialized. + * * @example * ```tsx * type Settings = { key: boolean, nested: { key: boolean } } diff --git a/lib/storage/src/init.ts b/lib/storage/src/init.ts index 9d76d4e..eeb3eab 100644 --- a/lib/storage/src/init.ts +++ b/lib/storage/src/init.ts @@ -1,7 +1,34 @@ import { pEmitter } from '@revenge-mod/plugins/_' import { PluginsStorageDirectory } from '@revenge-mod/plugins/constants' import { defineLazyProperty } from '@revenge-mod/utils/objects' -import { getStorage } from '.' +import { useReRender } from '@revenge-mod/utils/react' +import { useEffect } from 'react' +import { getStorage, Storage } from '.' +import type { StorageSubscription } from '.' + +// Actual implementation of Storage#use +const SP: Storage = Storage.prototype +SP.use = function (filter) { + if (!this.cache) this.get() + + const reRender = useReRender() + + useEffect(() => { + const sub: StorageSubscription = filter + ? v => { + if (filter(v)) reRender() + } + : reRender + + this._s.add(sub) + + return () => { + this._s.delete(sub) + } + }, []) + + return this.cache +} pEmitter.on('init', ({ _s: storageOptions, manifest: { id } }, api) => defineLazyProperty(api, 'storage', () => diff --git a/lib/utils/src/react.ts b/lib/utils/src/react.ts index 9617e7a..a38ec0c 100644 --- a/lib/utils/src/react.ts +++ b/lib/utils/src/react.ts @@ -1,5 +1,4 @@ -// @revenge-mod/storage depends on this file, and it is imported way too early, so we can't import our shim -import { React } from '@revenge-mod/react' +import { useEffect, useReducer } from 'react' import { findInTree } from './trees' import type { ReactNode } from 'react' import type { SearchFilter } from './trees' @@ -7,7 +6,7 @@ import type { SearchFilter } from './trees' export function useIsFirstRender() { let firstRender = false - React.useEffect(() => { + useEffect(() => { firstRender = true }, []) @@ -15,7 +14,7 @@ export function useIsFirstRender() { } export function useReRender() { - const [, forceUpdate] = React.useReducer(x => ~x, 0) + const [, forceUpdate] = useReducer(x => ~x, 0) return forceUpdate }