diff --git a/src/def.d.ts b/src/def.d.ts index 7bd1aa6..c31a79e 100644 --- a/src/def.d.ts +++ b/src/def.d.ts @@ -1,6 +1,7 @@ import * as _spitroast from "spitroast"; import _React from "react"; import _RN from "react-native"; +import { Events } from "./lib/emitter"; type MetroModules = { [id: number]: any }; @@ -177,6 +178,28 @@ interface MMKVManager { type Indexable = { [index: string]: Type } +type EmitterEvent = (keyof typeof Events) & string; + +interface EmitterListenerData { + path: string[]; + value?: any; +} + +type EmitterListener = ( + event: EmitterEvent, + data: EmitterListenerData | any +) => any; + +type EmitterListeners = Indexable>; + +interface Emitter { + listeners: EmitterListeners; + on: (event: EmitterEvent, listener: EmitterListener) => void; + off: (event: EmitterEvent, listener: EmitterListener) => void; + once: (event: EmitterEvent, listener: EmitterListener) => void; + emit: (event: EmitterEvent, data: EmitterListenerData) => void; +} + interface VendettaObject { patcher: { after: typeof _spitroast.after; @@ -238,6 +261,12 @@ interface VendettaObject { commands: { registerCommand: (command: ApplicationCommand) => () => void; }; + storage: { + createProxy(target: T): { proxy: T, emitter: Emitter }; + useProxy(storage: T): T; + createStorage(storeName: string): Promise>; + wrapSync>(store: T): Awaited; + }; settings: Settings; logger: Logger; version: string; diff --git a/src/index.ts b/src/index.ts index a7df50f..297c620 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,13 +8,14 @@ import * as metro from "@metro/filters"; import * as common from "@metro/common"; import * as components from "@ui/components"; import * as toasts from "@ui/toasts"; +import * as storage from "@lib/storage"; import { patchAssets, all, find, getAssetByID, getAssetByName, getAssetIDByName } from "@ui/assets"; import initSettings from "@ui/settings"; import { fixTheme } from "@ui/fixTheme"; import { connectToDebugger, patchLogHook, versionHash } from "@lib/debug"; import { plugins, fetchPlugin, evalPlugin, stopPlugin, removePlugin, getSettings } from "@lib/plugins"; import settings from "@lib/settings"; -import { registerCommand } from "./lib/commands"; +import { registerCommand } from "@lib/commands"; console.log("Hello from Vendetta!"); @@ -56,6 +57,7 @@ async function init() { commands: { registerCommand: registerCommand, }, + storage: { ...storage }, settings: settings, logger: logger, version: versionHash, @@ -73,4 +75,4 @@ async function init() { if (!erroredOnLoad) logger.log("Vendetta is ready!"); }; -init(); \ No newline at end of file +init(); diff --git a/src/lib/emitter.ts b/src/lib/emitter.ts new file mode 100644 index 0000000..d7bc1cf --- /dev/null +++ b/src/lib/emitter.ts @@ -0,0 +1,35 @@ +import { Emitter, EmitterEvent, EmitterListener, EmitterListenerData, EmitterListeners } from "@types"; + +export const Events = Object.freeze({ + GET: "GET", + SET: "SET", + DEL: "DEL" +}); + +export default function createEmitter(): Emitter { + return { + listeners: Object.values(Events).reduce((acc, val: string) => ((acc[val] = new Set()), acc), {}) as EmitterListeners, + + on(event: EmitterEvent, listener: EmitterListener) { + if (!this.listeners[event].has(listener)) + this.listeners[event].add(listener); + }, + + off(event: EmitterEvent, listener: EmitterListener) { + this.listeners[event].delete(listener); + }, + + once(event: EmitterEvent, listener: EmitterListener) { + const once = (event: EmitterEvent, data: EmitterListenerData) => { + this.off(event, once); + listener(event, data); + } + this.on(event, once); + }, + + emit(event: EmitterEvent, data: EmitterListenerData) { + for (const listener of this.listeners[event]) + listener(event, data); + }, + }; +} diff --git a/src/lib/plugins.ts b/src/lib/plugins.ts index 9819bab..229c804 100644 --- a/src/lib/plugins.ts +++ b/src/lib/plugins.ts @@ -1,8 +1,8 @@ import { Indexable, PluginManifest, Plugin } from "@types"; import { navigation } from "@metro/common"; +import { createStorage, wrapSync } from "@lib/storage"; import logger from "@lib/logger"; -import createStorage from "@lib/storage"; -import Subpage from "@/ui/settings/components/Subpage"; +import Subpage from "@ui/settings/components/Subpage"; type EvaledPlugin = { onLoad?(): void; @@ -10,19 +10,16 @@ type EvaledPlugin = { settings: JSX.Element; }; -export const plugins = createStorage>("VENDETTA_PLUGINS", async function(parsed) { - for (let p of Object.keys(parsed)) { - const plugin: Plugin = parsed[p]; +export const plugins = wrapSync(createStorage>("VENDETTA_PLUGINS").then(async function (store) { + for (let p of Object.keys(store)) { + const plugin: Plugin = store[p]; - if (parsed[p].update) { - await fetchPlugin(plugin.id); - } else { - plugins[p] = parsed[p]; - } - - if (parsed[p].enabled && plugins[p]) startPlugin(p); + if (store[p].update) await fetchPlugin(plugin.id); + if (store[p].enabled && plugins[p]) await startPlugin(p); } -}); + + return store; +})); const loadedPlugins: Indexable = {}; export async function fetchPlugin(id: string) { @@ -58,13 +55,14 @@ export async function fetchPlugin(id: string) { }; } -export function evalPlugin(plugin: Plugin) { +export async function evalPlugin(plugin: Plugin) { // TODO: Refactor to not depend on own window object const vendettaForPlugins = { ...window.vendetta, plugin: { manifest: plugin.manifest, - storage: createStorage>(plugin.id), + // Wrapping this with wrapSync is NOT an option. + storage: await createStorage>(plugin.id), showSettings: () => showSettings(plugin), } }; @@ -75,12 +73,12 @@ export function evalPlugin(plugin: Plugin) { return ret.default || ret; } -export function startPlugin(id: string) { +export async function startPlugin(id: string) { const plugin = plugins[id]; if (!plugin) throw new Error("Attempted to start non-existent plugin"); try { - const pluginRet: EvaledPlugin = evalPlugin(plugin); + const pluginRet: EvaledPlugin = await evalPlugin(plugin); loadedPlugins[id] = pluginRet; pluginRet.onLoad?.(); plugin.enabled = true; diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 5cc6fd9..0c8ada1 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -1,4 +1,4 @@ -import createStorage from "@lib/storage"; +import { createStorage, wrapSync } from "@lib/storage"; import { Settings } from "@types"; -export default createStorage("VENDETTA_SETTINGS"); \ No newline at end of file +export default wrapSync(createStorage("VENDETTA_SETTINGS")); diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 4e249e9..7af9547 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,48 +1,101 @@ -import { Indexable, MMKVManager } from "@types"; +import { Emitter, MMKVManager } from "@types"; import { ReactNative as RN } from "@metro/hoist"; +import createEmitter from "./emitter"; -// Discord's custom special storage sauce const MMKVManager = RN.NativeModules.MMKVManager as MMKVManager; -// TODO: React hook? -// TODO: Clean up types, if necessary -export default function createStorage(storeName: string, onRestore?: (parsed: T) => void): T { - const internalStore: Indexable = {}; +const emitterSymbol = Symbol("emitter accessor"); - const proxyValidator = { - get(target: object, key: string | symbol): any { - const orig = Reflect.get(target, key); - - if (typeof orig === "object" && orig !== null) { - return new Proxy(orig, proxyValidator); - } else { - return orig; - } - }, - - set(target: object, key: string | symbol, value: any) { - Reflect.set(target, key, value); - MMKVManager.setItem(storeName, JSON.stringify(internalStore)); - return true; - }, - - deleteProperty(target: object, key: string | symbol) { - Reflect.deleteProperty(target, key); - MMKVManager.setItem(storeName, JSON.stringify(internalStore)); - return true; +export function createProxy(target: any = {}): { proxy: any, emitter: Emitter } { + const emitter = createEmitter(); + + function createProxy(target: any, path: string[]): any { + return new Proxy(target, { + get(target, prop: string) { + if ((prop as unknown) === emitterSymbol) + return emitter; + + const newPath = [...path, prop]; + const value: any = target[prop]; + + if (value !== undefined && value !== null) { + emitter.emit("GET", { + path: newPath, + value, + }); + if (typeof value === "object") { + return createProxy(value, newPath); + } + return value; } - } - MMKVManager.getItem(storeName).then(async function (v) { - if (!v) return; - const parsed: T & Indexable = JSON.parse(v); + return value; + }, - if (onRestore && typeof onRestore === "function") { - onRestore(parsed); - } else { - for (let p of Object.keys(parsed)) internalStore[p] = parsed[p]; - } - }) + set(target, prop: string, value) { + target[prop] = value; + emitter.emit("SET", { + path: [...path, prop], + value + }); + // we do not care about success, if this actually does fail we have other problems + return true; + }, - return new Proxy(internalStore, proxyValidator) as T; + deleteProperty(target, prop: string) { + const success = delete target[prop]; + if (success) emitter.emit("DEL", { + path: [...path, prop], + }); + return success; + }, + }); + } + + return { + proxy: createProxy(target, []), + emitter, + } +} + +export function useProxy(storage: T): T { + const emitter = (storage as any)[emitterSymbol] as Emitter; + + const [, forceUpdate] = React.useReducer((n) => ~n, 0); + + React.useEffect(() => { + const listener = () => forceUpdate(); + + emitter.on("SET", listener); + emitter.on("DEL", listener); + + return () => { + emitter.off("SET", listener); + emitter.off("DEL", listener); + } + }, []); + + return storage; +} + +export async function createStorage(storeName: string): Promise> { + const data = JSON.parse(await MMKVManager.getItem(storeName) ?? "{}"); + const { proxy, emitter } = createProxy(data); + + const handler = () => MMKVManager.setItem(storeName, JSON.stringify(proxy)); + emitter.on("SET", handler); + emitter.on("DEL", handler); + + return proxy; +} + +export function wrapSync>(store: T): Awaited { + let awaited: any = undefined; + store.then((v) => (awaited = v)); + return new Proxy( + {} as Awaited, + Object.fromEntries(Object.getOwnPropertyNames(Reflect) + // @ts-expect-error + .map((k) => [k, (t: T, ...a: any[]) => Reflect[k](awaited ?? t, ...a)])), + ); } diff --git a/src/ui/settings/components/PluginCard.tsx b/src/ui/settings/components/PluginCard.tsx index d45e478..36a72c5 100644 --- a/src/ui/settings/components/PluginCard.tsx +++ b/src/ui/settings/components/PluginCard.tsx @@ -2,8 +2,8 @@ import { ReactNative as RN, stylesheet } from "@metro/common"; import { Forms, General } from "@ui/components"; import { Plugin } from "@types"; import { getAssetIDByName } from "@ui/assets"; -import { removePlugin, startPlugin, stopPlugin, showSettings, getSettings } from "@lib/plugins"; import { showToast } from "@ui/toasts"; +import { removePlugin, startPlugin, stopPlugin, showSettings, getSettings } from "@lib/plugins"; import copyText from "@lib/utils/copyText"; const { FormRow, FormSwitch } = Forms; @@ -39,13 +39,9 @@ interface PluginCardProps { } export default function PluginCard({ plugin }: PluginCardProps) { - const [enabled, setEnabled] = React.useState(plugin.enabled); - const [update, setUpdate] = React.useState(plugin.update); const [removed, setRemoved] = React.useState(false); - - // This is bad, but I don't think I have much choice - Beef - // Once the user re-renders the page, this is not taken into account anyway. - if (removed) return <>; + // This is needed because of Reactâ„¢ + if (removed) return null; return ( @@ -58,7 +54,6 @@ export default function PluginCard({ plugin }: PluginCardProps) { value={plugin.enabled} onValueChange={(v: boolean) => { if (v) startPlugin(plugin.id); else stopPlugin(plugin.id); - setEnabled(v); }} /> } @@ -87,7 +82,6 @@ export default function PluginCard({ plugin }: PluginCardProps) { onPress={() => { plugin.update = !plugin.update; showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved")); - setUpdate(plugin.update); }} > @@ -100,4 +94,4 @@ export default function PluginCard({ plugin }: PluginCardProps) { /> ) -} \ No newline at end of file +} diff --git a/src/ui/settings/components/SettingsSection.tsx b/src/ui/settings/components/SettingsSection.tsx index c26cfc0..b2045ca 100644 --- a/src/ui/settings/components/SettingsSection.tsx +++ b/src/ui/settings/components/SettingsSection.tsx @@ -1,6 +1,7 @@ import { Forms } from "@ui/components"; import { getAssetIDByName } from "@ui/assets"; -import settings from "@/lib/settings"; +import { useProxy } from "@lib/storage"; +import settings from "@lib/settings"; const { FormRow, FormSection, FormDivider } = Forms; @@ -9,6 +10,8 @@ interface SettingsSectionProps { } export default function SettingsSection({ navigation }: SettingsSectionProps) { + useProxy(settings); + return ( { settings.debuggerUrl = v; - setDebuggerUrl(v); }} title="DEBUGGER URL" /> } - onPress={() => connectToDebugger(debuggerUrl)} + leading={} + onPress={() => connectToDebugger(settings.debuggerUrl)} /> {window.__vendetta_rdc && } + leading={} onPress={() => { try { window.__vendetta_rdc?.connectToDevTools({ - host: debuggerUrl.split(":")[0], + host: settings.debuggerUrl.split(":")?.[0], resolveRNStyle: RN.StyleSheet.flatten, }); } catch(e) { @@ -50,7 +50,7 @@ export default function Developer() { } + leading={} trailing={FormRow.Arrow} onPress={() => navigation.push(Subpage, { name: "Asset Browser", diff --git a/src/ui/settings/pages/General.tsx b/src/ui/settings/pages/General.tsx index c8d4baa..3b021d6 100644 --- a/src/ui/settings/pages/General.tsx +++ b/src/ui/settings/pages/General.tsx @@ -3,14 +3,15 @@ import { DISCORD_SERVER, GITHUB } from "@lib/constants"; import { getAssetIDByName } from "@ui/assets"; import { Forms } from "@ui/components"; import { getDebugInfo } from "@lib/debug"; -import Version from "@ui/settings/components/Version"; +import { useProxy } from "@lib/storage"; import settings from "@lib/settings"; +import Version from "@ui/settings/components/Version"; const { FormRow, FormSwitchRow, FormSection, FormDivider } = Forms; const debugInfo = getDebugInfo(); export default function General() { - const [devSettings, setDevSettings] = React.useState(settings.developerSettings || false); + useProxy(settings); const versions = [ { @@ -116,10 +117,9 @@ export default function General() { } - value={devSettings} + value={settings.developerSettings} onValueChange={(v: boolean) => { settings.developerSettings = v; - setDevSettings(v); }} /> diff --git a/src/ui/settings/pages/Plugins.tsx b/src/ui/settings/pages/Plugins.tsx index 1da97ca..a17278a 100644 --- a/src/ui/settings/pages/Plugins.tsx +++ b/src/ui/settings/pages/Plugins.tsx @@ -2,14 +2,15 @@ import { ReactNative as RN } from "@metro/common"; import { Forms } from "@ui/components"; import { showToast } from "@ui/toasts"; import { getAssetIDByName } from "@ui/assets"; -import { fetchPlugin, plugins } from "@lib/plugins"; +import { useProxy } from "@lib/storage"; +import { plugins, fetchPlugin } from "@lib/plugins"; import PluginCard from "@ui/settings/components/PluginCard"; const { FormInput, FormRow } = Forms; export default function Plugins() { + useProxy(plugins); const [pluginUrl, setPluginUrl] = React.useState(""); - const [pluginList, setPluginList] = React.useState(plugins); return ( @@ -24,7 +25,6 @@ export default function Plugins() { onPress={() => { fetchPlugin(pluginUrl).then(() => { setPluginUrl(""); - setPluginList(plugins); }).catch((e: Error) => { showToast(e.message, getAssetIDByName("Small")); }); @@ -33,7 +33,7 @@ export default function Plugins() { /> } keyExtractor={item => item.id} />