diff --git a/addons/fonts/index.ts b/addons/fonts/index.ts new file mode 100644 index 0000000..47fc4d4 --- /dev/null +++ b/addons/fonts/index.ts @@ -0,0 +1,117 @@ +import { awaitStorage, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage"; +import { clearFolder, downloadFile, fileExists, removeFile, writeFile } from "@lib/api/native/fs"; +import { safeFetch } from "@lib/utils"; + +type FontMap = Record; + +export type FontDefinition = { + spec: 1; + name: string; + description?: string; + main: FontMap; + source?: string +} + +type FontStorage = Record & { __selected?: string; }; +export const fonts = wrapSync(createStorage(createMMKVBackend("BUNNY_FONTS"))); + +async function writeFont(font: FontDefinition | null) { + if (!font && font !== null) throw new Error("Arg font must be a valid object or null"); + if (font) { + await writeFile("fonts.json", JSON.stringify(font)); + } else { + await removeFile("fonts.json"); + } +} + +export function validateFont(font: FontDefinition) { + if (!font || typeof font !== "object") throw new Error("URL returned a null/non-object JSON"); + if (typeof font.spec !== "number") throw new Error("Invalid font 'spec' number"); + if (font.spec !== 1) throw new Error("Only fonts which follows spec:1 are supported"); + + const requiredFields = ["name", "main"] as const; + + if (requiredFields.some(f => !font[f])) throw new Error(`Font is missing one of the fields: ${requiredFields}`); + if (font.name.startsWith("__")) throw new Error("Font names cannot start with __"); + if (font.name in fonts) throw new Error(`There is already a font named '${font.name}' installed`); +} + +export async function saveFont(data: string | FontDefinition, selected = false) { + let fontDefJson: FontDefinition; + + if (typeof data === "string") { + try { + fontDefJson = await (await safeFetch(data)).json(); + } catch (e) { + throw new Error(`Failed to fetch fonts at ${data}`, { cause: e }); + } + } else { + fontDefJson = data; + } + + validateFont(fontDefJson); + + const errors = await Promise.allSettled(Object.entries(fontDefJson.main).map(async ([font, url]) => { + let ext = url.split(".").pop(); + if (ext !== "ttf" && ext !== "otf") ext = "ttf"; + const path = `downloads/fonts/${fontDefJson.name}/${font}.${ext}`; + if (!await fileExists(path)) await downloadFile(url, path); + })).then(it => it.map(it => it.status === 'fulfilled' ? undefined : it.reason)); + + if (errors.some(it => it)) throw errors + + fonts[fontDefJson.name] = fontDefJson; + + if (selected) writeFont(fonts[fontDefJson.name]); + return fontDefJson; +} + +export async function updateFont(fontDef: FontDefinition) { + let fontDefCopy = { ...fontDef } + if (fontDefCopy.source) fontDefCopy = { + ...await fetch(fontDefCopy.source).then(it => it.json()), + // Can't change these properties + name: fontDef.name, + source: fontDef.source + } + + const selected = fonts.__selected === fontDef.name + await removeFont(fontDef.name) + await saveFont(fontDefCopy, selected) +} + +export async function installFont(url: string, selected = false) { + const font = await saveFont(url); + if (selected) await selectFont(font.name); +} + +export async function selectFont(name: string | null) { + if (name && !(name in fonts)) throw new Error("Selected font does not exist!"); + + if (name) { + fonts.__selected = name; + } else { + delete fonts.__selected; + } + await writeFont(name == null ? null : fonts[name]); +} + +export async function removeFont(name: string) { + const selected = fonts.__selected === name; + if (selected) await selectFont(null); + delete fonts[name]; + try { + await clearFolder(`downloads/fonts/${name}`); + } catch { + // ignore + } +} + +export async function updateFonts() { + await awaitStorage(fonts); + Promise.allSettled( + Object.keys(fonts).map( + name => saveFont(fonts[name], fonts.__selected === name) + ) + ); +} diff --git a/addons/plugins/api.ts b/addons/plugins/api.ts new file mode 100644 index 0000000..9ff5a4d --- /dev/null +++ b/addons/plugins/api.ts @@ -0,0 +1,59 @@ +import { patcher } from "@lib/api"; +import { registerCommand } from "@lib/api/commands"; +import { createStorage } from "@lib/api/storage"; +import { logger } from "@lib/utils/logger"; + +import { registeredPlugins } from "."; +import { BunnyPluginObject } from "./types"; + +type DisposableFn = (...props: any[]) => () => unknown; +function shimDisposableFn(unpatches: (() => void)[], f: F): F { + const dummy = ((...props: Parameters) => { + const up = f(...props); + unpatches.push(up); + return up; + }) as F; + + for (const key in f) if (typeof f[key] === "function") { + // @ts-ignore + dummy[key] = shimDisposableFn(unpatches, f[key] as DisposableFn); + } + + return dummy; +} + +export function createBunnyPluginApi(id: string) { + const disposers = new Array; + + // proxying this would be a good idea + const object = { + ...window.bunny, + api: { + ...window.bunny.api, + patcher: { + before: shimDisposableFn(disposers, patcher.before), + after: shimDisposableFn(disposers, patcher.after), + instead: shimDisposableFn(disposers, patcher.instead) + }, + commands: { + ...window.bunny.api.commands, + registerCommand: shimDisposableFn(disposers, registerCommand) + }, + flux: { + ...window.bunny.api.flux, + intercept: shimDisposableFn(disposers, window.bunny.api.flux.intercept) + } + }, + // Added something in here? Make sure to also update BunnyPluginProperty in ./types + plugin: { + createStorage: () => createStorage(`plugins/storage/${id}.json`), + manifest: registeredPlugins.get(id), + logger + } + } as unknown as BunnyPluginObject; + + return { + object, + disposers, + }; +} diff --git a/addons/plugins/index.ts b/addons/plugins/index.ts new file mode 100644 index 0000000..8a9783e --- /dev/null +++ b/addons/plugins/index.ts @@ -0,0 +1,823 @@ +import { getCorePlugins } from "@core/plugins"; +import { readFile, removeFile, writeFile } from "@lib/api/native/fs"; +import { + awaitStorage, + createStorage, + getPreloadedStorage, + preloadStorageIfExists, + purgeStorage, + updateStorage, +} from "@lib/api/storage"; +import { safeFetch } from "@lib/utils"; +import { OFFICIAL_PLUGINS_REPO_URL } from "@lib/utils/constants"; +import { semver } from "@metro/common"; +import { updaterSettings } from "@lib/api/settings"; + +import { createBunnyPluginApi } from "./api"; +import * as t from "./types"; + +type PluginInstantiator = ( + bunny: t.BunnyPluginObject, + definePlugin?: (p: t.PluginInstance) => t.PluginInstanceInternal, +) => t.PluginInstanceInternal; + +// Core plugins instances are stored both in this and pluginInstance +// This exists because pluginInstances only stores running plugins while this one +// stores the always existing core plugins instances (which can't be destroyed) +export const corePluginInstances = new Map(); + +export const registeredPlugins = new Map(); +export const pluginInstances = new Map(); +export const apiObjects = new Map< + string, + ReturnType +>(); + +/** + * Register core plugins into the runtime registries so they appear in the UI + * as soon as this module is imported. This function is intentionally synchronous + * and idempotent so it is safe to call at module-load time or from initialization + * code. + */ +export function registerCorePlugins() { + try { + const core = getCorePlugins(); + for (const id in core) { + try { + const { default: instance, preenabled } = core[id]; + + // Ensure manifest is registered for UI listing + registeredPlugins.set(id, instance.manifest); + + // Keep a reference to the core plugin instance so isCorePlugin checks work + corePluginInstances.set(id, instance); + + // Ensure pluginSettings has an entry for the core plugin so the UI can + // reflect enabled/disabled state. Do not overwrite existing user settings. + try { + // `pluginSettings` is a storage wrapper; ensure we only set a default if absent. + if (pluginSettings[id] == null) { + pluginSettings[id] = { enabled: preenabled ?? true }; + } + } catch (e) { + // Best-effort: if storage isn't available yet, ignore and continue. + console.error( + "Failed to set default pluginSettings for core plugin", + id, + e, + ); + } + } catch (e) { + console.error("Failed to register core plugin", id, e); + } + } + } catch (e) { + console.error("registerCorePlugins: unexpected error", e); + } +} + +// Eager core plugin registration removed to avoid circular require / timing issues. +// Call `registerCorePlugins()` explicitly from initialization code when appropriate, +// for example during a safe deferred startup step. + +export const pluginRepositories = createStorage( + "plugins/repositories.json", +); +export const pluginSettings = createStorage( + "plugins/settings.json", +); + +const _fetch = (repoUrl: string, path: string) => { + try { + const u = new URL(path, repoUrl); + // Append cache-busting query parameter so manual refresh triggers network requests + u.searchParams.set("_", String(Date.now())); + return safeFetch(u, { cache: "no-store" }); + } catch (e) { + // Fallback to original behavior if URL construction fails for any reason + return safeFetch(new URL(path, repoUrl), { cache: "no-store" }); + } +}; +const fetchJS = (repoUrl: string, path: string) => + _fetch(repoUrl, path).then((r) => r.text()); +const fetchJSON = (repoUrl: string, path: string) => + _fetch(repoUrl, path).then((r) => r.json()); + +function assert( + condition: T, + id: string, + attempt: string, +): asserts condition { + if (!condition) throw new Error(`[${id}] Attempted to ${attempt}`); +} + +/** + * Checks if a version is newer than the other. However, this comes with an additional logic, + * where if the version are equal, one with prerelease "tag" will be considered "newer" + * @internal + * @returns Whether the version is newer + */ +export function isGreaterVersion(v1: string, v2: string) { + if (semver.gt(v1, v2)) return true; + const coerced = semver.coerce(v1); + if (coerced == null) return false; + return semver.prerelease(v1)?.includes("dev") && semver.eq(coerced, v2); +} + +function isExternalPlugin( + manifest: t.BunnyPluginManifest, +): manifest is t.BunnyPluginManifestInternal { + return "parentRepository" in manifest; +} + +export function isCorePlugin(id: string) { + return corePluginInstances.has(id); +} + +export function getPluginSettingsComponent( + id: string, +): React.ComponentType | null { + const instance = pluginInstances.get(id); + if (!instance) return null; + + if (instance.SettingsComponent) return instance.SettingsComponent; + return null; +} + +export function isPluginInstalled(id: string) { + return pluginSettings[id] != null; +} + +export function isPluginEnabled(id: string) { + return Boolean(pluginSettings[id]?.enabled); +} + +/** + * Fetch and write the plugin to thier respective storage. This does not compare the version nor execute the plugin + * @param repoUrl URL to the plugin repository + * @param id The ID of the plugin + * @returns The newly fetched plugin manifest + */ +export async function updateAndWritePlugin( + repoUrl: string, + id: string, + fetchScript: boolean, +) { + // Try multiple manifest/script paths and provide robust logging so we can trace + // exactly which network requests are performed. Primary path is `builds/...`, + // fallback paths include `id/manifest.json` and `id/index.js` (some repos expose those). + const manifestCandidates = [ + `builds/${id}/manifest.json`, + `${id}/manifest.json`, + ]; + + let manifest: t.BunnyPluginManifestInternal | undefined; + let lastManifestErr: any = null; + + for (const candidate of manifestCandidates) { + const url = new URL(candidate, repoUrl).toString(); + try { + manifest = await fetchJSON(repoUrl, candidate); + break; + } catch (e) { + lastManifestErr = e; + } + } + + if (!manifest) { + throw lastManifestErr ?? new Error(`Failed to fetch manifest for ${id}`); + } + + // @ts-expect-error - Setting a readonly property + manifest.parentRepository = repoUrl; + + if (fetchScript) { + // @ts-expect-error - Setting a readonly property + manifest.jsPath = `plugins/scripts/${id}.js`; + + const scriptCandidates = [`builds/${id}/index.js`, `${id}/index.js`]; + let js: string | undefined; + let lastScriptErr: any = null; + + for (const candidate of scriptCandidates) { + const url = new URL(candidate, repoUrl).toString(); + try { + js = await fetchJS(repoUrl, candidate); + break; + } catch (e) { + lastScriptErr = e; + } + } + + if (!js) { + throw lastScriptErr ?? new Error(`Failed to fetch script for ${id}`); + } + + try { + await writeFile(manifest.jsPath, js); + } catch (writeErr) { + throw writeErr; + } + } + + try { + await updateStorage(`plugins/manifests/${id}.json`, manifest); + } catch (e) { + throw e; + } + + if (registeredPlugins.has(id)) { + const existingManifest = registeredPlugins.get(id)!; + return Object.assign(existingManifest, manifest); + } + + return manifest; +} + +/** + * Stops the plugin, fetches the update and restart the updated plugin + * @param id The registered plugin's ID + * @param repoUrl URL to the plugin repository. If unprovided, the repository url from the registered plugin will be used. + */ +export async function refreshPlugin(id: string, repoUrl?: string) { + let manifest = registeredPlugins.get(id); + + assert(manifest, id, "refresh a non-registered plugin"); + assert(pluginInstances.get(id), id, "refresh a non-started plugin"); + + stopPlugin(id); + + if (isExternalPlugin(manifest)) { + manifest = await updateAndWritePlugin( + repoUrl ?? manifest.parentRepository, + id, + true, + ); + } + + registeredPlugins.delete(id); + registeredPlugins.set(id, manifest); + + await startPlugin(id); +} + +/** + * Check for any updates from the repository given, or add it. + * Then, register all plugins within the repository. + * @param repoUrl Registered plugin repository url + * @returns Whether there was any changes made from the update + */ +export async function updateRepository(repoUrl: string) { + // Attempt to fetch a repository descriptor (repo.json). If that fails, + // try to treat the provided URL as a single-plugin host (manifest.json at root) + // and synthesize a 1-entry repo mapping. This allows local/dev servers which + // serve a single plugin at the host root to be consumed by the updater. + let repo: t.PluginRepo; + let storedRepo = pluginRepositories[repoUrl]; + + let updated = false; + + try { + repo = await fetchJSON(repoUrl, "repo.json"); + } catch (repoErr) { + // repo.json not found — try fallback candidate manifest at the repo root. + try { + const manifest = (await fetchJSON(repoUrl, "manifest.json")) as any; + // Infer plugin id: + // Prefer an explicit id property if present; otherwise use name; otherwise + // fall back to a path-derived token (path or hostname). + const inferredId = + manifest?.id ?? + manifest?.name ?? + (() => { + try { + const u = new URL(repoUrl); + const p = u.pathname.replace(/^\/|\/$/g, ""); + return p || u.hostname; + } catch { + return "local-plugin"; + } + })(); + + // Build a minimal repo mapping containing the single manifest entry. + repo = { + [inferredId]: manifest, + // Keep a $meta marker to be consistent with multi-plugin repos (optional) + $meta: { source: repoUrl }, + } as any; + + // Persist mapping so other code can read pluginRepositories[repoUrl] + pluginRepositories[repoUrl] = repo; + storedRepo = pluginRepositories[repoUrl]; + updated = true; + } catch (manifestErr) { + // If both repo.json and manifest.json attempts fail, rethrow the original repo error + // so callers can handle/report the problem. + throw repoErr; + } + } + + // This repository never existed, update it! + if (!storedRepo) { + for (const id in repo) { + if (corePluginInstances.has(id)) { + throw new Error( + `Plugins can't have the same ID as any of Bunny core plugin '${id}'`, + ); + } + } + + updated = true; + pluginRepositories[repoUrl] = repo; + } else { + // Remove plugins which no longer exists on the fetched repository + for (const plugin in storedRepo) + if (!repo[plugin]) { + delete storedRepo[plugin]; + } + } + + const pluginIds = Object.keys(repo).filter((id) => !id.startsWith("$")); + await Promise.all( + pluginIds.map(async (pluginId) => { + if ( + !storedRepo || + !storedRepo[pluginId] || + repo[pluginId].alwaysFetch || + isGreaterVersion(repo[pluginId].version, storedRepo[pluginId].version) + ) { + updated = true; + pluginRepositories[repoUrl][pluginId] = repo[pluginId]; + await updateAndWritePlugin( + repoUrl, + pluginId, + Boolean(storedRepo && pluginSettings[pluginId]), + ); + } else { + const manifest = await preloadStorageIfExists( + `plugins/manifests/${pluginId}.json`, + ); + if (!manifest) { + // File does not exist, so do refetch and stuff + await updateAndWritePlugin( + repoUrl, + pluginId, + Boolean(storedRepo && pluginSettings[pluginId]), + ); + } + } + }), + ); + + // Register plugins in this repository + for (const id of pluginIds) { + const manifest = getPreloadedStorage( + `plugins/manifests/${id}.json`, + ); + if (manifest === undefined) continue; // shouldn't happen, but just incase if it does + + const existing = registeredPlugins.get(id); + + // Skip if this version isn't any higher + if (existing && !isGreaterVersion(manifest.version, existing.version)) { + continue; + } + + registeredPlugins.set(id, manifest); + } + + return updated; +} + +/** + * Deletes a repository from registrations and uninstalls ALL plugins under this repository + */ +export async function deleteRepository(repoUrl: string) { + assert( + repoUrl !== OFFICIAL_PLUGINS_REPO_URL, + repoUrl, + "delete the official repository", + ); + assert( + pluginRepositories[repoUrl], + repoUrl, + "delete a non-registered repository", + ); + + const promQueues = [] as Promise[]; + + for (const [id, manifest] of registeredPlugins) { + if (!isExternalPlugin(manifest) || manifest.parentRepository !== repoUrl) + continue; + + // Uninstall + if (isPluginInstalled(id)) { + promQueues.push(uninstallPlugin(id)); + } + + // Deregister all plugins under this repository + promQueues.push(purgeStorage(`plugins/manifests/${id}.json`)); + registeredPlugins.delete(id); + } + + delete pluginRepositories[repoUrl]; + await Promise.all(promQueues); + updateAllRepository(); +} + +/** + * Enablea a plugin. The plugin must have been declared as installed. + * @param id The installed plugin ID + * @param start Whether to start the plugin + */ +export async function enablePlugin(id: string, start: boolean) { + assert(isPluginInstalled(id), id, "enable a non-installed plugin"); + + if (start) await startPlugin(id); + pluginSettings[id]!.enabled = true; +} + +/** + * Disables and stop the plugin. The plugin must have been declared as installed + * @param id The installed plugin ID + */ +export function disablePlugin(id: string) { + assert(isPluginInstalled(id), id, "disable a non-installed plugin"); + + pluginInstances.has(id) && stopPlugin(id); + pluginSettings[id]!.enabled = false; +} + +/** + * Installs a registered plugin, will throw when plugin was already installed + * @param id The registered plugin ID + * @param start Whether to start the plugin or not + */ +export async function installPlugin(id: string, start: boolean) { + const manifest = registeredPlugins.get(id); + + assert(manifest, id, "install an non-registered plugin"); + assert(!isPluginInstalled(id), id, "install an already installed plugin"); + assert(isExternalPlugin(manifest), id, "install a core plugin"); + + // We only need to fetch the JS, but this is fine + await updateAndWritePlugin(manifest.parentRepository, id, true); + + pluginSettings[id] = { enabled: true }; + if (start) startPlugin(id); +} + +/** + * Uninstalls a plugin and remove it from the storage + * @param id The installed plugin ID + */ +export async function uninstallPlugin(id: string) { + const manifest = registeredPlugins.get(id); + + assert(manifest, id, "uninstall an unregistered plugin"); + assert(isPluginInstalled(id), id, "uninstall a non-installed plugin"); + assert(isExternalPlugin(manifest), id, "uninstall a core plugin"); + + pluginInstances.has(id) && stopPlugin(id); + delete pluginSettings[id]; + + await purgeStorage(`plugins/storage/${id}.json`); + await removeFile(`plugins/scripts/${id}.js`); +} + +/** + * Starts a registered, installed, enabled and unstarted plugin. Otherwise, would throw. + * @param id The enabled plugin ID + */ +export async function startPlugin( + id: string, + { throwIfDisabled = false, disableWhenThrown = true } = {}, +) { + const manifest = registeredPlugins.get(id); + + assert(manifest, id, "start a non-registered plugin"); + assert(isPluginInstalled(id), id, "start a non-installed plugin"); + assert( + !throwIfDisabled || pluginSettings[id]?.enabled, + id, + "start a disabled plugin", + ); + assert(!pluginInstances.has(id), id, "start an already started plugin"); + + await preloadStorageIfExists(`plugins/storage/${id}.json`); + + let pluginInstance: t.PluginInstanceInternal; + + if (isExternalPlugin(manifest)) { + // Stage one, "compile" the plugin + try { + // jsPath should always exists when the plugin is installed, unless the storage is corrupted + const iife = await readFile(manifest.jsPath!!); + + // Set a global marker so runtime/async errors can be attributed to the plugin + // This is a minimal, best-effort context marker. It will be cleared immediately + // after the synchronous evaluation step below. + try { + (window as any).__SHIGGY_CURRENT_PLUGIN = id; + } catch { + // ignore if environment prevents writing to window + } + + var instantiator = globalEvalWithSourceUrl( + `(bunny,definePlugin)=>{${iife};return plugin?.default ?? plugin;}`, + `bunny-plugin/${id}-${manifest.version}`, + ) as PluginInstantiator; + } catch (error) { + // Annotate parsing errors with plugin id to aid attribution in ErrorBoundary + const e = new Error( + "An error occured while parsing plugin's code, possibly a syntax error?", + { cause: error }, + ) as any; + e.pluginId = id; + throw e; + } finally { + // Clear the immediate evaluation marker - instantiation happens next. + try { + (window as any).__SHIGGY_CURRENT_PLUGIN = null; + } catch {} + } + + // Stage two, load the plugin + try { + // During instantiation we again mark the current plugin so synchronous + // work performed by the plugin's top-level code is attributable. + try { + (window as any).__SHIGGY_CURRENT_PLUGIN = id; + } catch {} + + const api = createBunnyPluginApi(id); + + // Wrap instantiator call to annotate errors with plugin id if they escape here. + try { + pluginInstance = instantiator(api.object, (p) => { + return Object.assign(p, { + manifest, + }) as t.PluginInstanceInternal; + }); + } catch (innerError) { + const e = new Error( + "An error occured while instantiating plugin's code", + ) as any; + e.cause = innerError; + e.pluginId = id; + throw e; + } + + if (!pluginInstance) { + const e = new Error( + `Plugin '${id}' does not export a valid plugin instance`, + ) as any; + e.pluginId = id; + throw e; + } + + apiObjects.set(id, api); + pluginInstances.set(id, pluginInstance); + } catch (error) { + // propagate already-annotated error or annotate as fallback + if (!(error as any).pluginId) (error as any).pluginId = id; + throw error; + } finally { + // Always clear the global plugin marker after instantiation attempt. + try { + (window as any).__SHIGGY_CURRENT_PLUGIN = null; + } catch {} + } + } else { + pluginInstance = corePluginInstances.get(id)!; + assert(pluginInstance, id, "start a non-existent core plugin"); + pluginInstances.set(id, pluginInstance); + } + + // Stage three (of external plugins), start the plugin + try { + pluginInstance.start?.(); + + pluginSettings[id]!.enabled = true; + } catch (error) { + disableWhenThrown && disablePlugin(id); + throw new Error("An error occured while starting the plugin", { + cause: error, + }); + } +} + +/** + * Stops the plugin and disposes all usages of scoped plugin API + * @param id The currently-running plugin's ID + */ +export function stopPlugin(id: string) { + const instance = pluginInstances.get(id); + assert(instance, id, "stop a non-started plugin"); + + instance.stop?.(); + const obj = apiObjects.get(id); + obj?.disposers.forEach((d: Function) => d()); + pluginInstances.delete(id); +} + +/** + * Utility: produce a sequence of bisect batches for enabled external plugins. + * This returns an array of arrays where each returned array is the set of plugin + * ids you should disable in that bisect step (simple binary-halving strategy). + * + * The UI can use these batches to guide a manual binary-search disabling flow: + * - Disable all plugin ids in batches[0], reproduce crash + * - If crash persists, disable batches[1], otherwise re-enable batches[0] and disable complementary half, etc. + * + * This function is intentionally read-only; it only computes batches. The UI + * should call `disablePlugin(id)` / `enablePlugin(id, ...)` itself to toggle plugins. + */ +export function getBisectBatches(maxPerStep = 50): string[][] { + // Collect external enabled plugin ids + const enabled: string[] = []; + for (const [id, manifest] of registeredPlugins) { + try { + const isExternal = (manifest as any).parentRepository != null; + const enabledFlag = Boolean(pluginSettings[id]?.enabled); + if (!isExternal) continue; + if (!enabledFlag) continue; + enabled.push(id); + } catch { + // ignore corrupt manifests + } + } + + // Limit to a reasonable number to avoid overwhelming the user + const ids = enabled.slice(0, maxPerStep); + + if (ids.length <= 1) return ids.length === 1 ? [[ids[0]]] : []; + + // Binary-halving batches: + // On step n, disable the first half of the current candidate set. + const batches: string[][] = []; + let candidates = ids.slice(); + while (candidates.length > 1) { + const half = Math.ceil(candidates.length / 2); + const toDisable = candidates.slice(0, half); + batches.push(toDisable); + // For the next iteration assume we will test the complement; compute complement + candidates = candidates.slice(half); + // If complement has only one left, add it as final step + if (candidates.length === 1) { + batches.push([candidates[0]]); + break; + } + } + + return batches; +} + +export async function updateAllRepository() { + // Always attempt to update the official repository first + try { + await updateRepository(OFFICIAL_PLUGINS_REPO_URL); + } catch (error) { + console.error("Failed to update official plugins repository", error); + } + + // Determine global fetch-on-start behavior and per-repo overrides + const fetchOnStart = !!(updaterSettings.fetchPluginsOnStart ?? true); + const overrides = updaterSettings.repoAutoFetchOverrides ?? {}; + + const repos = Object.keys(pluginRepositories); + + await Promise.allSettled( + repos.map(async (repo) => { + // Skip official (already handled) + if (repo === OFFICIAL_PLUGINS_REPO_URL) return; + + // If global fetching is disabled, only process repos explicitly overridden to true + const repoOverride = overrides[repo]; + if (!fetchOnStart && repoOverride !== true) return; + + try { + await updateRepository(repo); + } catch (e) { + console.error(`Failed to update repository ${repo}`, e); + } + }), + ); +} + +export async function updatePlugins() { + await awaitStorage(pluginRepositories, pluginSettings); + + // Register core plugins + const corePlugins = getCorePlugins(); + for (const id in corePlugins) { + const { default: instance, preenabled } = corePlugins[id]; + + // Core plugins are always installed + pluginSettings[id] ??= { + enabled: preenabled ?? true, + }; + + registeredPlugins.set(id, instance.manifest); + corePluginInstances.set(id, instance); + } + + // Always ensure official repository metadata is at least attempted to be fetched, + // because core plugin registration / system behavior may depend on it. + try { + await updateRepository(OFFICIAL_PLUGINS_REPO_URL); + } catch (error) { + console.error("Failed to update official plugins repository", error); + } + + // If fetch-on-start is enabled, update all repositories following configured policy. + // Otherwise, only update repositories which have per-repo overrides explicitly set to true. + const fetchOnStart = !!(updaterSettings.fetchPluginsOnStart ?? true); + const overrides = updaterSettings.repoAutoFetchOverrides ?? {}; + + if (fetchOnStart) { + await updateAllRepository(); + } else { + // Only fetch explicitly opted-in repositories to avoid startup slowdowns. + const repos = Object.keys(pluginRepositories).filter( + (r) => r !== OFFICIAL_PLUGINS_REPO_URL && overrides[r] === true, + ); + await Promise.allSettled( + repos.map(async (repo) => { + try { + await updateRepository(repo); + } catch (e) { + console.error(`Failed to update repository ${repo}`, e); + } + }), + ); + } +} + +/** + * Initialize plugins but stagger startup to avoid CPU spikes during app start. + * + * This replaces the original "start all enabled plugins in parallel" approach + * with a configurable staggered startup: plugins are started in small batches and + * we await a small interval between batches. This reduces short-lived CPU and I/O contention + * that causes jank on low-end devices. + * + * Backwards-compatible: callers can still call initPlugins() with no arguments. + * + * Options: + * - staggerInterval (ms) - wait time between batches (default: 100) + * - batchSize - how many plugins to start concurrently in a single batch (default: 3) + */ +export async function initPlugins( + options: { staggerInterval?: number; batchSize?: number } = {}, +) { + const { staggerInterval = 100, batchSize = 3 } = options; + + // Ensure core plugins are registered prior to any plugin startup logic so + // settings pages and UI that read registered/core plugins show them. + // registerCorePlugins is synchronous and idempotent, but call it here as a + // safety-net in case module-load registration didn't run earlier. + try { + registerCorePlugins(); + } catch { + // ignore + } + + await awaitStorage(pluginRepositories, pluginSettings); + + // Collect enabled plugin ids + const enabledIds = [...registeredPlugins.keys()].filter((id) => + isPluginEnabled(id), + ); + + if (enabledIds.length === 0) return; + + // Helper sleep + const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); + + // Start plugins in batches to avoid starting all at once. + for (let i = 0; i < enabledIds.length; i += batchSize) { + const batch = enabledIds.slice(i, i + batchSize); + + // Start the batch in parallel but don't let exceptions bubble to halt everything. + await Promise.allSettled( + batch.map(async (id) => { + try { + // Only attempt to start if still enabled and not already started + if (!isPluginEnabled(id) || pluginInstances.has(id)) return; + await startPlugin(id).catch((e) => { + // startPlugin may throw; ensure it doesn't crash the whole batch + console.error(`Failed to start plugin ${id}:`, e); + }); + } catch (error) { + console.error(`Unexpected error while starting plugin ${id}:`, error); + } + }), + ); + + // If there are more plugins to start, wait before starting the next batch. + if (i + batchSize < enabledIds.length) { + await sleep(staggerInterval); + } + } +} diff --git a/addons/plugins/types.ts b/addons/plugins/types.ts new file mode 100644 index 0000000..0b6baea --- /dev/null +++ b/addons/plugins/types.ts @@ -0,0 +1,55 @@ +import { BunnyManifest } from "@lib/addons/types"; +import { createStorage } from "@lib/api/storage"; +import { Logger } from "@lib/utils/logger"; + +export type PluginRepo = Record & { + $meta: { + name: string; + description: string; + }; +}; + +export interface PluginRepoStorage { + [repoUrl: string]: PluginRepo; +} + +export interface PluginSettingsStorage { + [pluginId: string]: { + enabled: boolean; + }; +} + +export interface BunnyPluginManifest extends BunnyManifest { + readonly type: "plugin"; + readonly spec: 3; + readonly main: string; +} + +export interface BunnyPluginManifestInternal extends BunnyPluginManifest { + readonly parentRepository: string; + readonly jsPath?: string; +} + +export interface PluginInstance { + start?(): void; + stop?(): void; + SettingsComponent?(): JSX.Element; +} + +export interface PluginInstanceInternal extends PluginInstance { + readonly manifest: BunnyPluginManifest; +} + +export interface BunnyPluginProperty { + readonly manifest: BunnyPluginManifestInternal; + readonly logger: Logger; + createStorage(): ReturnType>; +} + +export type BunnyPluginObject = typeof window.bunny & { + plugin: BunnyPluginProperty; +}; diff --git a/addons/themes/colors/index.ts b/addons/themes/colors/index.ts new file mode 100644 index 0000000..1da31dc --- /dev/null +++ b/addons/themes/colors/index.ts @@ -0,0 +1,18 @@ +import patchChatBackground from "./patches/background"; +import patchDefinitionAndResolver from "./patches/resolver"; +import patchStorage from "./patches/storage"; +import { ColorManifest } from "./types"; +import { updateBunnyColor } from "./updater"; + +/** @internal */ +export default function initColors(manifest: ColorManifest | null) { + if (manifest) updateBunnyColor(manifest, { update: false }); + + const patches = [ + patchStorage(), + patchDefinitionAndResolver(), + patchChatBackground() + ]; + + return () => patches.forEach(p => p()); +} \ No newline at end of file diff --git a/addons/themes/colors/keyboard.ts b/addons/themes/colors/keyboard.ts new file mode 100644 index 0000000..d3e4987 --- /dev/null +++ b/addons/themes/colors/keyboard.ts @@ -0,0 +1,64 @@ +import { TextInput, Platform, NativeModules } from "react-native"; +import { findByStoreNameLazy } from "@metro/wrappers"; +import { _colorRef } from "./updater"; + +const ThemeStore = findByStoreNameLazy("ThemeStore"); + +function getKeyboardAppearance() { + // Check for custom theme + const custom = _colorRef.current; + if (custom) { + // Bunny theme (spec 3) + if (custom.spec === 3 && custom.reference) { + return custom.reference === "light" ? "light" : "dark"; + } + // Vendetta theme (spec 2) + if (custom.spec === 2 && custom.reference) { + return custom.reference === "light" ? "light" : "dark"; + } + } + + // Fallback to Discord theme string + const theme = ThemeStore?.theme; + if (typeof theme === "string") { + const lowerTheme = theme.toLowerCase(); + return (lowerTheme.includes("dark") || lowerTheme.includes("midnight")) + ? "dark" + : "light"; + } + + return "dark"; +} + +export default function fixKeyboard() { + if (Platform.OS !== "ios") return; + + const UIManager = NativeModules.UIManager; + + const origUpdateView = UIManager?.updateView; + const origCreateView = UIManager?.createView; + + if (origUpdateView) { + UIManager.updateView = function(viewTag: number, viewName: string, props: any) { + if (props && typeof props === 'object') { + if (viewName.toLowerCase().includes('text') || viewName.toLowerCase().includes('input')) { + props.keyboardAppearance = getKeyboardAppearance(); + } + } + return origUpdateView.call(this, viewTag, viewName, props); + }; + } + + if (origCreateView) { + UIManager.createView = function(reactTag: number, viewName: string, rootTag: number, props: any) { + if (props && typeof props === 'object') { + if (viewName.toLowerCase().includes('text') || viewName.toLowerCase().includes('input')) { + props.keyboardAppearance = getKeyboardAppearance(); + } + } + return origCreateView.call(this, reactTag, viewName, rootTag, props); + }; + } + + return ThemeStore?.addChangeListener?.(() => {}); +} diff --git a/addons/themes/colors/parser.ts b/addons/themes/colors/parser.ts new file mode 100644 index 0000000..4b3eff5 --- /dev/null +++ b/addons/themes/colors/parser.ts @@ -0,0 +1,157 @@ +import { findByProps } from "@metro"; +import chroma from "chroma-js"; +import { omit } from "es-toolkit"; +import { Platform, processColor } from "react-native"; + +import { colorsPref } from "./preferences"; +import { ColorManifest, InternalColorDefinition } from "./types"; + +const tokenRef = findByProps("SemanticColor"); + +export function parseColorManifest(manifest: ColorManifest): InternalColorDefinition { + const resolveType = (type = "dark") => (colorsPref.type ?? type) === "dark" ? "darker" : "light"; + + if (manifest.spec === 3) { + const semanticColorDefinitions: InternalColorDefinition["semantic"] = {}; + + for (const [semanticColorKey, semanticColorValue] of Object.entries( + manifest.main.semantic ?? {}, + )) { + if (typeof semanticColorValue === "object") { + const { + type, + value, + opacity: semanticColorOpacity, + } = semanticColorValue; + + if (type === "raw") { + semanticColorDefinitions[semanticColorKey] = { + value, + opacity: semanticColorOpacity ?? 1, + }; + } else { + const rawColorValue = tokenRef.RawColor[value]; + semanticColorDefinitions[semanticColorKey] = { + value: rawColorValue, + opacity: semanticColorOpacity ?? 1, + }; + } + } else if (typeof semanticColorValue === "string") { + if (semanticColorValue.startsWith("#")) { + semanticColorDefinitions[semanticColorKey] = { + value: chroma.hex(semanticColorValue).hex(), + opacity: 1, + }; + } else { + semanticColorDefinitions[semanticColorKey] = { + value: tokenRef.RawColor[semanticColorValue], + opacity: 1, + }; + } + } else { + throw new Error(`Invalid semantic definitions: ${semanticColorValue}`); + } + } + + if (Platform.OS === "android") applyAndroidAlphaKeys(manifest.main.raw); + + return { + spec: 3, + reference: resolveType(manifest.type), + semantic: semanticColorDefinitions, + raw: manifest.main.raw ?? {}, + background: manifest.main.background, + }; + } + + if (manifest.spec === 2) { + // is Vendetta theme + const semanticDefinitions: InternalColorDefinition["semantic"] = {}; + const background: InternalColorDefinition["background"] | undefined = + manifest.background + ? { + ...omit(manifest.background, ["alpha"]), + opacity: manifest.background.alpha, + } + : undefined; + + if (manifest.semanticColors) { + for (const key in manifest.semanticColors) { + const values = manifest.semanticColors[key] + .map((c) => c || undefined) + .slice(0, 2); + if (!values[0]) continue; + + semanticDefinitions[key] = { + value: normalizeToHex(values[resolveType() === "light" ? 1 : 0])!, + opacity: 1, + }; + } + } + + if (manifest.rawColors) { + const draft: typeof manifest.rawColors = {}; + + for (const key in manifest.rawColors) { + const value = manifest.rawColors[key]; + if (!value) continue; + draft[key] = normalizeToHex(value)!; + } + + if (Platform.OS === "android") applyAndroidAlphaKeys(draft); + manifest.rawColors = draft; + } + + return { + spec: 2, + reference: resolveType(), + semantic: semanticDefinitions, + raw: manifest.rawColors ?? {}, + background, + }; + } + + throw new Error("Invalid theme spec"); +} + +export function applyAndroidAlphaKeys(rawColors?: Record) { + if (!rawColors) return; + + // these are native Discord Android keys + const alphaMap: Record = { + BLACK_ALPHA_60: ["BLACK", 0.6], + BRAND_NEW_360_ALPHA_20: ["BRAND_360", 0.2], + BRAND_NEW_360_ALPHA_25: ["BRAND_360", 0.25], + BRAND_NEW_500_ALPHA_20: ["BRAND_500", 0.2], + PRIMARY_DARK_500_ALPHA_20: ["PRIMARY_500", 0.2], + PRIMARY_DARK_700_ALPHA_60: ["PRIMARY_700", 0.6], + STATUS_GREEN_500_ALPHA_20: ["GREEN_500", 0.2], + STATUS_RED_500_ALPHA_20: ["RED_500", 0.2], + }; + + for (const key in alphaMap) { + const [colorKey, alpha] = alphaMap[key]; + if (!rawColors[colorKey]) continue; + rawColors[key] = chroma(rawColors[colorKey]).alpha(alpha).hex(); + } + + return rawColors; +} + +export function normalizeToHex( + colorString: string | undefined, +): string | undefined { + if (colorString === undefined) return undefined; + if (chroma.valid(colorString)) return chroma(colorString).hex(); + + const color = Number(processColor(colorString)); + + return chroma + .rgb( + (color >> 16) & 0xff, // red + (color >> 8) & 0xff, // green + color & 0xff, // blue + (color >> 24) & 0xff, // alpha + ) + .hex(); +} diff --git a/addons/themes/colors/patches/background.tsx b/addons/themes/colors/patches/background.tsx new file mode 100644 index 0000000..4c337ca --- /dev/null +++ b/addons/themes/colors/patches/background.tsx @@ -0,0 +1,59 @@ +import { colorsPref } from "@lib/addons/themes/colors/preferences"; +import { _colorRef } from "@lib/addons/themes/colors/updater"; +import { after } from "@lib/api/patcher"; +import { useObservable } from "@lib/api/storage"; +import { findInReactTree } from "@lib/utils"; +import { findByFilePathLazy } from "@metro"; +import chroma from "chroma-js"; +import { ImageBackground, StyleSheet } from "react-native"; + +const Messages = findByFilePathLazy("components_native/chat/Messages.tsx", true); + +function ThemeBackground({ children }: { children: React.ReactNode }) { + useObservable([colorsPref]); + + if (!_colorRef.current + || colorsPref.customBackground === "hidden" + || !_colorRef.current.background?.url + || _colorRef.current.background?.blur && (typeof _colorRef.current.background?.blur !== "number") + ){ + return children; + } + + return + {children} + ; +} + +export default function patchChatBackground() { + const patches = [ + after("render", Messages, (_, ret) => { + if (!_colorRef.current || !_colorRef.current.background?.url) return; + const messagesComponent = findInReactTree( + ret, + x => x && "HACK_fixModalInteraction" in x.props && x?.props?.style + ); + + if (messagesComponent) { + const flattened = StyleSheet.flatten(messagesComponent.props.style); + const backgroundColor = chroma( + flattened.backgroundColor || "black" + ).alpha( + 1 - (_colorRef.current.background?.opacity ?? 1) + ).hex(); + + messagesComponent.props.style = StyleSheet.flatten([ + messagesComponent.props.style, + { backgroundColor } + ]); + } + + return {ret}; + }) + ]; + return () => patches.forEach(x => x()); +} diff --git a/addons/themes/colors/patches/resolver.ts b/addons/themes/colors/patches/resolver.ts new file mode 100644 index 0000000..c6411f2 --- /dev/null +++ b/addons/themes/colors/patches/resolver.ts @@ -0,0 +1,92 @@ +import { _colorRef } from "@lib/addons/themes/colors/updater"; +import { NativeThemeModule } from "@lib/api/native/modules"; +import { before, instead } from "@lib/api/patcher"; +import { findByProps } from "@metro"; +import { byMutableProp } from "@metro/filters"; +import { createLazyModule } from "@metro/lazy"; +import chroma from "chroma-js"; + +const tokenReference = findByProps("SemanticColor"); +const isThemeModule = createLazyModule(byMutableProp("isThemeDark")); + +const SEMANTIC_FALLBACK_MAP: Record = { + "BG_BACKDROP": "BACKGROUND_FLOATING", + "BG_BASE_PRIMARY": "BACKGROUND_PRIMARY", + "BG_BASE_SECONDARY": "BACKGROUND_SECONDARY", + "BG_BASE_TERTIARY": "BACKGROUND_SECONDARY_ALT", + "BG_MOD_FAINT": "BACKGROUND_MODIFIER_ACCENT", + "BG_MOD_STRONG": "BACKGROUND_MODIFIER_ACCENT", + "BG_MOD_SUBTLE": "BACKGROUND_MODIFIER_ACCENT", + "BG_SURFACE_OVERLAY": "BACKGROUND_FLOATING", + "BG_SURFACE_OVERLAY_TMP": "BACKGROUND_FLOATING", + "BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY" +}; + +const origRawColor = { ...tokenReference.RawColor }; + +export default function patchDefinitionAndResolver() { + const callback = ([theme]: any[]) => theme === _colorRef.key ? [_colorRef.current!.reference] : void 0; + + Object.keys(tokenReference.RawColor).forEach(key => { + Object.defineProperty(tokenReference.RawColor, key, { + configurable: true, + enumerable: true, + get: () => { + const ret = _colorRef.current?.raw[key]; + if (ret) return ret; + + return origRawColor[key]; + } + }); + }); + + const unpatches = [ + before("isThemeDark", isThemeModule, callback), + before("isThemeLight", isThemeModule, callback), + before("updateTheme", NativeThemeModule, callback), + instead("resolveSemanticColor", tokenReference.default.meta ?? tokenReference.default.internal, (args: any[], orig: any) => { + if (!_colorRef.current) return orig(...args); + if (args[0] !== _colorRef.key) return orig(...args); + + args[0] = _colorRef.current.reference; + + const [name, colorDef] = extractInfo(_colorRef.current!.reference, args[1]); + + let semanticDef = _colorRef.current.semantic[name]; + if (!semanticDef && _colorRef.current.spec === 2 && name in SEMANTIC_FALLBACK_MAP) { + semanticDef = _colorRef.current.semantic[SEMANTIC_FALLBACK_MAP[name]]; + } + + if (semanticDef?.value) { + if (semanticDef.opacity === 1) return semanticDef.value; + return chroma(semanticDef.value).alpha(semanticDef.opacity).hex(); + } + + const rawValue = _colorRef.current.raw[colorDef.raw]; + if (rawValue) { + // Set opacity if needed + return colorDef.opacity === 1 ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex(); + } + + // Fallback to default + return orig(...args); + }), + () => { + Object.defineProperty(tokenReference, "RawColor", { + configurable: true, + writable: true, + value: origRawColor + }); + } + ]; + + return () => unpatches.forEach(p => p()); +} + +function extractInfo(themeName: string, colorObj: any): [name: string, colorDef: any] { + // @ts-ignore - assigning to extractInfo._sym + const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]]; + const colorDef = tokenReference.SemanticColor[propName]; + + return [propName, colorDef[themeName]]; +} \ No newline at end of file diff --git a/addons/themes/colors/patches/storage.ts b/addons/themes/colors/patches/storage.ts new file mode 100644 index 0000000..ddf14ad --- /dev/null +++ b/addons/themes/colors/patches/storage.ts @@ -0,0 +1,38 @@ +import { _colorRef } from "@lib/addons/themes/colors/updater"; +import { after, before } from "@lib/api/patcher"; +import { findInTree } from "@lib/utils"; +import { proxyLazy } from "@lib/utils/lazy"; +import { findByProps } from "@metro"; + +const mmkvStorage = proxyLazy(() => { + const newModule = findByProps("impl"); + if (typeof newModule?.impl === "object") return newModule.impl; + return findByProps("storage"); +}); + +export default function patchStorage() { + const patchedKeys = new Set(["ThemeStore", "SelectivelySyncedUserSettingsStore"]); + + const patches = [ + after("get", mmkvStorage, ([key], ret) => { + if (!_colorRef.current || !patchedKeys.has(key)) return; + + const state = findInTree(ret._state, s => typeof s.theme === "string"); + if (state) state.theme = _colorRef.key; + }), + before("set", mmkvStorage, ([key, value]) => { + if (!patchedKeys.has(key)) return; + + const json = JSON.stringify(value); + const lastSetDiscordTheme = _colorRef.lastSetDiscordTheme ?? "darker"; + const replaced = json.replace( + /"theme":"bn-theme-\d+"/, + `"theme":${JSON.stringify(lastSetDiscordTheme)}` + ); + + return [key, JSON.parse(replaced)]; + }) + ]; + + return () => patches.forEach(p => p()); +} diff --git a/addons/themes/colors/preferences.ts b/addons/themes/colors/preferences.ts new file mode 100644 index 0000000..ba5eb1c --- /dev/null +++ b/addons/themes/colors/preferences.ts @@ -0,0 +1,18 @@ +import { createStorage } from "@lib/api/storage"; + +interface BunnyColorPreferencesStorage { + selected: string | null; + type?: "dark" | "light" | null; + customBackground: "hidden" | null; + per?: Record; +} + +export const colorsPref = createStorage( + "themes/colors/preferences.json", + { + dflt: { + selected: null, + customBackground: null + } + } +); diff --git a/addons/themes/colors/statusbar.ts b/addons/themes/colors/statusbar.ts new file mode 100644 index 0000000..22f0f38 --- /dev/null +++ b/addons/themes/colors/statusbar.ts @@ -0,0 +1,57 @@ +import { StatusBar, TextInput, Platform } from "react-native"; +import { findByStoreNameLazy } from "@metro/wrappers"; +import { _colorRef } from "./updater"; + +const ThemeStore = findByStoreNameLazy("ThemeStore"); + +function getBarStyle() { + // Check for custom theme + const custom = _colorRef.current; + if (custom) { + // Bunny theme (spec 3) + if (custom.spec === 3 && custom.reference) { + return custom.reference === "light" ? "dark-content" : "light-content"; + } + // Vendetta theme (spec 2) + if (custom.spec === 2 && custom.reference) { + return custom.reference === "light" ? "dark-content" : "light-content"; + } + } + + // Fallback to Discord theme string + const theme = ThemeStore?.theme; + if (typeof theme === "string") { + const lowerTheme = theme.toLowerCase(); + return (lowerTheme.includes("dark") || lowerTheme.includes("midnight")) ? "light-content" : "dark-content"; + } + + return "dark-content"; +} + +export default function fixStatusBar() { + + if (Platform.OS == "android") { + const origSetBarStyle = StatusBar.setBarStyle; + StatusBar.setBarStyle = function (_style: any, ...args: any[]) { + return origSetBarStyle.call(this, getBarStyle(), ...args); + }; + } + + if (Platform.OS == "ios") { + function applyStatusBar() { + const style = getBarStyle(); + StatusBar.setBarStyle(style, true); + } + + const unsubscribe = ThemeStore?.addChangeListener?.(applyStatusBar); + + StatusBar.setBarStyle(getBarStyle()); + + let delay = 200; + for (let i = 0; i < 5; i++, delay *= 2) { + setTimeout(applyStatusBar, delay); + } + + return unsubscribe; + } +} diff --git a/addons/themes/colors/types.ts b/addons/themes/colors/types.ts new file mode 100644 index 0000000..203f3c0 --- /dev/null +++ b/addons/themes/colors/types.ts @@ -0,0 +1,52 @@ +import { Author, BunnyManifest } from "@lib/addons/types"; + +interface SemanticReference { + type: "color" | "raw"; + value: string; + opacity?: number; +} + +interface BackgroundDefinition { + url: string; + blur?: number; + opacity?: number; +} + +export interface BunnyColorManifest extends BunnyManifest { + type: "color"; + spec: 3; + main: { + type: "dark" | "light"; + semantic?: Record; + raw?: Record; + background?: BackgroundDefinition; + } +} + +export interface VendettaThemeManifest { + spec: 2; + name: string; + description?: string; + authors?: Author[]; + semanticColors?: Record; + rawColors?: Record; + background?: { + url: string; + blur?: number; + alpha?: number; + }; +} + +/** @internal */ +export interface InternalColorDefinition { + spec: 1 | 2 | 3; + reference: "darker" | "light"; + semantic: Record; + raw: Record; + background?: BackgroundDefinition; +} + +export type ColorManifest = BunnyColorManifest | VendettaThemeManifest; diff --git a/addons/themes/colors/updater.ts b/addons/themes/colors/updater.ts new file mode 100644 index 0000000..8c3d85b --- /dev/null +++ b/addons/themes/colors/updater.ts @@ -0,0 +1,72 @@ +import { settings } from "@lib/api/settings"; +import { findByProps, findByPropsLazy, findByStoreNameLazy } from "@metro"; + +import { parseColorManifest } from "./parser"; +import { ColorManifest, InternalColorDefinition } from "./types"; + +const tokenRef = findByProps("SemanticColor"); +const origRawColor = { ...tokenRef.RawColor }; +const AppearanceManager = findByPropsLazy("updateTheme"); +const ThemeStore = findByStoreNameLazy("ThemeStore"); +const FormDivider = findByPropsLazy("DIVIDER_COLORS"); +import { colorsPref } from "./preferences"; + +let _inc = 1; + +interface InternalColorRef { + key: `bn-theme-${string}`; + current: InternalColorDefinition | null; + readonly origRaw: Record; + lastSetDiscordTheme: string; +} + +/** @internal */ +export const _colorRef: InternalColorRef = { + current: null, + key: `bn-theme-${_inc}`, + origRaw: origRawColor, + lastSetDiscordTheme: "darker" +}; + +export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true } ) { + if (settings.safeMode?.enabled) return; + + const resolveType = (type = "dark") => (colorsPref.type ?? type) === "dark" ? "darker" : "light"; + const internalDef = colorManifest ? parseColorManifest(colorManifest) : null; + + if (resolveType() == "light" || update) { + var ref = Object.assign(_colorRef, { + current: internalDef, + key: `bn-theme-${++_inc}`, + lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-") || !ThemeStore.theme.startsWith("darker") + ? ThemeStore.theme + : _colorRef.lastSetDiscordTheme + }); + } else { + var ref = Object.assign(_colorRef, { + current: internalDef, + key: `dark`, + lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-") || !ThemeStore.theme.startsWith("darker") + ? ThemeStore.theme + : _colorRef.lastSetDiscordTheme + }); + } + + if (internalDef != null) { + tokenRef.Theme[ref.key.toUpperCase()] = ref.key; + FormDivider.DIVIDER_COLORS[ref.key] = FormDivider.DIVIDER_COLORS[ref.current!.reference]; + + Object.keys(tokenRef.Shadow).forEach(k => tokenRef.Shadow[k][ref.key] = tokenRef.Shadow[k][ref.current!.reference]); + Object.keys(tokenRef.SemanticColor).forEach(k => { + tokenRef.SemanticColor[k][ref.key] = { + ...tokenRef.SemanticColor[k][ref.current!.reference] + }; + }); + } + + if (update) { + AppearanceManager.setShouldSyncAppearanceSettings(false); + AppearanceManager.updateTheme(internalDef != null ? ref.key : "darker"); + } +} + diff --git a/addons/themes/index.ts b/addons/themes/index.ts new file mode 100644 index 0000000..4dcf455 --- /dev/null +++ b/addons/themes/index.ts @@ -0,0 +1,168 @@ +import { awaitStorage, createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage"; +import { writeFile } from "@lib/api/native/fs"; +import { getStoredTheme, getThemeFilePath, isPyonLoader, isThemeSupported } from "@lib/api/native/loader"; +import { awaitStorage as newAwaitStorage } from "@lib/api/storage"; +import { safeFetch } from "@lib/utils"; +import { Platform } from "react-native"; +import { settings } from "@lib/api/settings"; +import initColors from "./colors"; +import { applyAndroidAlphaKeys, normalizeToHex } from "./colors/parser"; +import { colorsPref } from "./colors/preferences"; +import { VendettaThemeManifest } from "./colors/types"; +import { updateBunnyColor } from "./colors/updater"; + +export interface VdThemeInfo { + id: string; + selected: boolean; + data: VendettaThemeManifest; +} + +export const themes = wrapSync(createStorage>(createMMKVBackend("VENDETTA_THEMES"))); + +/** + * @internal + */ +export async function writeThemeToNative(theme: VdThemeInfo | {}) { + if (typeof theme !== "object") throw new Error("Theme must be an object"); + + // Save the current theme as current-theme.json. When supported by loader, + // this json will be written to appropriate path and be used to theme the native side. + await createFileBackend(getThemeFilePath() || "theme.json").set(theme); +} + +// Process data for some compatiblity with native side +function processData(data: VendettaThemeManifest) { + if (data.semanticColors) { + const { semanticColors } = data; + + for (const key in semanticColors) { + for (const index in semanticColors[key]) { + semanticColors[key][index] &&= normalizeToHex(semanticColors[key][index] as string) || false; + } + } + } + + if (data.rawColors) { + const { rawColors } = data; + + for (const key in rawColors) { + const normalized = normalizeToHex(rawColors[key]); + if (normalized) data.rawColors[key] = normalized; + } + + if (Platform.OS === "android") applyAndroidAlphaKeys(rawColors); + } + + if (data.spec === undefined) { + if (!('theme_color_map' in data)) { + data.spec = 2; + } + //If not then Enmity theme and no spec + } + + return data; +} + +function validateTheme(themeJSON: any): boolean { + if (typeof themeJSON !== "object" || themeJSON === null) return false; + + if (themeJSON.spec === 3 && !themeJSON.main) return false; + + if (themeJSON.spec === 2) return true; + + if (themeJSON.theme_color_map) return true; + + return themeJSON.spec === 2 || themeJSON.spec === 3; +} + +export async function fetchTheme(url: string, selected = false) { + let themeJSON: any; + + try { + themeJSON = await (await safeFetch(url, { cache: "no-store" })).json(); + } catch { + throw new Error(`Failed to fetch theme at ${url}`); + } + + // Validate theme + if (!validateTheme(themeJSON)) throw new Error(`Invalid theme at ${url}`); + + themes[url] = { + id: url, + selected: selected, + data: processData(themeJSON), + }; + + if (selected) { + writeThemeToNative(themes[url]); + updateBunnyColor(themes[url].data, { update: true }); + } +} + +export async function installTheme(url: string) { + if (typeof url !== "string" || url in themes) throw new Error("Theme already installed"); + await fetchTheme(url); +} + +export function selectTheme(theme: VdThemeInfo | null, write = true) { + if (theme) theme.selected = true; + Object.keys(themes).forEach( + k => themes[k].selected = themes[k].id === theme?.id + ); + + if (theme == null && write) { + updateBunnyColor(null, { update: true }); + return writeThemeToNative({}); + } else if (theme) { + updateBunnyColor(theme.data, { update: true }); + return writeThemeToNative(theme); + } +} + +export async function removeTheme(id: string) { + const theme = themes[id]; + if (theme.selected) await selectTheme(null); + delete themes[id]; + + return theme.selected; +} + +export async function updateThemes() { + await awaitStorage(themes); + const currentTheme = getThemeFromLoader(); + await Promise.allSettled(Object.keys(themes).map(id => fetchTheme(id, currentTheme?.id === id))); +} + +export function getCurrentTheme() { + return Object.values(themes).find(t => t.selected) ?? null; +} + +/** + * @internal + */ +export function getThemeFromLoader(): VdThemeInfo | null { + return getStoredTheme(); +} + +/** + * @internal + */ +export async function initThemes() { + if (!isThemeSupported()) return; + if (settings.safeMode?.enabled) return; + + try { + if (isPyonLoader()) { + writeFile("../vendetta_theme.json", "null"); + } + + await newAwaitStorage(colorsPref); + + const currentTheme = getThemeFromLoader(); + initColors(currentTheme?.data ?? null); + + updateThemes().catch(e => console.error("Failed to update themes", e)); + } catch (e) { + console.error("Failed to initialize themes", e); + } +} diff --git a/addons/types.ts b/addons/types.ts new file mode 100644 index 0000000..447cdeb --- /dev/null +++ b/addons/types.ts @@ -0,0 +1,17 @@ +export type Author = { name: string, id?: `${bigint}`; }; + +export interface BunnyManifest { + readonly id: string; + readonly spec: number; + readonly version: string; + readonly type: string; + readonly display: { + readonly name: string; + readonly description?: string; + readonly authors?: Author[]; + }; + readonly main: unknown; + readonly extras?: { + readonly [key: string]: any; + }; +} diff --git a/api/assets/index.ts b/api/assets/index.ts new file mode 100644 index 0000000..a82ca6e --- /dev/null +++ b/api/assets/index.ts @@ -0,0 +1,79 @@ +import { getMetroCache } from "@metro/internals/caches"; +import { ModuleFlags } from "@metro/internals/enums"; +import { requireModule } from "@metro/internals/modules"; + +import { assetsModule } from "./patches"; + +export interface Asset { + id: number; + name: string; + moduleId: number; + type: string; +} + +// Cache common usage +const _nameToAssetCache = {} as Record; + +export function* iterateAssets() { + const { flagsIndex } = getMetroCache(); + const yielded = new Set(); + + for (const id in flagsIndex) { + if (flagsIndex[id] & ModuleFlags.ASSET) { + const assetId = requireModule(Number(id)); + if (typeof assetId !== "number" || yielded.has(assetId)) continue; + yield getAssetById(assetId); + yielded.add(assetId); + } + } +} + +// Apply additional properties for convenience +function getAssetById(id: number): Asset { + const asset = assetsModule.getAssetByID(id); + if (!asset) return asset; + return Object.assign(asset, { id }); +} + +/** + * Returns the first asset registry by its registry id (number), name (string) or given filter (function) + */ +export function findAsset(id: number): Asset | undefined; +export function findAsset(name: string): Asset | undefined; +export function findAsset(filter: (a: Asset) => boolean): Asset | undefined; + +export function findAsset(param: number | string | ((a: Asset) => boolean)) { + if (typeof param === "number") return getAssetById(param); + + if (typeof param === "string" && _nameToAssetCache[param]) { + return _nameToAssetCache[param]; + } + + for (const asset of iterateAssets()) { + if (typeof param === "string" && asset.name === param) { + _nameToAssetCache[param] = asset; + return asset; + } else if (typeof param === "function" && param(asset)) { + return asset; + } + } +} + +export function filterAssets(param: string | ((a: Asset) => boolean)) { + const filteredAssets = [] as Array; + + for (const asset of iterateAssets()) { + if (typeof param === "string" ? asset.name === param : param(asset)) { + filteredAssets.push(asset); + } + } + + return filteredAssets; +} + +/** + * Returns the first asset ID in the registry with the given name + */ +export function findAssetId(name: string) { + return findAsset(name)?.id; +} diff --git a/api/assets/patches.ts b/api/assets/patches.ts new file mode 100644 index 0000000..1a204cd --- /dev/null +++ b/api/assets/patches.ts @@ -0,0 +1,25 @@ +import { after } from "@lib/api/patcher"; +import { indexAssetModuleFlag } from "@metro/internals/caches"; +import { getImportingModuleId } from "@metro/internals/modules"; + +interface AssetModule { + registerAsset(assetDefinition: any): number; + getAssetByID(id: number): any; +} + +export let assetsModule: AssetModule; + +/** + * @internal + */ +export function patchAssets(module: AssetModule) { + if (assetsModule) return; + assetsModule = module; + + const unpatch = after("registerAsset", assetsModule, () => { + const moduleId = getImportingModuleId(); + if (moduleId !== -1) indexAssetModuleFlag(moduleId); + }); + + return unpatch; +} diff --git a/api/commands/index.ts b/api/commands/index.ts new file mode 100644 index 0000000..7a295d8 --- /dev/null +++ b/api/commands/index.ts @@ -0,0 +1,84 @@ +import { ApplicationCommand, ApplicationCommandInputType, ApplicationCommandType, BunnyApplicationCommand } from "@lib/api/commands/types"; +import { after, instead } from "@lib/api/patcher"; +import { logger } from "@lib/utils/logger"; +import { commands as commandsModule, messageUtil } from "@metro/common"; + +let commands: ApplicationCommand[] = []; + +/** + * @internal + */ +export function patchCommands() { + const unpatch = after("getBuiltInCommands", commandsModule, ([type], res: ApplicationCommand[]) => { + return [...res, ...commands.filter(c => + (type instanceof Array ? type.includes(c.type) : type === c.type) + && c.__bunny?.shouldHide?.() !== false) + ]; + }); + + // Register core commands + [ + require("@core/commands/eval"), + require("@core/commands/debug"), + require("@core/commands/plugins"), + require("@core/commands/reload") + ].forEach(r => registerCommand(r.default())); + + return () => { + commands = []; + unpatch(); + }; +} + +export function registerCommand(command: BunnyApplicationCommand): () => void { + // Get built in commands + let builtInCommands: ApplicationCommand[]; + try { + builtInCommands = commandsModule.getBuiltInCommands(ApplicationCommandType.CHAT, true, false); + } catch { + builtInCommands = commandsModule.getBuiltInCommands(Object.values(ApplicationCommandType), true, false); + } + + builtInCommands.sort((a: ApplicationCommand, b: ApplicationCommand) => parseInt(b.id!) - parseInt(a.id!)); + + const lastCommand = builtInCommands[builtInCommands.length - 1]; + + // Override the new command's id to the last command id - 1 + command.id = (parseInt(lastCommand.id!, 10) - 1).toString(); + + // Fill optional args + command.__bunny = { + shouldHide: command.shouldHide + }; + + command.applicationId ??= "-1"; + command.type ??= ApplicationCommandType.CHAT; + command.inputType = ApplicationCommandInputType.BUILT_IN; + command.displayName ??= command.name; + command.untranslatedName ??= command.name; + command.displayDescription ??= command.description; + command.untranslatedDescription ??= command.description; + + if (command.options) for (const opt of command.options) { + opt.displayName ??= opt.name; + opt.displayDescription ??= opt.description; + } + + instead("execute", command, (args, orig) => { + Promise.resolve( + orig.apply(command, args) + ).then(ret => { + if (ret && typeof ret === "object") { + messageUtil.sendMessage(args[1].channel.id, ret); + } + }).catch(err => { + logger.error("Failed to execute command", err); + }); + }); + + // Add it to the commands array + commands.push(command); + + // Return command id so it can be unregistered + return () => (commands = commands.filter(({ id }) => id !== command.id)); +} diff --git a/api/commands/types.ts b/api/commands/types.ts new file mode 100644 index 0000000..c971499 --- /dev/null +++ b/api/commands/types.ts @@ -0,0 +1,76 @@ +export interface Argument { + type: ApplicationCommandOptionType, + name: string, + value: string, + focused: undefined; + options: Argument[]; +} + +export interface ApplicationCommand { + name: string; + description: string; + execute: (args: Argument[], ctx: CommandContext) => CommandResult | void | Promise | Promise; + options: ApplicationCommandOption[]; + id?: string; + applicationId?: string; + displayName?: string; + displayDescription?: string; + untranslatedDescription?: string; + untranslatedName?: string; + inputType?: ApplicationCommandInputType; + type?: ApplicationCommandType; + __bunny?: { + shouldHide: () => boolean; + }; +} + +export interface BunnyApplicationCommand extends ApplicationCommand { + shouldHide: () => boolean; +} + +export enum ApplicationCommandInputType { + BUILT_IN, + BUILT_IN_TEXT, + BUILT_IN_INTEGRATION, + BOT, + PLACEHOLDER, +} + +export interface ApplicationCommandOption { + name: string; + description: string; + required?: boolean; + type: ApplicationCommandOptionType; + displayName?: string; + displayDescription?: string; +} + +export enum ApplicationCommandOptionType { + SUB_COMMAND = 1, + SUB_COMMAND_GROUP, + STRING, + INTEGER, + BOOLEAN, + USER, + CHANNEL, + ROLE, + MENTIONABLE, + NUMBER, + ATTACHMENT, +} + +export enum ApplicationCommandType { + CHAT = 1, + USER, + MESSAGE, +} + +export interface CommandContext { + channel: any; + guild: any; +} + +export interface CommandResult { + content: string; + tts?: boolean; +} diff --git a/api/debug.ts b/api/debug.ts new file mode 100644 index 0000000..870739b --- /dev/null +++ b/api/debug.ts @@ -0,0 +1,201 @@ +import { getThemeFromLoader, selectTheme, themes } from "@lib/addons/themes"; +import { findAssetId } from "@lib/api/assets"; +import { getLoaderName, getLoaderVersion, isThemeSupported, getReactDevToolsProp } from "@lib/api/native/loader"; +import { BundleUpdaterManager, NativeClientInfoModule, NativeDeviceModule } from "@lib/api/native/modules"; +import { after } from "@lib/api/patcher"; +import { settings } from "@lib/api/settings"; +import { logger } from "@lib/utils/logger"; +import { showToast } from "@ui/toasts"; +import { version } from "bunny-build-info"; +import { Platform, type PlatformConstants } from "react-native"; +import { StyleSheet } from "react-native"; + +export interface RNConstants extends PlatformConstants { + // Android + Version: number; + Release: string; + Serial: string; + Fingerprint: string; + Model: string; + Brand: string; + Manufacturer: string; + ServerHost?: string; + + // iOS + forceTouchAvailable: boolean; + interfaceIdiom: string; + osVersion: string; + systemName: string; +} + +/** + * @internal + */ +export async function toggleSafeMode() { + settings.safeMode = { ...settings.safeMode, enabled: !settings.safeMode?.enabled }; + if (isThemeSupported()) { + if (getThemeFromLoader()?.id) settings.safeMode!.currentThemeId = getThemeFromLoader()!.id; + if (settings.safeMode?.enabled) { + await selectTheme(null); + } else if (settings.safeMode?.currentThemeId) { + await selectTheme(themes[settings.safeMode?.currentThemeId]); + } + } + setTimeout(BundleUpdaterManager.reload, 400); +} + +let socket: WebSocket; +export function connectToDebugger(url: string) { + if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) socket.close(); + + if (!url) { + showToast("Invalid debugger URL!", findAssetId("Small")); + return; + } + + socket = new WebSocket(`ws://${url}`); + + socket.addEventListener("open", () => showToast("Connected to debugger.", findAssetId("Check"))); + socket.addEventListener("message", (message: any) => { + try { + (0, eval)(message.data); + } catch (e) { + console.error(e); + } + }); + + socket.addEventListener("error", (err: any) => { + console.log(`Debugger error: ${err.message}`); + showToast("An error occurred with the debugger connection!", findAssetId("Small")); + }); +} + +/** + * @internal + */ +export function patchLogHook() { + const unpatch = after("nativeLoggingHook", globalThis, args => { + if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ message: args[0], level: args[1] })); + logger.log(args[0]); + }); + + return () => { + socket && socket.close(); + unpatch(); + }; +} + +/** @internal */ +export const versionHash = version; + +export function getDebugInfo() { + // Hermes + const hermesProps = window.HermesInternal.getRuntimeProperties(); + const hermesVer = hermesProps["OSS Release Version"]; + const padding = "for RN "; + + // RN + const PlatformConstants = Platform.constants as RNConstants; + const rnVer = PlatformConstants.reactNativeVersion; + + return { + /** + * @deprecated use `bunny` field + * */ + vendetta: { + version: versionHash.split("-")[0], + loader: getLoaderName(), + }, + bunny: { + version: versionHash, + loader: { + name: getLoaderName(), + version: getLoaderVersion() + } + }, + discord: { + version: NativeClientInfoModule.getConstants().Version, + build: NativeClientInfoModule.getConstants().Build, + }, + react: { + version: React.version, + nativeVersion: hermesVer.startsWith(padding) ? hermesVer.substring(padding.length) : `${rnVer.major}.${rnVer.minor}.${rnVer.patch}`, + }, + hermes: { + version: hermesVer, + buildType: hermesProps.Build, + bytecodeVersion: hermesProps["Bytecode Version"], + }, + ...Platform.select( + { + android: { + os: { + name: "Android", + version: PlatformConstants.Release, + sdk: PlatformConstants.Version + }, + }, + ios: { + os: { + name: PlatformConstants.systemName, + version: PlatformConstants.osVersion + }, + } + } + )!, + ...Platform.select( + { + android: { + device: { + manufacturer: PlatformConstants.Manufacturer, + brand: PlatformConstants.Brand, + model: PlatformConstants.Model, + codename: NativeDeviceModule.device + } + }, + ios: { + device: { + manufacturer: NativeDeviceModule.deviceManufacturer, + brand: NativeDeviceModule.deviceBrand, + model: NativeDeviceModule.deviceModel, + codename: NativeDeviceModule.device + } + } + } + )! + }; +} + + +/** + * @internal + */ +export function initDebugger() { + if (settings.autoDebugger) { + try { + connectToDebugger(settings.debuggerUrl); + } catch (e) { + logger.error("Failed to connect to Debugger during startup:", e); + } + } + if (settings.autoDevTools) { + try { + (async () => { + const devTools = window[getReactDevToolsProp() || "__vendetta_rdc"]; + + if (!devTools?.connectToDevTools) { + showToast("Invalid devTools URL!", findAssetId("Small")); + return; + } + + await devTools.connectToDevTools({ + host: settings.devToolsUrl.split(":")?.[0], + resolveRNStyle: StyleSheet.flatten, + }); + })(); + } catch (e) { + logger.error("Failed to connect to ReactDevTools during startup:", e); + } + } + +} diff --git a/api/flux/index.ts b/api/flux/index.ts new file mode 100644 index 0000000..880902c --- /dev/null +++ b/api/flux/index.ts @@ -0,0 +1,49 @@ +// shelter-mod inspired +import { FluxDispatcher } from "@metro/common"; + +const blockedSym = Symbol.for("bunny.flux.blocked"); +const modifiedSym = Symbol.for("bunny.flux.modified"); + +export const dispatcher = FluxDispatcher; + +type Intercept = (payload: Record & { type: string; }) => any; +let intercepts: Intercept[] = []; + +/** + * @internal + */ +export function injectFluxInterceptor() { + const cb = (payload: any) => { + for (const intercept of intercepts) { + const res = intercept(payload); + + // nullish -> nothing, falsy -> block, object -> modify + if (res == null) { + continue; + } else if (!res) { + payload[blockedSym] = true; + } else if (typeof res === "object") { + Object.assign(payload, res); + payload[modifiedSym] = true; + } + } + + return blockedSym in payload; + }; + + (dispatcher._interceptors ??= []).unshift(cb); + + return () => dispatcher._interceptors &&= dispatcher._interceptors.filter(v => v !== cb); +} + +/** + * Intercept Flux dispatches. Return type affects the end result, where + * nullish -> nothing, falsy -> block, object -> modify + */ +export function intercept(cb: Intercept) { + intercepts.push(cb); + + return () => { + intercepts = intercepts.filter(i => i !== cb); + }; +} diff --git a/api/index.ts b/api/index.ts new file mode 100644 index 0000000..4d9057a --- /dev/null +++ b/api/index.ts @@ -0,0 +1,9 @@ +export * as assets from "./assets"; +export * as commands from "./commands"; +export * as debug from "./debug"; +export * as flux from "./flux"; +export * as native from "./native"; +export * as patcher from "./patcher"; +export * as react from "./react"; +export * as settings from "./settings"; +export * as storage from "./storage"; diff --git a/api/native/fs.ts b/api/native/fs.ts new file mode 100644 index 0000000..131dbfa --- /dev/null +++ b/api/native/fs.ts @@ -0,0 +1,76 @@ +import { NativeFileModule } from "./modules"; + +/** + * Removes all files in a directory from the path given + * @param path Path to the targeted directory + */ +export async function clearFolder(path: string, { prefix = "pyoncord/" } = {}) { + if (typeof NativeFileModule.clearFolder !== "function") throw new Error("'fs.clearFolder' is not supported"); + return void await NativeFileModule.clearFolder("documents", `${prefix}${path}`); +} + +/** + * Remove file from given path, currently no check for any failure + * @param path Path to the file + */ +export async function removeFile(path: string, { prefix = "pyoncord/" } = {}) { + if (typeof NativeFileModule.removeFile !== "function") throw new Error("'fs.removeFile' is not supported"); + return void await NativeFileModule.removeFile("documents", `${prefix}${path}`); +} + +/** + * Remove file from given path, currently no check for any failure + * @param path Path to the file + */ +export async function removeCacheFile(path: string, prefix = "pyoncord/") { + if (typeof NativeFileModule.removeFile !== "function") throw new Error("'fs.removeFile' is not supported"); + return void await NativeFileModule.removeFile("cache", `${prefix}${path}`); +} + +/** + * Check if the file or directory given by the path exists + * @param path Path to the file + */ +export async function fileExists(path: string, { prefix = "pyoncord/" } = {}) { + return await NativeFileModule.fileExists(`${NativeFileModule.getConstants().DocumentsDirPath}/${prefix}${path}`); +} + +/** + * A wrapper to write to a file to the documents directory + * @param path Path to the file + * @param data String data to write to the file + */ +export async function writeFile(path: string, data: string, { prefix = "pyoncord/" } = {}): Promise { + if (typeof data !== "string") throw new Error("Argument 'data' must be a string"); + return void await NativeFileModule.writeFile("documents", `${prefix}${path}`, data, "utf8"); +} + +/** + * A wrapper to read a file from the documents directory + * @param path Path to the file + * @param fallback Fallback data to return if the file doesn't exist, and will be written to the file + */ +export async function readFile(path: string, { prefix = "pyoncord/" } = {}): Promise { + try { + return await NativeFileModule.readFile(`${NativeFileModule.getConstants().DocumentsDirPath}/${prefix}${path}`, "utf8"); + } catch (err) { + throw new Error(`An error occured while writing to '${path}'`, { cause: err }); + } +} + +/** + * Download a file from the given URL and save it to the path given + * @param url URL to download the file from + * @param path Path to save the file to + */ +export async function downloadFile(url: string, path: string, { prefix = "pyoncord/" } = {}) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download file from ${url}: ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const data = Buffer.from(arrayBuffer).toString("base64"); + + await NativeFileModule.writeFile("documents", `${prefix}${path}`, data, "base64"); +} diff --git a/api/native/index.ts b/api/native/index.ts new file mode 100644 index 0000000..997738e --- /dev/null +++ b/api/native/index.ts @@ -0,0 +1 @@ +export * as fs from "./fs"; diff --git a/api/native/loader.ts b/api/native/loader.ts new file mode 100644 index 0000000..e38b405 --- /dev/null +++ b/api/native/loader.ts @@ -0,0 +1,254 @@ +import { VdThemeInfo } from "@lib/addons/themes"; +import { removeCacheFile } from "./fs"; + +// @ts-ignore +const pyonLoaderIdentity = globalThis.__PYON_LOADER__; + +// @ts-ignore +const rainLoaderIdentity = globalThis.__RAIN_LOADER__; + +// @ts-ignore +const vendettaLoaderIdentity = globalThis.__vendetta_loader; + +export interface VendettaLoaderIdentity { + name: string; + features: { + loaderConfig?: boolean; + devtools?: { + prop: string; + version: string; + }, + themes?: { + prop: string; + }; + }; +} + +export function isVendettaLoader() { + return vendettaLoaderIdentity != null; +} + +export function isPyonLoader() { + return pyonLoaderIdentity != null; +} + +export function isRa1nLoader() { + return rainLoaderIdentity != null; +} + +function polyfillVendettaLoaderIdentity() { + if (!isPyonLoader() || isVendettaLoader() || !isRa1nLoader()) return null; + + let loader: { name: string; features: Record }; + + if (isRa1nLoader() == true) { + loader = { + name: rainLoaderIdentity.loaderName, + features: {} as Record, + }; + } else { + loader = { + name: pyonLoaderIdentity.loaderName, + features: {} as Record, + }; + } + + if (isLoaderConfigSupported()) loader.features.loaderConfig = true; + if (isSysColorsSupported()) { + loader.features.syscolors = { + prop: "__vendetta_syscolors" + }; + + Object.defineProperty(globalThis, "__vendetta_syscolors", { + get: () => getSysColors(), + configurable: true + }); + } + if (isThemeSupported()) { + loader.features.themes = { + prop: "__vendetta_theme" + }; + + Object.defineProperty(globalThis, "__vendetta_theme", { + // get: () => getStoredTheme(), + get: () => { + // PyonXposed only returns keys it parses, making custom keys like Themes+' to gone + const id = getStoredTheme()?.id; + if (!id) return null; + + const { themes } = require("@lib/addons/themes"); + return themes[id] ?? getStoredTheme() ?? null; + }, + configurable: true + }); + } + + Object.defineProperty(globalThis, "__vendetta_loader", { + get: () => loader, + configurable: true + }); + + return loader as VendettaLoaderIdentity; +} + +export function getLoaderIdentity() { + if (isPyonLoader()) { + return pyonLoaderIdentity; + } else if (isVendettaLoader()) { + return getVendettaLoaderIdentity(); + } else if (isRa1nLoader()) { + return rainLoaderIdentity(); + } + + return null; +} + +export function getVendettaLoaderIdentity(): VendettaLoaderIdentity | null { + // @ts-ignore + if (globalThis.__vendetta_loader) return globalThis.__vendetta_loader; + return polyfillVendettaLoaderIdentity(); +} + +// add to __vendetta_loader anyway +getVendettaLoaderIdentity(); + +export function getLoaderName() { + if (isPyonLoader()) return pyonLoaderIdentity.loaderName; + else if (isRa1nLoader()) return rainLoaderIdentity.loadername; + else if (isVendettaLoader()) return vendettaLoaderIdentity.name; + + return "Unknown"; +} + +export function getLoaderVersion(): string | null { + if (isPyonLoader()) return pyonLoaderIdentity.loaderVersion; + else if (isRa1nLoader()) return rainLoaderIdentity.loaderVersion; + return null; +} + +export function isLoaderConfigSupported() { + if (isPyonLoader()) { + return true; + } else if (isVendettaLoader()) { + return vendettaLoaderIdentity!!.features.loaderConfig; + } else if (isRa1nLoader()) { + return true; + } + + return false; +} + +export function isThemeSupported() { + if (isPyonLoader()) { + return pyonLoaderIdentity.hasThemeSupport; + } else if (isVendettaLoader()) { + return vendettaLoaderIdentity!!.features.themes != null; + } else if (isRa1nLoader()) { + return false; // Ra1n has theme support disabled, this is here just to make sure it doesnt think it does + } + + return false; +} + +export function getStoredTheme(): VdThemeInfo | null { + if (isPyonLoader()) { + return pyonLoaderIdentity.storedTheme; + } else if (isVendettaLoader()) { + const themeProp = vendettaLoaderIdentity!!.features.themes?.prop; + if (!themeProp) return null; + // @ts-ignore + return globalThis[themeProp] || null; + } + + return null; +} + +export function getThemeFilePath() { + if (isPyonLoader()) { + return "pyoncord/current-theme.json"; + } else if (isVendettaLoader()) { + return "vendetta_theme.json"; + } + + return null; +} + +export function isReactDevToolsPreloaded() { + if (isPyonLoader()) { + return Boolean(window.__reactDevTools); + } + if (isRa1nLoader()) { + return false; + } + if (isVendettaLoader()) { + return vendettaLoaderIdentity!!.features.devtools != null; + } + + return false; +} + +export function getReactDevToolsProp(): string | null { + if (!isReactDevToolsPreloaded()) return null; + + if (isPyonLoader()) { + window.__pyoncord_rdt = window.__reactDevTools.exports; + return "__pyoncord_rdt"; + } + + if (isVendettaLoader()) { + return vendettaLoaderIdentity!!.features.devtools!!.prop; + } + + return null; +} + +export function getReactDevToolsVersion() { + if (!isReactDevToolsPreloaded()) return null; + + if (isPyonLoader()) { + return window.__reactDevTools.version || null; + } + if (isVendettaLoader()) { + return vendettaLoaderIdentity!!.features.devtools!!.version; + } + + return null; +} + +export function isSysColorsSupported() { + return true; +} + +export function getSysColors() { + if (!isSysColorsSupported()) return null; + if (isPyonLoader()) { + return pyonLoaderIdentity.sysColors; + } else if (isVendettaLoader()) { + return vendettaLoaderIdentity!!.features.syscolors!!.prop; + } + + return null; +} + +export function getLoaderConfigPath() { + if (isPyonLoader()) { + return "pyoncord/loader.json"; + } else if (isVendettaLoader()) { + return "vendetta_loader.json"; + } else if (isRa1nLoader()) { + return "rain/loader.json"; + } + + return "loader.json"; +} + +export function isFontSupported() { + if (isPyonLoader()) return pyonLoaderIdentity.fontPatch === 2; + + return false; +} + +export async function clearBundle() { + // TODO: This should be not be hardcoded, maybe put in loader.json? + return void await removeCacheFile("bundle.js"); +} \ No newline at end of file diff --git a/api/native/modules/Loader.ts b/api/native/modules/Loader.ts new file mode 100644 index 0000000..57e4284 --- /dev/null +++ b/api/native/modules/Loader.ts @@ -0,0 +1,82 @@ +import { ImageLoader } from "./"; +import type { LoaderPayload } from "./types"; + +type InteropReturn = Promise<{ ret: string } | { err: string } | { cancelled: true; reason: string }>; + +export const loaderPayload = window.__PYON_LOADER__ as LoaderPayload; + +export function isModuleRegistered(module: string) { + return loaderPayload.loader.modules[module] !== undefined; +} + +export function isFunctionRegistered(module: string, function_: string) { + return loaderPayload.loader.modules[module]?.functions[function_] !== undefined; +} + +export async function callFunction(module: string, functionName: string, args: unknown[]) { + if (!isFunctionRegistered(module, functionName)) { + throw new Error(`Function ${module}.${functionName} is not registered`); + } + + const promise: InteropReturn = ImageLoader.queryCache([ + "__pyon_bridge", + JSON.stringify({ m: module, f: functionName, a: args }), + ]); + + const result = await promise; + + if ("ret" in result) { + return JSON.parse(result.ret); + } + + if ("cancelled" in result) { + throw new Error(`Function ${module}.${functionName} was cancelled: ${result.reason}`); + } + + if ("err" in result) { + throw new Error(result.err); + } +} + +export interface LoaderModule { + hasFunction(functionName: string): boolean; + callFunction(functionName: string, args: unknown[]): Promise; +} + +type ModuleProps = { + name: string; + argumentProcessors?: Partial<{ [key in keyof T]: (args: any[]) => any[] }>; +}; + +export function getModule({ name, argumentProcessors: processArgs }: ModuleProps) { + const module = loaderPayload.loader.modules[name]; + if (!module) { + throw new Error(`Module ${name} is not registered`); + } + + const baseObject: Record = { + hasFunction: (functionName: string) => isFunctionRegistered(name, functionName), + callFunction: (functionName: string, args: unknown[]) => callFunction(name, functionName, args), + }; + + for (const [functionName] of Object.entries(module.functions)) { + baseObject[functionName] = (...args: unknown[]) => + callFunction( + name, + functionName, + // @ts-expect-error + processArgs && functionName in processArgs ? processArgs[functionName](args) : args, + ); + } + + return baseObject as T; +} + +/** + * Determines if safe mode is currently enabled. The result remains consistent over time. + * This will not change until the app is restarted. For draft changes, use the InitConfigStore. + * @returns True if safe mode is enabled for the current instance, otherwise false. + */ +export function isSafeModeEnabled() { + return Boolean(loaderPayload.loader.initConfig.safeMode); +} diff --git a/api/native/modules/bubblemodule.ts b/api/native/modules/bubblemodule.ts new file mode 100644 index 0000000..c6e3d85 --- /dev/null +++ b/api/native/modules/bubblemodule.ts @@ -0,0 +1,16 @@ +import { processColor } from "react-native"; +import { getModule } from "./loader"; +import type { LoaderModule } from "./loader"; + +interface BubbleModule extends LoaderModule { + hookBubbles(): Promise; + unhookBubbles(): Promise; + configure(avatarRadius?: number, bubbleRadius?: number, bubbleColor?: string): Promise; +} + +export default getModule({ + name: "BubbleModule", + argumentProcessors: { + configure: args => [args[0], args[1], Number(processColor(args[2]))], + }, +}); diff --git a/api/native/modules/index.ts b/api/native/modules/index.ts new file mode 100644 index 0000000..a8d8f65 --- /dev/null +++ b/api/native/modules/index.ts @@ -0,0 +1,38 @@ +import { RNModules } from "./types"; + +const nmp = window.nativeModuleProxy; + +function getNativeModule(...names: string[]): T | undefined { + for (const name of names) { + if (globalThis.__turboModuleProxy) { + const module = globalThis.__turboModuleProxy(name); + if (module) return module as T; + } + + if (nmp[name]) return nmp[name] as T; + } + + return undefined; +} + +export const NativeCacheModule = getNativeModule( + "NativeCacheModule", "MMKVManager" +)!; +export const NativeFileModule = getNativeModule( + "NativeFileModule", "RTNFileManager", "DCDFileManager" +)!; +export const NativeClientInfoModule = getNativeModule( + "NativeClientInfoModule", "RTNClientInfoManager", "InfoDictionaryManager" +)!; +export const NativeDeviceModule = getNativeModule( + "NativeDeviceModule", "RTNDeviceManager", "DCDDeviceManager" +)!; +export const NativeThemeModule = getNativeModule( + "NativeThemeModule", "RTNThemeManager", "DCDTheme" +)!; +export const BundleUpdaterManager = getNativeModule( + "BundleUpdaterManager" +)!; +export const ImageLoader = getNativeModule( + "ImageLoader" +)!; diff --git a/api/native/modules/loader.ts b/api/native/modules/loader.ts new file mode 100644 index 0000000..7a6fd65 --- /dev/null +++ b/api/native/modules/loader.ts @@ -0,0 +1,82 @@ +import { ImageLoader } from "./"; +import type { LoaderPayload } from "./types"; + +type InteropReturn = Promise<{ ret: string } | { err: string } | { cancelled: true; reason: string }>; + +export const loaderPayload = window.__PYON_LOADER__ as LoaderPayload; + +export function isModuleRegistered(module: string) { + return loaderPayload.loader.modules[module] !== undefined; +} + +export function isFunctionRegistered(module: string, function_: string) { + return loaderPayload.loader.modules[module]?.functions[function_] !== undefined; +} + +export async function callFunction(module: string, functionName: string, args: unknown[]) { + if (!isFunctionRegistered(module, functionName)) { + throw new Error(`Function ${module}.${functionName} is not registered`); + } + + const promise: InteropReturn = ImageLoader.queryCache([ + "__pyon_bridge", + JSON.stringify({ m: module, f: functionName, a: args }), + ]); + + const result = await promise; + + if ("ret" in result) { + return JSON.parse(result.ret); + } + + if ("cancelled" in result) { + throw new Error(`Function ${module}.${functionName} was cancelled: ${result.reason}`); + } + + if ("err" in result) { + throw new Error(result.err); + } +} + +export interface LoaderModule { + hasFunction(functionName: string): boolean; + callFunction(functionName: string, args: unknown[]): Promise; +} + +type ModuleProps = { + name: string; + argumentProcessors?: Partial<{ [key in keyof T]: (args: any[]) => any[] }>; +}; + +export function getModule({ name, argumentProcessors: processArgs }: ModuleProps) { + const module = loaderPayload.loader.modules[name]; + if (!module) { + throw new Error(`Module ${name} is not registered`); + } + + const baseObject: Record = { + hasFunction: (functionName: string) => isFunctionRegistered(name, functionName), + callFunction: (functionName: string, args: unknown[]) => callFunction(name, functionName, args), + }; + + for (const [functionName] of Object.entries(module.functions)) { + baseObject[functionName] = (...args: unknown[]) => + callFunction( + name, + functionName, + // @ts-expect-error + processArgs && functionName in processArgs ? processArgs[functionName](args) : args, + ); + } + + return baseObject as T; +} + +/** + * Determines if safe mode is currently enabled. The result remains consistent over time. + * This will not change until the app is restarted. For draft changes, use the InitConfigStore. + * @returns True if safe mode is enabled for the current instance, otherwise false. + */ +export function isSafeModeEnabled() { + return Boolean(loaderPayload.loader.initConfig.safeMode); +} \ No newline at end of file diff --git a/api/native/modules/types.ts b/api/native/modules/types.ts new file mode 100644 index 0000000..a20d02e --- /dev/null +++ b/api/native/modules/types.ts @@ -0,0 +1,175 @@ +export namespace RNModules { + /** + * A key-value storage based upon `SharedPreferences` on Android. + * + * These types are based on Android though everything should be the same between + * platforms. + */ + export interface MMKVManager { + /** + * Get the value for the given `key`, or null + * @param key The key to fetch + */ + getItem: (key: string) => Promise; + /** + * Deletes the value for the given `key` + * @param key The key to delete + */ + removeItem: (key: string) => void; + /** + * Sets the value of `key` to `value` + */ + setItem: (key: string, value: string) => void; + /** + * Goes through every item in storage and returns it, excluding the + * keys specified in `exclude`. + * @param exclude A list of items to exclude from result + */ + refresh: (exclude: string[]) => Promise>; + /** + * You will be murdered if you use this function. + * Clears ALL of Discord's settings. + */ + clear: () => void; + } + + export interface FileManager { + /** + * @param path **Full** path to file + */ + fileExists: (path: string) => Promise; + /** + * Allowed URI schemes on Android: `file://`, `content://` ([See here](https://developer.android.com/reference/android/content/ContentResolver#accepts-the-following-uri-schemes:_3)) + */ + getSize: (uri: string) => Promise; + /** + * @param path **Full** path to file + * @param encoding Set to `base64` in order to encode response + */ + readFile(path: string, encoding: "base64" | "utf8"): Promise; + saveFileToGallery?(uri: string, fileName: string, fileType: "PNG" | "JPEG"): Promise; + /** + * @param storageDir Either `cache` or `documents`. + * @param path Path in `storageDir`, parents are recursively created. + * @param data The data to write to the file + * @param encoding Set to `base64` if `data` is base64 encoded. + * @returns Promise that resolves to path of the file once it got written + */ + writeFile(storageDir: "cache" | "documents", path: string, data: string, encoding: "base64" | "utf8"): Promise; + /** + * Removes a file from the path given. + * (!) On Android, this always returns false, regardless if it fails or not! + * @param storageDir Either `cache` or `documents` + * @param path Path to the file to be removed + */ + removeFile(storageDir: "cache" | "documents", path: string): Promise; + /** + * Clear the folder from the path given + * (!) On Android, this only clears all *files* and not subdirectories! + * @param storageDir Either `cache` or `documents` + * @param path Path to the folder to be cleared + * @returns Whether the clearance succeeded + */ + clearFolder(storageDir: "cache" | "documents", path: string): Promise; + getConstants: () => { + /** + * The path the `documents` storage dir (see {@link writeFile}) represents. + */ + DocumentsDirPath: string; + CacheDirPath: string; + }; + /** + * Will apparently cease to exist some time in the future so please use {@link getConstants} instead. + * @deprecated + */ + DocumentsDirPath: string; + } + + export interface ClientInfoModule { + getConstants(): { + /** + * Sentry ingestion DSN URL for alpha/beta builds + */ + SentryAlphaBetaDsn: string + /** + * Sentry ingestion DSN URL for staff builds (?) + */ + SentryStaffDsn: string + /** + * Sentry ingestion DSN URL for stable builds + */ + SentryDsn: string + DeviceVendorID: string + Manifest: string + /** + * Version code + * + * Follows the format of `{MINOR}{CHANNEL}{PATCH}` for `{MINOR}.{PATCH} ({CHANNEL})` + * - `248200` for `248.0 (alpha)` + * - `247105` for `247.5 (beta)` + * - `246011` for `246.11 (stable)` + */ + Build: string + /** + * Version string + * + * Eg. `248.0` + */ + Version: string + /** + * Release channel + */ + ReleaseChannel: string + /** + * Matches `Version` + */ + OTABuild: string + /** + * Identifier for the installed client + * + * - **Android**: Package name + * - **iOS**: Bundle ID + */ + Identifier: string + } + } +} + +export interface InitConfig { + baseUrl: string; + forceUpdate: boolean; + skipUpdate: boolean; + bundlePath: string | null; + safeMode: boolean; +} + +interface LoaderConstants { + WINTRY_DIR: string; + DEFAULT_BASE_URL: string; +} + +interface LoaderModule { + functions: { + [functionName: string]: number; + }; + constants: { + [constantName: string]: any; + }; +} + +interface LoaderModules { + [moduleName: string]: LoaderModule; +} + +export interface LoaderPayload { + bundle: { + revision: string; + }; + loader: { + name: string; + version: string; + initConfig: InitConfig; + constants: LoaderConstants; + modules: LoaderModules; + }; +} diff --git a/api/patcher.ts b/api/patcher.ts new file mode 100644 index 0000000..098c831 --- /dev/null +++ b/api/patcher.ts @@ -0,0 +1,67 @@ +const { + after: _after, + before: _before, + instead: _instead +} = require("spitroast"); + +/** @internal */ +export const _patcherDelaySymbol = Symbol.for("bunny.api.patcher.delay"); + +type Unpatcher = () => boolean; +type DelayCallback = (callback: (target: any) => void) => unknown; +type Thenable = { then: typeof Promise.prototype.then }; + +interface PatchFn { + (func: string, parent: any, callback: Callback, once?: boolean): Unpatcher; + await(func: string, parent: Promise, callback: Callback, once?: boolean): Unpatcher; +} + +type BeforeFn = PatchFn<(args: any[]) => unknown | unknown[]>; +type InsteadFn = PatchFn<(args: any[], origFunc: Function) => unknown>; +type AfterFn = PatchFn<(args: any[], ret: any) => unknown>; + +function create(fn: Function) { + function patchFn(this: any, ...args: any[]) { + if (typeof args[1][_patcherDelaySymbol] === "function") { + const delayCallback: DelayCallback = args[1][_patcherDelaySymbol]; + + let cancel = false; + let unpatch = () => cancel = true; + + delayCallback(target => { + if (cancel) return; + args[1] = target; + unpatch = fn.apply(this, args); + }); + + return () => unpatch(); + } + + return fn.apply(this, args); + } + + function promisePatchFn(this: any, ...args: [any, Thenable, ...any]) { + const thenable = args[1]; + if (!thenable || !("then" in thenable)) throw new Error("target is not a then-able object"); + + let cancel = false; + let unpatch = () => cancel = true; + + thenable.then(target => { + if (cancel) return; + args[1] = target; + unpatch = patchFn.apply(this, args); + }); + + return () => unpatch(); + } + + return Object.assign(patchFn, { await: promisePatchFn }); +} + +export const after = create(_after) as AfterFn; +export const before = create(_before) as BeforeFn; +export const instead = create(_instead) as InsteadFn; + +/** @internal */ +export default { after, before, instead }; diff --git a/api/react/index.ts b/api/react/index.ts new file mode 100644 index 0000000..03149cd --- /dev/null +++ b/api/react/index.ts @@ -0,0 +1 @@ +export * as jsx from "./jsx"; diff --git a/api/react/jsx.ts b/api/react/jsx.ts new file mode 100644 index 0000000..b3d025b --- /dev/null +++ b/api/react/jsx.ts @@ -0,0 +1,49 @@ +import { after } from "@lib/api/patcher"; +import { findByPropsLazy } from "@metro"; + +type Callback = (Component: any, ret: JSX.Element) => JSX.Element; +const callbacks = new Map(); + +const jsxRuntime = findByPropsLazy("jsx", "jsxs"); + +export function onJsxCreate(Component: string, callback: Callback) { + if (!callbacks.has(Component)) callbacks.set(Component, []); + callbacks.get(Component)!.push(callback); +} + +export function deleteJsxCreate(Component: string, callback: Callback) { + if (!callbacks.has(Component)) return; + const cbs = callbacks.get(Component)!; + cbs.splice(cbs.indexOf(callback), 1); + if (cbs.length === 0) callbacks.delete(Component); +} + +/** + * @internal + */ +export function patchJsx() { + const callback = ([Component]: unknown[], ret: JSX.Element) => { + // Band-aid fix for iOS invalid element type crashes + if (typeof ret.type === 'undefined') { + ret.type = 'RCTView' + return ret + } + + // The check could be more complex, but this is fine for now to avoid overhead + if (typeof Component === "function" && callbacks.has(Component.name)) { + const cbs = callbacks.get(Component.name)!; + for (const cb of cbs) { + const _ret = cb(Component, ret); + if (_ret !== undefined) ret = _ret; + } + return ret; + } + }; + + const patches = [ + after("jsx", jsxRuntime, callback), + after("jsxs", jsxRuntime, callback) + ]; + + return () => patches.forEach(unpatch => unpatch()); +} diff --git a/api/settings.ts b/api/settings.ts new file mode 100644 index 0000000..05f1c54 --- /dev/null +++ b/api/settings.ts @@ -0,0 +1,42 @@ +import { createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage"; +import { getLoaderConfigPath } from "@lib/api/native/loader"; + +export interface Settings { + debuggerUrl: string; + devToolsUrl: string; + autoDebugger: boolean; + autoDevTools: boolean; + developerSettings: boolean; + enableDiscordDeveloperSettings: boolean; + safeMode?: { + enabled: boolean; + currentThemeId?: string; + }; + enableEvalCommand?: boolean; +} + +export interface LoaderConfig { + customLoadUrl: { + enabled: boolean; + url: string; + }; + loadReactDevTools: boolean; +} + +export const settings = wrapSync(createStorage(createMMKVBackend("VENDETTA_SETTINGS"))); + +export const loaderConfig = wrapSync(createStorage( + createFileBackend(getLoaderConfigPath(), { + customLoadUrl: { + enabled: false, + url: "http://localhost:4040/kettu.js" + } + }) +)); + +export interface UpdaterSettings { + fetchPluginsOnStart?: boolean; + repoAutoFetchOverrides?: Record; +} + +export const updaterSettings = wrapSync(createStorage(createMMKVBackend("VENDETTA_UPDATER_SETTINGS"))); diff --git a/api/storage/index.ts b/api/storage/index.ts new file mode 100644 index 0000000..5746429 --- /dev/null +++ b/api/storage/index.ts @@ -0,0 +1,190 @@ +import { Emitter } from "@core/vendetta/Emitter"; +import { Observable, ObserverOptions } from "@gullerya/object-observer"; +import { fileExists, readFile, removeFile, writeFile } from "@lib/api/native/fs"; +import { debounce } from "es-toolkit"; + +const storageInitErrorSymbol = Symbol.for("bunny.storage.initError"); +const storagePromiseSymbol = Symbol.for("bunny.storage.promise"); + +const _loadedStorage = {} as Record; + +function createFileBackend(filePath: string) { + const write = debounce((data: T) => { + writeFile(filePath, JSON.stringify(data)); + }, 500); + + return { + get: async () => { + try { + return JSON.parse(await readFile(filePath)) as T; + } catch (e) { + throw new Error(`Failed to parse storage from '${filePath}'`, { cause: e }); + } + }, + set: async (data: T) => { + if (!data || typeof data !== "object") { + throw new Error("data needs to be an object"); + } + + write(data); + }, + exists: async () => { + return await fileExists(filePath); + } + }; +} + +export function useObservable(observables: Observable[], opts?: ObserverOptions) { + if (observables.some((o: any) => o?.[storageInitErrorSymbol])) throw new Error( + "An error occured while initializing the storage", + ); + + if (observables.some(o => !Observable.isObservable(o))) { + throw new Error("Argument passed isn't an Observable"); + } + + const [, forceUpdate] = React.useReducer(n => ~n, 0); + + React.useEffect(() => { + const listener = () => forceUpdate(); + + observables.forEach(o => Observable.observe(o, listener, opts)); + + return () => { + observables.forEach(o => Observable.unobserve(o, listener)); + }; + }, []); +} + +export async function updateStorage(path: string, value: T): Promise { + _loadedStorage[path] = value; + createFileBackend(path).set(value); +} + +export function createStorageAndCallback( + path: string, + cb: (proxy: T) => void, + { + dflt = {} as T, + nullIfEmpty = false + } = {} +) { + let emitter: Emitter; + + const callback = (data: any) => { + const proxy = new Proxy(Observable.from(data), { + get(target, prop, receiver) { + if (prop === Symbol.for("vendetta.storage.emitter")) { + if (emitter) return emitter; + emitter = new Emitter(); + + Observable.observe(target, changes => { + for (const change of changes) { + emitter.emit(change.type !== "delete" ? "SET" : "DEL", { + path: change.path, + value: change.value + }); + } + }); + + return emitter; + } + + return Reflect.get(target, prop, receiver); + }, + }); + + const handler = () => backend.set(proxy); + Observable.observe(proxy, handler); + + cb(proxy); + }; + + const backend = createFileBackend(path); + if (_loadedStorage[path]) { + callback(_loadedStorage[path]); + } else { + backend.exists().then(async exists => { + if (!exists) { + if (nullIfEmpty) { + callback(_loadedStorage[path] = null); + } else { + _loadedStorage[path] = dflt; + await backend.set(dflt); + callback(dflt); + } + } else { + callback(_loadedStorage[path] = await backend.get()); + } + }); + } +} + +type StorageOptions = Parameters>[2]; + +export async function createStorageAsync( + path: string, + opts: StorageOptions = {} +): Promise { + return new Promise(r => createStorageAndCallback(path, r, opts)); +} + +export const createStorage = ( + path: string, + opts: StorageOptions = {} +): T => { + const promise = new Promise(r => resolvePromise = r); + let awaited: any, resolved: boolean, error: any, resolvePromise: (val?: unknown) => void; + + createStorageAndCallback(path, proxy => { + awaited = proxy; + resolved = true; + resolvePromise(); + }, opts); + + const check = () => { + if (resolved) return true; + throw new Error(`Attempted to access storage without initializing: ${path}`); + }; + + return new Proxy({} as any, { + ...Object.fromEntries( + Object.getOwnPropertyNames(Reflect) + .map(k => [k, (t: T, ...a: any[]) => { + // @ts-expect-error + return check() && Reflect[k](awaited, ...a); + }]) + ), + get(target, prop, recv) { + if (prop === storageInitErrorSymbol) return error; + if (prop === storagePromiseSymbol) return promise; + return check() && Reflect.get(awaited ?? target, prop, recv); + }, + }); +}; + +export async function preloadStorageIfExists(path: string): Promise { + if (_loadedStorage[path]) return true; + + const backend = createFileBackend(path); + if (await backend.exists()) { + _loadedStorage[path] = await backend.get(); + return true; + } + + return false; +} + +export async function purgeStorage(path: string) { + await removeFile(path); + delete _loadedStorage[path]; +} + +export function awaitStorage(...proxies: any[]) { + return Promise.all(proxies.map(proxy => proxy[storagePromiseSymbol])); +} + +/** @internal */ +export function getPreloadedStorage(path: string): T | undefined { + return _loadedStorage[path]; +} diff --git a/commands/debug.ts b/commands/debug.ts new file mode 100644 index 0000000..f77b433 --- /dev/null +++ b/commands/debug.ts @@ -0,0 +1,41 @@ +import { Strings } from "@core/i18n"; +import { + ApplicationCommand, + ApplicationCommandOptionType, +} from "@lib/api/commands/types"; +import { getDebugInfo } from "@lib/api/debug"; +import { messageUtil } from "@metro/common"; + +export default () => + { + name: "Goon", + description: Strings.COMMAND_DEBUG_DESC, + options: [ + { + name: "ephemeral", + type: ApplicationCommandOptionType.BOOLEAN, + description: Strings.COMMAND_DEBUG_OPT_EPHEMERALLY, + }, + ], + execute([ephemeral], ctx) { + const info = getDebugInfo(); + const content = [ + "**💦GoonCord💦 Debug Info**", + `> GoonCord: ${info.bunny.version} (${info.bunny.loader.name} ${info.bunny.loader.version})`, + `> Discord: ${info.discord.version} (${info.discord.build})`, + `> React: ${info.react.version} (RN ${info.react.nativeVersion})`, + `> Hermes: ${info.hermes.version} (bcv${info.hermes.bytecodeVersion})`, + `> System: ${info.os.name} ${info.os.version} ${info.os.sdk ? `(SDK ${info.os.sdk})` : ""}`.trimEnd(), + `> Device: ${info.device.model}`, + ].join("\n"); + + if (ephemeral?.value) { + messageUtil.sendBotMessage(ctx.channel.id, content); + } else { + const fixNonce = Date.now().toString(); + messageUtil.sendMessage(ctx.channel.id, { content }, void 0, { + nonce: fixNonce, + }); + } + }, + }; diff --git a/commands/eval.ts b/commands/eval.ts new file mode 100644 index 0000000..0d17c85 --- /dev/null +++ b/commands/eval.ts @@ -0,0 +1,43 @@ +import { Strings } from "@core/i18n"; +import { ApplicationCommand, ApplicationCommandOptionType } from "@lib/api/commands/types"; +import { settings } from "@lib/api/settings"; +import { messageUtil } from "@metro/common"; +import { findByPropsLazy } from "@metro/wrappers"; + +const util = findByPropsLazy("inspect"); +const AsyncFunction = (async () => void 0).constructor; + +const ZERO_WIDTH_SPACE_CHARACTER = "\u200B"; + +function wrapInJSCodeblock(resString: string) { + return "```js\n" + resString.replaceAll("`", "`" + ZERO_WIDTH_SPACE_CHARACTER) + "\n```"; +} + +export default () => { + name: "eval", + description: Strings.COMMAND_EVAL_DESC, + shouldHide: () => settings.enableEvalCommand === true, + options: [ + { + name: "code", + type: ApplicationCommandOptionType.STRING, + description: Strings.COMMAND_EVAL_OPT_CODE, + required: true + }, + { + name: "async", + type: ApplicationCommandOptionType.BOOLEAN, + description: Strings.COMMAND_EVAL_OPT_ASYNC, + } + ], + async execute([code, async], ctx) { + try { + const res = util.inspect(async?.value ? await AsyncFunction(code.value)() : eval?.(code.value)); + const trimmedRes = res.length > 2000 ? res.slice(0, 2000) + "..." : res; + + messageUtil.sendBotMessage(ctx.channel.id, wrapInJSCodeblock(trimmedRes)); + } catch (err: any) { + messageUtil.sendBotMessage(ctx.channel.id, wrapInJSCodeblock(err?.stack ?? err)); + } + } +}; diff --git a/commands/plugins.ts b/commands/plugins.ts new file mode 100644 index 0000000..b83f6a7 --- /dev/null +++ b/commands/plugins.ts @@ -0,0 +1,199 @@ +import { Strings } from "@core/i18n"; +import { + ApplicationCommand, + ApplicationCommandOptionType, +} from "@lib/api/commands/types"; +import { messageUtil } from "@metro/common"; +import { VdPluginManager, VendettaPlugin } from "@core/vendetta/plugins"; +import { + registeredPlugins, + corePluginInstances, + pluginSettings, +} from "@lib/addons/plugins"; + +/** + * /plugins + * + * Aggregates: + * - Core plugins (built-in) from corePluginInstances + * - External plugins: + * - Vendetta-managed plugins (VdPluginManager.plugins) + * - Bunny repository plugins (registeredPlugins + pluginSettings) + * + * Dedupes by plugin id and shows Enabled / Disabled groups. + * For external plugins each entry will show its source(s) (vendetta / bunny). + */ +export default () => + { + name: "plugins", + description: Strings.COMMAND_PLUGINS_DESC, + options: [ + { + name: "ephemeral", + displayName: "ephemeral", + type: ApplicationCommandOptionType.BOOLEAN, + description: Strings.COMMAND_DEBUG_OPT_EPHEMERALLY, + }, + ], + execute([ephemeral], ctx) { + // --- Core plugins --- + const coreIds = [...corePluginInstances.keys()]; + + const coreManifests = coreIds + .map((id) => registeredPlugins.get(id)) + .filter(Boolean) as any[]; + + const coreEnabled = coreManifests + .filter((m) => Boolean(pluginSettings[m.id]?.enabled)) + .map((m) => m.display?.name ?? m.name ?? m.id) + .sort((a, b) => a.localeCompare(b)); + const coreDisabled = coreManifests + .filter((m) => !Boolean(pluginSettings[m.id]?.enabled)) + .map((m) => m.display?.name ?? m.name ?? m.id) + .sort((a, b) => a.localeCompare(b)); + + // --- External plugins aggregation (Vendetta + Bunny) --- + type ExtEntry = { + id: string; + name: string; + sources: Set; + enabled: boolean; // overall enabled if any source reports enabled (Vendetta) or pluginSettings for Bunny + }; + + const aggregated = new Map(); + + // Vendetta plugins (if available) + try { + const vdRaw = + (VdPluginManager && (VdPluginManager as any).plugins) || {}; + const vdPlugins = Object.values(vdRaw).filter( + Boolean, + ) as VendettaPlugin[]; + + for (const p of vdPlugins) { + const id = + p.manifest?.id ?? p.id ?? String(p.manifest?.name ?? Math.random()); + const name = p.manifest?.name ?? id; + const enabled = Boolean(p.enabled); + const existing = aggregated.get(id); + if (existing) { + existing.sources.add("vendetta"); + existing.enabled = existing.enabled || enabled; + } else { + aggregated.set(id, { + id, + name, + sources: new Set(["vendetta"]), + enabled, + }); + } + } + } catch (e) { + // Defensive: if Vendetta manager isn't ready, skip vendetta aggregation + } + + // Bunny external plugins: use registeredPlugins + pluginSettings (installed) + for (const [id, manifest] of registeredPlugins) { + if (!manifest) continue; + // External bunny plugins have parentRepository property + // Only consider those that are actual external plugins (have parentRepository) + const isExternal = (manifest as any).parentRepository != null; + if (!isExternal) continue; + + // Skip core plugin ids (safety) + if (corePluginInstances.has(id)) continue; + + const name = manifest.display?.name ?? manifest.name ?? id; + const installed = pluginSettings[id] != null; + const enabled = Boolean(pluginSettings[id]?.enabled); + + // If not installed and not present in aggregated (vendetta), still include as available but mark enabled=false + const existing = aggregated.get(id); + if (existing) { + existing.sources.add("bunny"); + existing.enabled = existing.enabled || enabled; + } else { + // Only add bunny entry if installed OR if it's registered and not installed we will handle as available later + if (installed) { + aggregated.set(id, { + id, + name, + sources: new Set(["bunny"]), + enabled, + }); + } + } + } + + // Also collect available-but-not-installed Bunny externals for the "available" list + const available = [...registeredPlugins.values()] + .filter( + (m: any) => + Boolean(m) && + (m as any).parentRepository && + !corePluginInstances.has((m as any).id), + ) + .map((m: any) => ({ + id: m.id, + name: m.display?.name ?? m.name ?? m.id, + installed: pluginSettings[m.id] != null, + })) + .filter((x) => !x.installed) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((x) => x.name); + + // Build external lists (deduped) + const externalList = Array.from(aggregated.values()).sort((a, b) => + a.name.localeCompare(b.name), + ); + + const externalEnabled = externalList + .filter((e) => e.enabled) + .map((e) => e.name); + const externalDisabled = externalList + .filter((e) => !e.enabled) + .map((e) => e.name); + + // Build message + const lines: string[] = []; + + // Core plugins + lines.push(`**Plugins (${coreManifests.length}):**`); + if (coreEnabled.length > 0) { + lines.push(`Enabled (${coreEnabled.length}):`); + lines.push("> " + coreEnabled.join(", ")); + } + if (coreDisabled.length > 0) { + lines.push(`Disabled (${coreDisabled.length}):`); + lines.push("> " + coreDisabled.join(", ")); + } + if (coreManifests.length === 0) { + lines.push("_No core plugins registered._"); + } + + // External plugins + lines.push(`**External Plugins (${externalList.length}):**`); + if (externalEnabled.length > 0) { + lines.push(`Installed & Enabled (${externalEnabled.length}):`); + lines.push("> " + externalEnabled.join(", ")); + } + if (externalDisabled.length > 0) { + lines.push(`Installed & Disabled (${externalDisabled.length}):`); + lines.push("> " + externalDisabled.join(", ")); + } + if (externalList.length === 0) { + lines.push("_No external plugins found._"); + } + + const content = lines.join("\n"); + + if (ephemeral?.value) { + messageUtil.sendBotMessage(ctx.channel.id, content); + } else { + const fixNonce = Date.now().toString(); + messageUtil.sendMessage(ctx.channel.id, { content }, void 0, { + nonce: fixNonce, + }); + } + }, + }; diff --git a/commands/reload.ts b/commands/reload.ts new file mode 100644 index 0000000..04676d8 --- /dev/null +++ b/commands/reload.ts @@ -0,0 +1,13 @@ +import { Strings } from "@core/i18n"; +import { ApplicationCommand } from "@lib/api/commands/types"; +import { BundleUpdaterManager } from "@lib/api/native/modules"; + +export default () => + { + name: "reload", + description: Strings.COMMAND_RELOAD_DESC, + options: [], + execute() { + BundleUpdaterManager.reload(); + }, + }; \ No newline at end of file diff --git a/common/components.ts b/common/components.ts new file mode 100644 index 0000000..f6d0052 --- /dev/null +++ b/common/components.ts @@ -0,0 +1,120 @@ +/* eslint-disable prefer-destructuring */ +import { lazyDestructure, proxyLazy } from "@lib/utils/lazy"; +import { createFilterDefinition } from "@metro/factories"; +import { findExports } from "@metro/finders"; +import { findByDisplayNameLazy, findByNameLazy, findByProps, findByPropsLazy } from "@metro/wrappers"; + +import * as t from "./types/components"; + +const bySingularProp = createFilterDefinition<[string]>( + ([prop], m) => m[prop] && Object.keys(m).length === 1, + prop => `bunny.metro.common.components.bySingularProp(${prop})` +); + +const findSingular = (prop: string) => proxyLazy(() => findExports(bySingularProp(prop))?.[prop]); +const findProp = (...props: string[]) => proxyLazy(() => findByProps(...props)[props[0]]); + +// Discord +export const LegacyAlert = findByDisplayNameLazy("FluxContainer(Alert)"); +export const CompatButton = findByPropsLazy("Looks", "Colors", "Sizes"); +export const HelpMessage = findByNameLazy("HelpMessage"); + +// React Native's included SafeAreaView only adds padding on iOS. +export const { SafeAreaView, SafeAreaProvider, useSafeAreaInsets } = lazyDestructure(() => findByProps("useSafeAreaInsets")); + +// ActionSheet +export const ActionSheetRow = findProp("ActionSheetRow"); + +// Buttons +export const Button = findSingular("Button") as t.Button; +export const TwinButtons = findProp("TwinButtons") as t.TwinButtons; +export const IconButton = findSingular("IconButton") as t.IconButton; +export const RowButton = findProp("RowButton") as t.RowButton; + +export const PressableScale = findProp("PressableScale"); + +// Tables +export const TableRow = findProp("TableRow") as t.TableRow; +export const TableRowIcon = findProp("TableRowIcon") as t.TableRowIcon; +export const TableRowTrailingText = findProp("TableRowTrailingText") as t.TableRowTrailingText; +export const TableRowGroup = findProp("TableRowGroup") as t.TableRowGroup; +export const TableRadioGroup = findProp("TableRadioGroup") as t.TableRadioGroup; +export const TableRadioRow = findProp("TableRadioRow") as t.TableRadioRow; +export const TableSwitchRow = findProp("TableSwitchRow") as t.TableSwitchRow; +export const TableCheckboxRow = findProp("TableCheckboxRow") as t.TableCheckboxRow; + +export const TableSwitch = findSingular("FormSwitch"); +export const TableRadio = findSingular("FormRadio"); +export const TableCheckbox = findSingular("FormCheckbox"); + +export const FormSwitch = findSingular("FormSwitch"); +export const FormRadio = findSingular("FormRadio"); +export const FormCheckbox = findSingular("FormCheckbox"); + +// Card +export const Card = findProp("Card"); +export const RedesignCompat = proxyLazy(() => findByProps("RedesignCompat").RedesignCompat); + +// Alert +export const AlertModal = findProp("AlertModal"); +export const AlertActionButton = findProp("AlertActionButton"); +export const AlertActions = findProp("AlertActions"); + +// Pile +export const AvatarPile = findSingular("AvatarPile"); + +// Misc. +export const ContextMenu = findProp("ContextMenu") as t.ContextMenu; +export const Stack = findProp("Stack") as t.Stack; +export const Avatar = findProp("default", "AvatarSizes", "getStatusSize"); + + +// Inputs +export const TextInput = findSingular("TextInput") as t.TextInput; +export const TextArea = findSingular("TextArea"); + +// SegmentedControl +export const SegmentedControl = findProp("SegmentedControl") as t.SegmentedControl; +export const SegmentedControlPages = findProp("SegmentedControlPages") as t.SegmentedControlPages; +export const useSegmentedControlState = findSingular("useSegmentedControlState") as (arg: t.SegmentedControlStateArgs) => t.SegmentedControlState; +export const CompatSegmentedControl = findProp("CompatSegmentedControl") as t.CompatSegmentedControl; + +export const FloatingActionButton = findProp("FloatingActionButton") as t.FloatingActionButton; +export const ActionSheet = findProp("ActionSheet") as t.ActionSheet; +export const BottomSheetTitleHeader = findProp("BottomSheetTitleHeader"); + +const textsModule = findByPropsLazy("Text", "LegacyText"); +export const Text = proxyLazy(() => textsModule.Text) as t.Text; + +export const Forms = findByPropsLazy("Form", "FormSection"); + +export const { + Form: LegacyForm, + FormArrow: LegacyFormArrow, + FormCTA: LegacyFormCTA, + FormCTAButton: LegacyFormCTAButton, + FormCardSection: LegacyFormCardSection, + FormCheckbox: LegacyFormCheckbox, + FormCheckboxRow: LegacyFormCheckboxRow, + FormCheckmark: LegacyFormCheckmark, + FormDivider: LegacyFormDivider, + FormHint: LegacyFormHint, + FormIcon: LegacyFormIcon, + FormInput: LegacyFormInput, + FormLabel: LegacyFormLabel, + FormRadio: LegacyFormRadio, + FormRadioGroup: LegacyFormRadioGroup, + FormRadioRow: LegacyFormRadioRow, + FormRow: LegacyFormRow, + FormSection: LegacyFormSection, + FormSelect: LegacyFormSelect, + FormSliderRow: LegacyFormSliderRow, + FormSubLabel: LegacyFormSubLabel, + FormSwitch: LegacyFormSwitch, + FormSwitchRow: LegacyFormSwitchRow, + FormTernaryCheckBox: LegacyFormTernaryCheckBox, + FormText: LegacyFormText, + FormTitle: LegacyFormTitle +} = lazyDestructure(() => Forms); + +export const FlashList = findProp("FlashList"); diff --git a/common/index.ts b/common/index.ts new file mode 100644 index 0000000..e374aa0 --- /dev/null +++ b/common/index.ts @@ -0,0 +1,50 @@ +import { lazyDestructure } from "@lib/utils/lazy"; +import { findByFilePathLazy, findByProps, findByPropsLazy } from "@metro/wrappers"; +import { Linking } from "react-native"; +import type { Dispatcher } from "./types/flux"; + +export * as components from "./components"; + +// Discord +export const constants = findByPropsLazy("Fonts", "Permissions"); +export const channels = findByPropsLazy("getVoiceChannelId"); +export const i18n = findByPropsLazy("Messages"); + +export const clipboard = findByPropsLazy("setString", "getString", "hasString"); +export const assets = findByPropsLazy("registerAsset"); +export const invites = findByPropsLazy("acceptInviteAndTransitionToInviteChannel"); +export const commands = findByPropsLazy("getBuiltInCommands"); +export const navigation = findByPropsLazy("pushLazy"); +export const toasts = findByFilePathLazy("modules/toast/native/ToastActionCreators.tsx", true); +export const messageUtil = findByPropsLazy("sendBotMessage"); +export const navigationStack = findByPropsLazy("createStackNavigator"); +export const NavigationNative = findByPropsLazy("NavigationContainer"); +export const semver = findByPropsLazy("parse", "clean"); + +export const tokens = findByPropsLazy("unsafe_rawColors", "colors"); +export const { useToken } = lazyDestructure(() => findByProps("useToken")); + +// Polyfill LinkingUtils + +const openURL = (url: string) => Linking.openURL(url); +export const url = nativeModuleProxy.NativeLinkingModule || nativeModuleProxy.DCDLinkingManager ? { + openURL, + openDeeplink: openURL, + handleSupportedURL: openURL, + isDiscordConnectOauth2Deeplink: () => { + console.warn("url.isDiscordConnectOauth2Deeplink is not implemented and will always return false"); + return false; + }, + showLongPressUrlActionSheet: () => console.warn("url.showLongPressUrlActionSheet is not implemented"), + handleMessageLinking: findByFilePathLazy("modules/links/native/handleContentLinking.tsx", true), +} : findByPropsLazy("openURL", "openDeeplink"); + +// Flux +export const Flux = findByPropsLazy("connectStores"); +// TODO: Making this a proxy/lazy fuck things up for some reason +export const FluxDispatcher = findByProps("_interceptors") as Dispatcher; +export const FluxUtils = findByProps("useStateFromStores"); + +// React +export const React = window.React = findByPropsLazy("createElement") as typeof import("react"); +export const ReactNative = window.ReactNative = findByPropsLazy("AppRegistry") as typeof import("react-native"); diff --git a/common/stores.ts b/common/stores.ts new file mode 100644 index 0000000..bef2ebf --- /dev/null +++ b/common/stores.ts @@ -0,0 +1,3 @@ +import { findByStoreNameLazy } from "@metro/wrappers"; + +export const UserStore = findByStoreNameLazy("UserStore"); diff --git a/common/types/components.ts b/common/types/components.ts new file mode 100644 index 0000000..254fa34 --- /dev/null +++ b/common/types/components.ts @@ -0,0 +1,265 @@ +import { Nullish } from "@lib/utils/types"; +import { TextStyles, ThemeColors } from "@ui/types"; +import { Falsey } from "lodash"; +import { FC, MutableRefObject, PropsWithoutRef, ReactNode, RefObject } from "react"; +import type * as RN from "react-native"; +import { ImageSourcePropType, PressableProps } from "react-native"; +import { SharedValue } from "react-native-reanimated"; +import { LiteralUnion } from "type-fest"; + +// Abandon all hope, ye who enter here +type Style = RN.ViewStyle & RN.ImageStyle & RN.TextStyle; + +type InteractiveSize = "sm" | "md" | "lg"; + +// Buttons +type ButtonVariant = "primary" | "secondary" | "tertiary" | "destructive" | "active"; + +interface ButtonProps { + disabled?: boolean; + onPress: () => void; + loading?: boolean; + variant?: LiteralUnion; + text?: string; + size?: LiteralUnion; + iconPosition?: "start" | "end"; + scaleAmountInPx?: number; + icon?: ImageSourcePropType | ReactNode; + style?: Style; + grow?: boolean; +} + +export type Button = React.ForwardRefExoticComponent; + +interface TwinButtonsProps { + children: ReactNode; +} + +export type TwinButtons = React.ComponentType; + +// Segmented Control +interface SegmentedControlItem { + id: string; + label: string; + page?: JSX.Element | null; +} + +export interface SegmentedControlStateArgs { + items: SegmentedControlItem[]; + pageWidth: number; + defaultIndex?: number; +} + +export interface SegmentedControlState { + activeIndex: SharedValue; + pagerRef: RefObject; + scrollTarget: SharedValue; + scrollOverflow: SharedValue; + scrollOffset: SharedValue; + items: SegmentedControlItem[]; + itemDimensions: SharedValue; + pageWidth: number; + pressedIndex: SharedValue; + onPageChangeRef: MutableRefObject; // { current: undefined } + setActiveIndex: (index: number) => void; + setItemDimensions: (index: number, dimensions: unknown[]) => void; + useReducedMotion: boolean; +} + +interface SegmentedControlProps { + state: SegmentedControlState; + variant?: string; +} + +export type SegmentedControl = React.FC; + +interface SegmentedControlPagesProps { + state: SegmentedControlState; +} + +export type SegmentedControlPages = React.FC; + +interface CompatSegmentedControlProps { + values: string[]; + selectedSegmentIndex?: number; + onValueChange?: (index: number) => void; + onSetActiveIndex?: (index: number) => void; +} + +export type CompatSegmentedControl = React.FC; + +// TODO: Confirm if this is real +interface TextInputProps extends Omit { + defaultValue?: string; + description?: string; + editable?: boolean; + errorMessage?: string; + focusable?: boolean; + grow?: boolean; + isCentered?: boolean; + isClearable?: boolean; + isDisabled?: boolean; + isRound?: boolean; + label?: string; + leadingIcon?: React.FC; + leadingPressableProps?: PressableProps; + leadingText?: string; + onChange?: (text: string) => void; + size?: "sm" | "md" | "lg"; + state?: "error" | "default"; + style?: Style; + trailingIcon?: React.FC; + trailingPressableProps?: PressableProps; + trailingText?: string; + value?: string | Nullish; +} + +export type TextInput = React.FC; + +interface RowButtonProps { + variant?: LiteralUnion; + style?: Style; + icon?: ImageSourcePropType | ReactNode; + label: string | ReactNode; + subLabel?: string | ReactNode; + onPress: () => void; + disabled?: boolean; +} + +export type RowButton = React.FC; + +interface StackProps { + /** defaults to vertical */ + direction?: "vertical" | "horizontal"; + /** defaults to 8 */ + spacing?: number; +} + +export type Stack = React.FC & RN.ViewProps>; + +interface FABProps { + icon: ImageSourcePropType | ReactNode; + style?: Style; + onPress: () => void; + positionBottom?: number; +} + +export type FloatingActionButton = React.FC; + +interface ActionSheetProps { + scrollable?: boolean; +} + +export type ActionSheet = React.FC>; + +type TextProps = React.ComponentProps & { + variant?: TextStyles; + color?: ThemeColors; + lineClamp?: number; + maxFontSizeMultiplier?: number; + style?: RN.TextStyle; +}; + +export type Text = React.FC; + +interface IconButtonProps { + label?: string; + icon: ImageSourcePropType | ReactNode; + onPress: () => void; + disabled?: boolean; + size?: InteractiveSize; + variant?: ButtonVariant; + style?: Style; +} + +export type IconButton = React.FC; + +export type PressableScale = React.FC>; + +interface TableRowBaseProps { + arrow?: boolean; + label: string | ReactNode; + subLabel?: string | ReactNode; + variant?: LiteralUnion<"danger", string>; + icon?: JSX.Element | Falsey; + disabled?: boolean; + trailing?: ReactNode | React.ComponentType; +} + +interface TableRowProps extends TableRowBaseProps { + onPress?: () => void; +} + +export type TableRow = React.FC & { + Icon: TableRowIcon; + TrailingText: TableRowTrailingText; + Arrow: FC<{}>; +}; + +interface TableRowTrailingTextProps { + text: string; +} + +export type TableRowTrailingText = FC; + +interface TableRowIconProps { + style?: RN.ImageStyle; + variant?: LiteralUnion<"danger", string>, + source: ImageSourcePropType | undefined; +} + +export type TableRowIcon = React.FC; + +interface TableRowGroupProps { + title: string; + children: ReactNode; +} + +export type TableRowGroup = React.FC; + +interface TableRadioGroupProps { + title: string; + value: string; + hasIcons?: boolean; + onChange: (type: T) => void; + children: ReactNode; +} + +export type TableRadioGroup = FC; + +interface TableRadioRowProps extends TableRowBaseProps { + value: string; +} + +export type TableRadioRow = FC; + +interface TableSwitchRowProps extends TableRowBaseProps { + value: boolean; + onValueChange: (value: boolean) => void; +} + +export type TableSwitchRow = FC; + +interface TableCheckboxRowProps extends TableRowBaseProps { + checked: boolean; + onPress: () => void; +} + +export type TableCheckboxRow = FC; + +interface ContextMenuItem { + label: string; + variant?: LiteralUnion<"destructive", string>; + iconSource?: number; + action: () => unknown; +} + +interface ContextMenuProps { + triggerOnLongPress?: boolean; + items: ContextMenuItem[] | ContextMenuItem[][]; + align?: "left" | "right" | "above" | "below" | "auto" | null; + title?: string; + children: React.FC>; +} + +export type ContextMenu = FC; diff --git a/common/types/flux.ts b/common/types/flux.ts new file mode 100644 index 0000000..fb414d0 --- /dev/null +++ b/common/types/flux.ts @@ -0,0 +1,18 @@ +export interface Dispatcher { + _actionHandlers: unknown; + _interceptors?: ((payload: any) => void | boolean)[]; + _currentDispatchActionType: undefined | string; + _processingWaitQueue: boolean; + _subscriptions: Record void>>; + _waitQueue: unknown[]; + + addDependencies(node1: any, node2: any): void; + dispatch(payload: any): Promise; + flushWaitQueue(): void; + isDispatching(): boolean; + register(name: string, actionHandler: Record void>, storeDidChange: (e: any) => boolean): string; + setInterceptor(interceptor?: (payload: any) => void | boolean): void; + subscribe(actionType: string, callback: (payload: any) => void): void; + unsubscribe(actionType: string, callback: (payload: any) => void): void; + wait(cb: () => void): void; +} diff --git a/debug/patches/patchErrorBoundary.tsx b/debug/patches/patchErrorBoundary.tsx new file mode 100644 index 0000000..2c30ce0 --- /dev/null +++ b/debug/patches/patchErrorBoundary.tsx @@ -0,0 +1,160 @@ +import React from "react"; +import ErrorBoundaryScreen from "@core/ui/reporter/components/ErrorBoundaryScreen"; +import { after } from "@lib/api/patcher"; +import { _lazyContextSymbol } from "@metro/lazy"; +import { LazyModuleContext } from "@metro/types"; +import { findByNameLazy } from "@metro/wrappers"; + +/** + * Try to resolve the ErrorBoundary prototype context in a resilient way. + * Some Discord versions / builds may expose modules under slightly different + * names; attempt lookup but fail gracefully if not found. + */ +function getErrorBoundaryContext(): Promise { + try { + const ctxt: LazyModuleContext = + findByNameLazy("ErrorBoundary")[_lazyContextSymbol]; + if (ctxt) { + return new Promise((resolve) => + ctxt.getExports((exp: any) => resolve(exp.prototype)), + ); + } + } catch (e) { + // silent + } + + // Best-effort fallback: resolve to null so callers can handle absence. + return Promise.reject(new Error("ErrorBoundary context not found")); +} + +/** + * Small stable wrapper component used when mounting our UI from the patched render. + * Defining a top-level component ensures that hook order inside ErrorBoundaryScreen + * is always respected (React expects components to be declared at module scope). + * + * We avoid using any hooks here; this wrapper simply forwards props to the real screen. + */ +const ErrorBoundaryMount: React.FC<{ + error: any; + rerender: () => void; +}> = ({ error, rerender }) => { + return React.createElement(ErrorBoundaryScreen, { + error, + rerender, + }); +}; + +/** + * Patch Discord's ErrorBoundary.render to return ShiggyCord's custom screen. + * Add defensive logging and fallback behavior so the patch won't break startup + * if lookup fails. Also register lightweight global handlers to capture + * uncaught errors for debugging. + */ +export default function patchErrorBoundary() { + try { + console.log("[ShiggyCord] patchErrorBoundary: registering"); + } catch {} + + // Attempt to attach after the ErrorBoundary render. If the context lookup + // fails, the promise will reject and the patcher will not install the patch, + // which is preferable to throwing during startup. + const ctxPromise = getErrorBoundaryContext().catch((err) => { + try { + console.warn( + "[ShiggyCord] patchErrorBoundary: context lookup failed", + err, + ); + } catch {} + // Return null so after.await receives a thenable; the patcher wrapper will handle it. + return null; + }); + + const unpatch = after.await("render", ctxPromise, function (this: any) { + try { + // Defensive checks: only render our screen when an error is actually present. + if (!this || !this.state || !this.state.error) return; + + try { + console.log( + "[ShiggyCord] patchErrorBoundary: rendering custom error screen", + this.state.error, + ); + } catch {} + + // Return a stable component element (ErrorBoundaryMount). Using a named, + // module-scoped component helps React ensure hook ordering for nested components. + return React.createElement(ErrorBoundaryMount, { + error: this.state.error, + rerender: () => { + try { + // Attempt to reset common ErrorBoundary state shapes safely. + try { + this.setState?.({ info: null, error: null, hasErr: false }); + } catch { + try { + this.setState?.({ error: null }); + } catch {} + } + } catch {} + }, + }); + } catch (e) { + try { + console.error( + "[ShiggyCord] patchErrorBoundary: error while rendering custom screen", + e, + ); + } catch {} + // Fall back to original behavior by returning undefined + return; + } + }); + + // Best-effort: install global handlers to capture uncaught errors / promise rejections. + // These handlers do not attempt to mount UI directly (that can be fragile), but they + // store the last uncaught error for inspection and log details which helps debugging. + try { + const g: any = global as any; + if (g) { + // Wrap React Native's ErrorUtils if present + try { + const ErrorUtils = g.ErrorUtils; + if (ErrorUtils && typeof ErrorUtils.setGlobalHandler === "function") { + // preserve previous handler + const prev = + ErrorUtils.getGlobalHandler?.() ?? + ErrorUtils._globalHandler ?? + null; + ErrorUtils.setGlobalHandler((err: any, isFatal?: boolean) => { + try { + console.error("[ShiggyCord] global uncaught error:", err, { + isFatal, + }); + (window as any).__SHIGGY_LAST_UNCAUGHT_ERROR = err; + } catch {} + try { + if (typeof prev === "function") prev(err, isFatal); + } catch {} + }); + } + } catch {} + + // Listen for unhandled promise rejections if environment supports it. + try { + if (typeof g.addEventListener === "function") { + g.addEventListener("unhandledrejection", (ev: any) => { + try { + console.error( + "[ShiggyCord] unhandledrejection:", + ev?.reason ?? ev, + ); + (window as any).__SHIGGY_LAST_UNCAUGHT_ERROR = ev?.reason ?? ev; + } catch {} + }); + } + } catch {} + } + } catch {} + + return unpatch; +} diff --git a/debug/safeMode.ts b/debug/safeMode.ts new file mode 100644 index 0000000..f44bf06 --- /dev/null +++ b/debug/safeMode.ts @@ -0,0 +1,17 @@ +import { getCurrentTheme, writeThemeToNative } from "@lib/addons/themes"; +import { BundleUpdaterManager } from "@lib/api/native/modules"; +import { settings } from "@lib/api/settings"; + +export function isSafeMode() { + return settings.safeMode?.enabled === true; +} + +export async function toggleSafeMode({ + to = !isSafeMode(), + reload = true +} = {}) { + const enabled = (settings.safeMode ??= { enabled: to }).enabled = to; + const currentColor = getCurrentTheme(); + await writeThemeToNative(enabled ? {} : currentColor?.data ?? {}); + if (reload) setTimeout(() => BundleUpdaterManager.reload(), 500); +} diff --git a/entry.ts b/entry.ts new file mode 100644 index 0000000..234cedd --- /dev/null +++ b/entry.ts @@ -0,0 +1,157 @@ +import type { Metro } from "@metro/types"; +import { version } from "bunny-build-info"; +const { instead } = require("spitroast"); + +// @ts-ignore - window is defined later in the bundle, so we assign it early +globalThis.window = globalThis; + +async function initializeShiggyCord() { + try { + // Make 'freeze' and 'seal' do nothing + Object.freeze = Object.seal = Object; + + await require("@metro/internals/caches").initMetroCache(); + await require(".").default(); + } catch (e) { + const { ClientInfoManager } = require("@lib/api/native/modules"); + const stack = e instanceof Error ? e.stack : undefined; + + console.log(stack ?? e?.toString?.() ?? e); + alert( + [ + "Failed to load ShiggyCord!\n", + `Build Number: ${ClientInfoManager.getConstants().Build}`, + `ShiggyCord: ${version}`, + stack || e?.toString?.(), + ].join("\n"), + ); + } +} + +if (typeof window.__r === "undefined") { + // Used for storing the current require function for the global.__r getter defined below + var _requireFunc: any; + + // Calls from the native side are deferred until the index.ts(x) is loaded + interface DeferredQueue { + object: any; + method: string; + resume?: (queue: DeferredQueue) => void; + args: any[]; + } + + const deferredCalls: Array = []; + const unpatches: Array<() => void> = []; + + const deferMethodExecution = ( + object: any, + method: string, + condition?: (...args: any[]) => boolean, + resume?: (queue: DeferredQueue) => void, + returnWith?: (queue: DeferredQueue) => any, + ) => { + const restore = instead( + method, + object, + function (this: any, args: any[], original: any) { + if (!condition || condition(...args)) { + const queue: DeferredQueue = { object, method, args, resume }; + deferredCalls.push(queue); + return returnWith ? returnWith(queue) : undefined; + } + + // If the condition is not met, we execute the original method immediately + return original.apply(this, args); + }, + ); + + unpatches.push(restore); + }; + + const resumeDeferred = () => { + for (const queue of deferredCalls) { + const { object, method, args, resume } = queue; + + if (resume) { + resume(queue); + } else { + object[method](...args); + } + } + + deferredCalls.length = 0; + }; + + const onceIndexRequired = (originalRequire: Metro.RequireFn) => { + // We hold calls from the native side + if (window.__fbBatchedBridge) { + const batchedBridge = window.__fbBatchedBridge; + deferMethodExecution( + batchedBridge, + "callFunctionReturnFlushedQueue", + // If the call is to AppRegistry, we want to defer it because it is not yet registered (Revenge delays it) + // Same goes to the non-callable modules, which are not registered yet, so we ensure that only registered ones can get through + (...args) => + args[0] === "AppRegistry" || + !batchedBridge.getCallableModule(args[0]), + ({ args }) => { + if (batchedBridge.getCallableModule(args[0])) { + batchedBridge.__callFunction(...args); + } + }, + () => batchedBridge.flushedQueue(), + ); + } + + // Introduced since RN New Architecture + if (window.RN$AppRegistry) { + deferMethodExecution(window.RN$AppRegistry, "runApplication"); + } + + const startDiscord = async () => { + await initializeShiggyCord(); + + for (const unpatch of unpatches) unpatch(); + unpatches.length = 0; + + originalRequire(0); + resumeDeferred(); + }; + + startDiscord(); + }; + + Object.defineProperties(globalThis, { + __r: { + configurable: true, + get: () => _requireFunc, + set(v) { + // _requireFunc is required here, because using 'this' here errors for some unknown reason + _requireFunc = function patchedRequire(a: number) { + // Initializing index.ts(x) + if (a === 0) { + if (window.modules instanceof Map) + window.modules = Object.fromEntries(window.modules); + onceIndexRequired(v); + _requireFunc = v; + } else return v(a); + }; + }, + }, + __d: { + configurable: true, + get() { + // @ts-ignore - I got an error where 'Object' is undefined *sometimes*, which is literally never supposed to happen + if (window.Object && !window.modules) { + window.modules = window.__c?.(); + } + return this.value; + }, + set(v) { + this.value = v; + }, + }, + }); +} else { + initializeShiggyCord(); +} diff --git a/factories.ts b/factories.ts new file mode 100644 index 0000000..1328735 --- /dev/null +++ b/factories.ts @@ -0,0 +1,40 @@ +import { FilterCheckDef, FilterDefinition, ModuleExports } from "./types"; + +export function createFilterDefinition( + fn: FilterCheckDef, + uniqMaker: (args: A) => string +): FilterDefinition { + function createHolder(func: T, args: A, raw: boolean) { + return Object.assign(func, { + filter: fn, + raw, + uniq: [ + raw && "raw::", + uniqMaker(args) + ].filter(Boolean).join("") + }); + } + + const curry = (raw: boolean) => (...args: A) => { + return createHolder((m: ModuleExports, id: number, defaultCheck: boolean) => { + return fn(args, m, id, defaultCheck); + }, args, raw); + }; + + return Object.assign(curry(false), { + byRaw: curry(true), + uniqMaker + }); +} + +export function createSimpleFilter( + filter: (m: ModuleExports) => boolean, + uniq: string +) { + return createFilterDefinition( + (_, m) => filter(m), + () => `dynamic::${uniq}` + )(); +} + + diff --git a/filters.ts b/filters.ts new file mode 100644 index 0000000..84cc88f --- /dev/null +++ b/filters.ts @@ -0,0 +1,40 @@ +import { createFilterDefinition } from "./factories"; +import { metroModules } from "./internals/modules"; + +export const byProps = createFilterDefinition( + (props, m) => props.length === 0 ? m[props[0]] : props.every(p => m[p]), + props => `bunny.metro.byProps(${props.join(",")})` +); + +export const byName = createFilterDefinition<[string]>( + ([name], m) => m.name === name, + name => `bunny.metro.byName(${name})` +); + +export const byDisplayName = createFilterDefinition<[string]>( + ([displayName], m) => m.displayName === displayName, + name => `bunny.metro.byDisplayName(${name})` +); + +export const byTypeName = createFilterDefinition<[string]>( + ([typeName], m) => m.type?.name === typeName, + name => `bunny.metro.byTypeName(${name})` +); + +export const byStoreName = createFilterDefinition<[string]>( + ([name], m) => m.getName?.length === 0 && m.getName() === name, + name => `bunny.metro.byStoreName(${name})` +); + +export const byFilePath = createFilterDefinition<[string, boolean]>( + // module return depends on defaultCheck. if true, it'll return module.default, otherwise the whole module + // unlike filters like byName, defaultCheck doesn't affect the return since we don't rely on exports, but only its ID + // one could say that this is technically a hack, since defaultCheck is meant for filtering exports + ([path, exportDefault], _, id, defaultCheck) => (exportDefault === defaultCheck) && metroModules[id]?.__filePath === path, + ([path, exportDefault]) => `bunny.metro.byFilePath(${path},${exportDefault})` +); + +export const byMutableProp = createFilterDefinition<[string]>( + ([prop], m) => m?.[prop] && !Object.getOwnPropertyDescriptor(m, prop)?.get, + prop => `bunny.metro.byMutableProp(${prop})` +); diff --git a/finders.ts b/finders.ts new file mode 100644 index 0000000..744160b --- /dev/null +++ b/finders.ts @@ -0,0 +1,113 @@ +import { getCacherForUniq } from "./internals/caches"; +import { getModules, requireModule } from "./internals/modules"; +import { FilterFn } from "./types"; + +function filterExports( + moduleExports: any, + moduleId: number, + filter: FilterFn, +) { + if ( + moduleExports.default && + moduleExports.__esModule && + filter(moduleExports.default, moduleId, true) + ) { + return { + exports: filter.raw ? moduleExports : moduleExports.default, + defaultExport: !filter.raw + }; + } + + if (!filter.raw && filter(moduleExports, moduleId, false)) { + return { exports: moduleExports, defaultExport: false }; + } + + return {}; +} + +/** + * Returns the [id, defaultExports] of the first module where filter returns non-undefined, and undefined otherwise. + * @param filter find calls filter once for each enumerable module's exports until it finds one where filter returns a thruthy value. + */ +export function findModule(filter: FilterFn) { + const { cacheId, finish } = getCacherForUniq(filter.uniq, false); + + for (const [id, moduleExports] of getModules(filter.uniq, false)) { + const { exports: testedExports, defaultExport } = filterExports( + moduleExports, + id, + filter, + ); + if (testedExports !== undefined) { + cacheId(id, testedExports); + return { id, defaultExport }; + } + } + + finish(true); + return {}; +} + +/** + * Returns the id of the first module where filter returns non-undefined, and undefined otherwise. + * @param filter find calls filter once for each enumerable module's exports until it finds one where filter returns a thruthy value. + */ +export function findModuleId(filter: FilterFn) { + return findModule(filter)?.id; +} + +/** + * Returns the exports of the first module where filter returns non-undefined, and undefined otherwise. + * @param filter find calls filter once for each enumerable module's exports until it finds one where filter returns a thruthy value. + */ +export function findExports(filter: FilterFn) { + const { id, defaultExport } = findModule(filter); + if (id == null) return; + + return defaultExport ? requireModule(id).default : requireModule(id); +} + +/** + * Returns the [id, defaultExports] of all modules where filter returns non-undefined. + * @param filter findAll calls filter once for each enumerable module's exports, adding the exports to the returned array when filter returns a thruthy value. + */ +export function findAllModule(filter: FilterFn) { + const { cacheId, finish } = getCacherForUniq(filter.uniq, true); + const foundExports: {id: number, defaultExport: boolean}[] = []; + + for (const [id, moduleExports] of getModules(filter.uniq, true)) { + const { exports: testedExports, defaultExport } = filterExports( + moduleExports, + id, + filter, + ); + if (testedExports !== undefined && typeof defaultExport === "boolean") { + foundExports.push({ id, defaultExport }); + cacheId(id, testedExports); + } + } + + finish(foundExports.length === 0); + return foundExports; +} + +/** + * Returns the ids of all modules where filter returns non-undefined. + * @param filter findAll calls filter once for each enumerable module's exports, adding the exports to the returned array when filter returns a thruthy value. + */ +export function findAllModuleId(filter: FilterFn) { + return findAllModule(filter).map(e => e.id); +} + +/** + * Returns the ids of all exports where filter returns non-undefined. + * @param filter findAll calls filter once for each enumerable module's exports, adding the exports to the returned array when filter returns a thruthy value. + */ +export function findAllExports(filter: FilterFn) { + return findAllModule(filter).map(ret => { + if (!ret.id) return; + + const { id, defaultExport } = ret; + return defaultExport ? requireModule(id).default : requireModule(id); + }); +} diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..d8d38b4 --- /dev/null +++ b/global.d.ts @@ -0,0 +1,18 @@ +declare global { + type React = typeof import("react"); + var SkiaApi: typeof import("@shopify/react-native-skia").Skia; + + // ReactNative/Hermes globals + var globalEvalWithSourceUrl: (script: string, sourceURL: string) => any; + var nativePerformanceNow: typeof performance.now; + var nativeModuleProxy: Record; + var __turboModuleProxy: (name: string) => any; + + interface Window { + [key: string]: any; + vendetta: any; + bunny: typeof import("@lib"); + } +} + +export { }; diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..ba8ecac --- /dev/null +++ b/index.ts @@ -0,0 +1,49 @@ +import "../global.d.ts"; // eslint-disable-line import-alias/import-alias +import "../modules.d.ts"; // eslint-disable-line import-alias/import-alias + +export * as fonts from "./addons/fonts/index.js"; +export * as plugins from "./addons/plugins/index.js"; +export * as themes from "./addons/themes/index.js"; +export * as api from "./api"; +export * as ui from "./ui"; +export * as utils from "./utils"; +export * as metro from "@metro"; + +import * as fonts from "./addons/fonts/index.js"; +import * as plugins from "./addons/plugins/index.js"; +import * as themes from "./addons/themes/index.js"; + +/** @internal */ +export * as _jsx from "react/jsx-runtime"; + +import { proxyLazy } from "./utils/lazy"; + +/** + * @internal + * @deprecated Moved to top level (bunny.*) + */ +export const managers = proxyLazy(() => { + console.warn("bunny.managers.* is deprecated, and moved the top level (bunny.*). bunny.managers will be eventually removed soon"); + + return { + get fonts() { return fonts; }, + get plugins() { return plugins; }, + get themes() { return themes; } + }; +}, { hint: "object" }); + +const _disposer = [] as Array<() => unknown>; + +export function unload() { + for (const d of _disposer) if (typeof d === "function") d(); + // @ts-expect-error + delete window.bunny; +} + +/** + * For internal use only, do not use! + * @internal + */ +unload.push = (fn: typeof _disposer[number]) => { + _disposer.push(fn); +}; diff --git a/internals/caches.ts b/internals/caches.ts new file mode 100644 index 0000000..b5192b8 --- /dev/null +++ b/internals/caches.ts @@ -0,0 +1,130 @@ +import { fileExists, readFile, writeFile } from "@lib/api/native/fs"; +import { NativeClientInfoModule } from "@lib/api/native/modules"; +import { debounce } from "es-toolkit"; + +import { ModuleFlags, ModulesMapInternal } from "./enums"; + +const CACHE_VERSION = 102; +const BUNNY_METRO_CACHE_PATH = "caches/metro_modules.json"; + +type ModulesMap = { + [flag in number | `_${ModulesMapInternal}`]?: ModuleFlags; +}; + +let _metroCache = null as unknown as ReturnType; + +export const getMetroCache = () => _metroCache; + +function buildInitCache() { + const cache = { + _v: CACHE_VERSION, + _buildNumber: NativeClientInfoModule.getConstants().Build, + _modulesCount: Object.keys(window.modules).length, + flagsIndex: {} as Record, + findIndex: {} as Record, + polyfillIndex: {} as Record + } as const; + + // Force load all modules so useful modules are pre-cached. Add a minor + // delay so the cache is initialized before the modules are loaded. + setTimeout(() => { + for (const id in window.modules) { + require("./modules").requireModule(id); + } + }, 100); + + _metroCache = cache; + return cache; +} + +/** @internal */ +export async function initMetroCache() { + if (!await fileExists(BUNNY_METRO_CACHE_PATH)) return void buildInitCache(); + const rawCache = await readFile(BUNNY_METRO_CACHE_PATH); + + try { + _metroCache = JSON.parse(rawCache); + if (_metroCache._v !== CACHE_VERSION) { + _metroCache = null!; + throw "cache invalidated; cache version outdated"; + } + if (_metroCache._buildNumber !== NativeClientInfoModule.getConstants().Build) { + _metroCache = null!; + throw "cache invalidated; version mismatch"; + } + if (_metroCache._modulesCount !== Object.keys(window.modules).length) { + _metroCache = null!; + throw "cache invalidated; modules count mismatch"; + } + } catch { + buildInitCache(); + } +} + +const saveCache = debounce(() => { + writeFile(BUNNY_METRO_CACHE_PATH, JSON.stringify(_metroCache)); +}, 1000); + +function extractExportsFlags(moduleExports: any) { + if (!moduleExports) return undefined; + + const bit = ModuleFlags.EXISTS; + return bit; +} + +/** @internal */ +export function indexExportsFlags(moduleId: number, moduleExports: any) { + const flags = extractExportsFlags(moduleExports); + if (flags && flags !== ModuleFlags.EXISTS) { + _metroCache.flagsIndex[moduleId] = flags; + } +} + +/** @internal */ +export function indexBlacklistFlag(id: number) { + _metroCache.flagsIndex[id] |= ModuleFlags.BLACKLISTED; +} + +/** @internal */ +export function indexAssetModuleFlag(id: number) { + _metroCache.flagsIndex[id] |= ModuleFlags.ASSET; +} + +/** @internal */ +export function getCacherForUniq(uniq: string, allFind: boolean) { + const indexObject = _metroCache.findIndex[uniq] ??= {}; + + return { + cacheId(moduleId: number, exports: any) { + indexObject[moduleId] ??= extractExportsFlags(exports); + + saveCache(); + }, + // Finish may not be called by single find + finish(notFound: boolean) { + if (allFind) indexObject[`_${ModulesMapInternal.FULL_LOOKUP}`] = 1; + if (notFound) indexObject[`_${ModulesMapInternal.NOT_FOUND}`] = 1; + + saveCache(); + } + }; +} + +/** @internal */ +export function getPolyfillModuleCacher(name: string) { + const indexObject = _metroCache.polyfillIndex[name] ??= {}; + + return { + getModules() { + return require("@metro/internals/modules").getCachedPolyfillModules(name); + }, + cacheId(moduleId: number) { + indexObject[moduleId] = 1; + saveCache(); + }, + finish() { + indexObject[`_${ModulesMapInternal.FULL_LOOKUP}`] = 1; + saveCache(); + } + }; +} diff --git a/internals/enums.ts b/internals/enums.ts new file mode 100644 index 0000000..71cf9de --- /dev/null +++ b/internals/enums.ts @@ -0,0 +1,10 @@ +export enum ModuleFlags { + EXISTS = 1 << 0, + BLACKLISTED = 1 << 1, + ASSET = 1 << 2, +} + +export enum ModulesMapInternal { + FULL_LOOKUP, + NOT_FOUND +} diff --git a/internals/modules.ts b/internals/modules.ts new file mode 100644 index 0000000..f62020f --- /dev/null +++ b/internals/modules.ts @@ -0,0 +1,266 @@ +import { + getMetroCache, + indexBlacklistFlag, + indexExportsFlags, +} from "@metro/internals/caches"; +import { Metro } from "@metro/types"; + +import { ModuleFlags, ModulesMapInternal } from "./enums"; + +const { before, instead } = require("spitroast"); + +export const metroModules: Metro.ModuleList = window.modules; +const metroRequire = (id: string | number) => window.__r(+id); + +// eslint-disable-next-line func-call-spacing +const moduleSubscriptions = new Map void>>(); +const blacklistedIds = new Set(); +const noopHandler = () => undefined; +const functionToString = Function.prototype.toString; + +let patchedInspectSource = false; +let patchedImportTracker = false; +let patchedNativeComponentRegistry = false; +let _importingModuleId: number = -1; + +for (const key in metroModules) { + const id = Number(key); + const metroModule = metroModules[id]; + + const cache = getMetroCache().flagsIndex[id]; + if (cache & ModuleFlags.BLACKLISTED) { + blacklistModule(id); + continue; + } + + if (metroModule!.factory) { + instead("factory", metroModule, (( + args: Parameters, + origFunc: Metro.FactoryFn, + ) => { + const originalImportingId = _importingModuleId; + _importingModuleId = id; + + const { 1: metroRequire, 4: moduleObject } = args; + + args[2 /* metroImportDefault */] = (id) => { + const exps = metroRequire(id); + return exps && exps.__esModule ? exps.default : exps; + }; + + args[3 /* metroImportAll */] = (id) => { + const exps = metroRequire(id); + if (exps && exps.__esModule) return exps; + + const importAll: Record = {}; + if (exps) Object.assign(importAll, exps); + importAll.default = exps; + return importAll; + }; + + origFunc(...args); + if (!isBadExports(moduleObject.exports)) { + onModuleRequire(moduleObject.exports, id); + } else { + blacklistModule(id); + } + + _importingModuleId = originalImportingId; + }) as any); // If only spitroast had better types + } +} + +/** Makes the module associated with the specified ID non-enumberable. */ +function blacklistModule(id: number) { + Object.defineProperty(metroModules, id, { enumerable: false }); + blacklistedIds.add(id); + indexBlacklistFlag(Number(id)); +} + +function isBadExports(exports: any) { + return ( + !exports || + exports === window || + exports[""] === + null || + (exports.__proto__ === Object.prototype && + Reflect.ownKeys(exports).length === 0) || + exports.default?.[Symbol.toStringTag] === "IntlMessagesProxy" + ); +} + +function onModuleRequire(moduleExports: any, id: Metro.ModuleID) { + indexExportsFlags(id, moduleExports); + + // Temporary + moduleExports.initSentry &&= () => undefined; + if (moduleExports.default?.track && moduleExports.default.trackMaker) + moduleExports.default.track = () => Promise.resolve(); + + if (moduleExports.registerAsset) { + require("@lib/api/assets/patches").patchAssets(moduleExports); + } + + // There are modules registering the same native component + if ( + !patchedNativeComponentRegistry && + [ + "customBubblingEventTypes", + "customDirectEventTypes", + "register", + "get", + ].every((x) => moduleExports[x]) + ) { + instead("register", moduleExports, ([name, cb]: any, origFunc: any) => { + try { + return origFunc(name, cb); + } catch { + return name; + } + }); + + patchedNativeComponentRegistry = true; + } + + // Hook DeveloperExperimentStore + if ( + moduleExports?.default?.constructor?.displayName === + "DeveloperExperimentStore" + ) { + moduleExports.default = new Proxy(moduleExports.default, { + get(target, property, receiver) { + if (property === "isDeveloper") { + // Hopefully won't explode accessing it here :3 + const { settings } = require("@lib/api/settings"); + return settings.enableDiscordDeveloperSettings ?? false; + } + + return Reflect.get(target, property, receiver); + }, + }); + } + + if (!patchedImportTracker && moduleExports.fileFinishedImporting) { + before("fileFinishedImporting", moduleExports, ([filePath]: [string]) => { + if (_importingModuleId === -1 || !filePath) return; + metroModules[_importingModuleId]!.__filePath = filePath; + }); + patchedImportTracker = true; + } + + // Funny infinity recursion caused by a race condition + if (!patchedInspectSource && window["__core-js_shared__"]) { + const inspect = (f: unknown) => + typeof f === "function" && functionToString.apply(f, []); + window["__core-js_shared__"].inspectSource = inspect; + patchedInspectSource = true; + } + + // + if (moduleExports.findHostInstance_DEPRECATED) { + const prevExports = metroModules[id - 1]?.publicModule.exports; + const inc = prevExports.default?.reactProfilingEnabled ? 1 : -1; + if (!metroModules[id + inc]?.isInitialized) { + blacklistModule(id + inc); + } + } + + // Hindi timestamps moment + if (moduleExports.isMoment) { + instead( + "defineLocale", + moduleExports, + (args: [string], orig: (lcl: string) => string) => { + const origLocale = moduleExports.locale(); + orig(...args); + moduleExports.locale(origLocale); + }, + ); + } + + const subs = moduleSubscriptions.get(Number(id)); + if (subs) { + subs.forEach((s) => s()); + moduleSubscriptions.delete(Number(id)); + } +} + +export function getImportingModuleId() { + return _importingModuleId; +} + +export function subscribeModule(id: number, cb: () => void): () => void { + const subs = moduleSubscriptions.get(id) ?? new Set(); + + subs.add(cb); + moduleSubscriptions.set(id, subs); + + return () => subs.delete(cb); +} + +export function requireModule(id: Metro.ModuleID) { + if (!metroModules[0]?.isInitialized) metroRequire(0); + if (blacklistedIds.has(id)) return undefined; + + if (Number(id) === -1) return require("@metro/polyfills/redesign"); + + if (metroModules[id]?.isInitialized && !metroModules[id]?.hasError) { + return metroRequire(id); + } + + // Disable Internal RN error reporting logic + const originalHandler = ErrorUtils.getGlobalHandler(); + ErrorUtils.setGlobalHandler(noopHandler); + + let moduleExports; + try { + moduleExports = metroRequire(id); + } catch { + blacklistModule(id); + moduleExports = undefined; + } + + // Done initializing! Now, revert our hacks + ErrorUtils.setGlobalHandler(originalHandler); + + return moduleExports; +} + +export function* getModules(uniq: string, all = false) { + yield [-1, require("@metro/polyfills/redesign")]; + + let cache = getMetroCache().findIndex[uniq]; + if (all && !cache?.[`_${ModulesMapInternal.FULL_LOOKUP}`]) cache = undefined; + if (cache?.[`_${ModulesMapInternal.NOT_FOUND}`]) return; + + for (const id in cache) { + if (id[0] === "_") continue; + const exports = requireModule(Number(id)); + if (isBadExports(exports)) continue; + yield [id, exports]; + } + + for (const id in metroModules) { + const exports = requireModule(Number(id)); + if (isBadExports(exports)) continue; + yield [id, exports]; + } +} + +export function* getCachedPolyfillModules(name: string) { + const cache = getMetroCache().polyfillIndex[name]!; + + for (const id in cache) { + const exports = requireModule(Number(id)); + if (isBadExports(exports)) continue; + yield [id, exports]; + } + + if (!cache[`_${ModulesMapInternal.FULL_LOOKUP}`]) { + for (const id in metroModules) { + const exports = requireModule(Number(id)); + if (isBadExports(exports)) continue; + yield [id, exports]; + } + } +} diff --git a/lazy.ts b/lazy.ts new file mode 100644 index 0000000..0ada336 --- /dev/null +++ b/lazy.ts @@ -0,0 +1,74 @@ +import { _patcherDelaySymbol } from "@lib/api/patcher"; +import { proxyLazy } from "@lib/utils/lazy"; + +import { findExports } from "./finders"; +import { getMetroCache } from "./internals/caches"; +import { metroModules, subscribeModule } from "./internals/modules"; +import type { FilterFn, LazyModuleContext } from "./types"; + +/** @internal */ +export const _lazyContextSymbol = Symbol.for("bunny.metro.lazyContext"); + +const _lazyContexts = new WeakMap(); + +function getIndexedFind(filter: FilterFn) { + const modulesMap = getMetroCache().findIndex[filter.uniq]; + if (!modulesMap) return undefined; + + for (const k in modulesMap) + if (k[0] !== "_") return Number(k); +} + +function subscribeLazyModule(proxy: any, callback: (exports: any) => void) { + const info = getLazyContext(proxy); + if (!info) throw new Error("Subscribing a module for non-proxy-find"); + if (!info.indexed) throw new Error("Attempting to subscribe to a non-indexed find"); + + return subscribeModule(info.moduleId!, () => { + callback(findExports(info.filter)); + }); +} + +export function getLazyContext(proxy: any): LazyModuleContext | void { + return _lazyContexts.get(proxy) as unknown as LazyModuleContext; +} + +export function createLazyModule(filter: FilterFn) { + let cache: any = undefined; + + const moduleId = getIndexedFind(filter); + const context: LazyModuleContext = { + filter, + indexed: !!moduleId, + moduleId, + getExports(cb: (exports: any) => void) { + if (!moduleId || metroModules[moduleId]?.isInitialized) { + cb(this.forceLoad()); + return () => void 0; + } + return this.subscribe(cb); + }, + subscribe(cb: (exports: any) => void) { + return subscribeLazyModule(proxy, cb); + }, + get cache() { + return cache; + }, + forceLoad() { + cache ??= findExports(filter); + if (!cache) throw new Error(`${filter.uniq} is ${typeof cache}! (id ${context.moduleId ?? "unknown"})`); + return cache; + } + }; + + const proxy = proxyLazy(() => context.forceLoad(), { + exemptedEntries: { + [_lazyContextSymbol]: context, + [_patcherDelaySymbol]: (cb: (exports: any) => void) => context.getExports(cb) + } + }); + + _lazyContexts.set(proxy, context as LazyModuleContext); + + return proxy; +} diff --git a/modules.d.ts b/modules.d.ts new file mode 100644 index 0000000..63febfd --- /dev/null +++ b/modules.d.ts @@ -0,0 +1,18 @@ +declare module "bunny-build-info" { + const version: string; +} + +declare module "*.png" { + const str: string; + export default str; +} + +/** + * Optional runtime helper used by the startup sequence. + * Declared here to satisfy the typechecker when the file isn't present. + */ +declare module "@core/debug/toggleCorePlugins" { + type ToggleOptions = { offDuration?: number }; + const _default: (opts?: ToggleOptions) => Promise; + export default _default; +} diff --git a/polyfills/redesign.ts b/polyfills/redesign.ts new file mode 100644 index 0000000..9e5c1a8 --- /dev/null +++ b/polyfills/redesign.ts @@ -0,0 +1,136 @@ +import { getPolyfillModuleCacher } from "@metro/internals/caches"; +import { LiteralUnion } from "type-fest"; + +const redesignProps = new Set([ + "AlertActionButton", + "AlertModal", + "AlertModalContainer", + "AvatarDuoPile", + "AvatarPile", + "BACKDROP_OPAQUE_MAX_OPACITY", + "Backdrop", + "Button", + "Card", + "ContextMenu", + "ContextMenuContainer", + "FauxHeader", + "FloatingActionButton", + "GhostInput", + "GuildIconPile", + "HeaderActionButton", + "HeaderButton", + "HeaderSubmittingIndicator", + "IconButton", + "Input", + "InputButton", + "InputContainer", + "LayerContext", + "LayerScope", + "Modal", + "ModalActionButton", + "ModalContent", + "ModalDisclaimer", + "ModalFloatingAction", + "ModalFloatingActionSpacer", + "ModalFooter", + "ModalScreen", + "ModalStepIndicator", + "NAV_BAR_HEIGHT", + "NAV_BAR_HEIGHT_MULTILINE", + "Navigator", + "NavigatorHeader", + "NavigatorScreen", + "Pile", + "PileOverflow", + "RedesignCompat", + "RedesignCompatContext", + "RowButton", + "STATUS_BAR_HEIGHT", + "SceneLoadingIndicator", + "SearchField", + "SegmentedControl", + "SegmentedControlPages", + "Slider", + "Stack", + "StepModal", + "StickyContext", + "StickyHeader", + "StickyWrapper", + "TABLE_ROW_CONTENT_HEIGHT", + "TABLE_ROW_HEIGHT", + "TableCheckboxRow", + "TableRadioGroup", + "TableRadioRow", + "TableRow", + "TableRowGroup", + "TableRowGroupTitle", + "TableRowIcon", + "TableSwitchRow", + "Tabs", + "TextArea", + "TextField", + "TextInput", + "Toast", + "dismissAlerts", + "getHeaderBackButton", + "getHeaderCloseButton", + "getHeaderConditionalBackButton", + "getHeaderNoTitle", + "getHeaderTextButton", + "hideContextMenu", + "navigatorShouldCrossfade", + "openAlert", + "useAccessibilityNativeStackOptions", + "useAndroidNavScrim", + "useCoachmark", + "useFloatingActionButtonScroll", + "useFloatingActionButtonState", + "useNativeStackNavigation", + "useNavigation", + "useNavigationTheme", + "useNavigatorBackPressHandler", + "useNavigatorScreens", + "useNavigatorShouldCrossfade", + "useSegmentedControlState", + "useStackNavigation", + "useTabNavigation", + "useTooltip" +] as const); + +type Keys = LiteralUnion ? U : string, string>; + +const _module = {} as Record; +const _source = {} as Record; + +const cacher = getPolyfillModuleCacher("redesign_module"); + +for (const [id, moduleExports] of cacher.getModules()) { + for (const prop of redesignProps) { + let actualExports: any; + + if (moduleExports[prop]) { + actualExports = moduleExports; + } else if (moduleExports.default?.[prop]) { + actualExports = moduleExports.default; + } else { + continue; + } + + const exportsKeysLength = Reflect.ownKeys(actualExports).length; + if (_source[prop] && exportsKeysLength >= _source[prop]) { + continue; + } + + _module[prop] = actualExports[prop]; + _source[prop] = Reflect.ownKeys(actualExports).length; + cacher.cacheId(id); + + if (exportsKeysLength === 1) { + redesignProps.delete(prop); + } + } +} + +cacher.finish(); + +export default _module; diff --git a/scripts/build.mjs b/scripts/build.mjs index c184bcb..d98d3e5 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -74,7 +74,7 @@ const config = { constModules: { globals: { "bunny-build-info": { - version: `"1.1.11"`, + version: `"Jolly 1.2"`, }, }, }, diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..82f1c25 --- /dev/null +++ b/types.ts @@ -0,0 +1,159 @@ +import { Nullish } from "@lib/utils/types"; + +/** @see {@link https://github.com/facebook/metro/blob/c2d7539dfc10aacb2f99fcc2f268a3b53e867a90/packages/metro-runtime/src/polyfills/require.js} */ +export namespace Metro { + export type DependencyMap = Array & { + readonly paths?: Readonly> | undefined; + }; + + /** Only available on Discord's development environment, will never be defined on release builds */ + export type InverseDependencyMap = Record; + + export type FactoryFn = ( + global: object, + require: RequireFn, + metroImportDefault: RequireFn, + metroImportAll: RequireFn, + moduleObject: { + exports: any; + }, + exports: any, + dependencyMap: DependencyMap | Nullish, + ) => void; + + /** Only available on Discord's development environment, will never be defined on release builds */ + export interface HotModuleReloadingData { + _acceptCallback: (() => void) | Nullish; + _disposeCallback: (() => void) | Nullish; + _didAccept: boolean; + accept: (callback?: (() => void) | undefined) => void; + dispose: (callback?: (() => void) | undefined) => void; + } + + export type ModuleID = number; + + export interface Module { + id?: ModuleID | undefined; + exports: any; + hot?: HotModuleReloadingData | undefined; + } + + export interface ModuleDefinition { + /** Set to undefined once module is initialized */ + dependencyMap: DependencyMap | Nullish; + /** Error.value thrown by the factory */ + error?: any; + /** Set to undefined once module is initialized */ + factory: FactoryFn | undefined; + /** + * If factory thrown any error + * */ + hasError: boolean; + /** + * Only available on Discord's development environment, will never be defined on release builds + * */ + hot?: HotModuleReloadingData | undefined; + /** + * Cached `import *` imports in Metro, always an empty object as Bunny prevents outdated import cache + * */ + importedAll: any; + /** + * Cached `import module from "./module"` imports in Metro, always an empty object as Bunny prevents outdated import cache + * */ + importedDefault: any; + /** + * Whether factory has been successfully called + * */ + isInitialized: boolean; + /** + * Only available on Discord's development environment, will never be defined on release builds + * */ + path?: string | undefined; + /** + * Acts as CJS module in the bundler + * */ + publicModule: Module; + /** + * Only available on Discord's development environment, will never be defined on release builds + * */ + verboseName?: string | undefined; + + /** + * This is set by us. Should be available for all Discord's tsx modules! + */ + __filePath?: string; + } + + export type ModuleList = Record; + + export type RequireFn = (id: ModuleID) => any; + + export type DefineFn = ( + factory: FactoryFn, + moduleId: ModuleID, + dependencyMap?: DependencyMap | undefined, + /** Only available on Discord's development environment, will never be defined on release builds */ + verboseName?: string | undefined, + /** Only available on Discord's development environment, will never be defined on release builds */ + inverseDependencies?: InverseDependencyMap | undefined + ) => void; + + export type ModuleDefiner = (moduleId: ModuleID) => void; + + export type ClearFn = () => ModuleList; + + export type RegisterSegmentFn = ( + segmentId: number, + moduleDefiner: ModuleDefiner, + moduleIds: Readonly | Nullish + ) => void; + + export interface Require extends RequireFn { + importDefault: RequireFn; + importAll: RequireFn; + /** @throws {Error} A macro, will always throws an error at runtime */ + context: () => never; + /** @throws {Error} A macro, will always throws an error at runtime */ + resolveWeak: () => never; + unpackModuleId: (moduleId: ModuleID) => { + localId: number; + segmentId: number; + }; + packModuleId: (value: { + localId: number; + segmentId: number; + }) => ModuleID; + } +} + + +export type ModuleExports = any; +export type FilterCheckDef = ( + args: A, + module: any, + modulesId: number, + defaultCheck: boolean +) => boolean; + +export interface FilterFn { + (m: any, id: number, defaultCheck: boolean): boolean; + filter: FilterCheckDef; + raw: boolean; + uniq: string; +} + +export interface FilterDefinition { + (...args: A): FilterFn; + byRaw(...args: A): FilterFn; + uniqMaker(args: A): string; +} + +export interface LazyModuleContext { + filter: FilterFn; + indexed: boolean; + moduleId?: number; + getExports(cb: (exports: any) => void): () => void; + subscribe(cb: (exports: any) => void): () => void; + forceLoad(): any; + get cache(): any; +} diff --git a/ui/alerts.ts b/ui/alerts.ts new file mode 100644 index 0000000..f80fe38 --- /dev/null +++ b/ui/alerts.ts @@ -0,0 +1,4 @@ +import { lazyDestructure } from "@lib/utils/lazy"; +import { findByProps } from "@metro"; + +export const { openAlert, dismissAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert")); diff --git a/ui/color.ts b/ui/color.ts new file mode 100644 index 0000000..8b1e7cc --- /dev/null +++ b/ui/color.ts @@ -0,0 +1,30 @@ +import { constants } from "@metro/common"; +import { findByProps, findByStoreNameLazy } from "@metro/wrappers"; + +//! This module is only found on 165.0+, under the assumption that iOS 165.0 is the same as Android 165.0. +//* In 167.1, most if not all traces of the old color modules were removed. +//* In 168.6, Discord restructured EVERYTHING again. SemanticColor on this module no longer works when passed to a stylesheet. We must now use what you see below. +//* In 173.10, Discord restructured a lot of the app. These changes included making the color module impossible to early-find. +// ? To stop duplication, it is now exported in our theming code. +// ? These comments are preserved for historical purposes. +// const colorModule = findByPropsProxy("colors", "meta"); + +const color = findByProps("SemanticColor"); + +// ? SemanticColor and default.colors are effectively ThemeColorMap +export const semanticColors = (color?.default?.colors ?? constants?.ThemeColorMap) as Record; + +// ? RawColor and default.unsafe_rawColors are effectively Colors +//* Note that constants.Colors does still appear to exist on newer versions despite Discord not internally using it - what the fuck? +export const rawColors = (color?.default?.unsafe_rawColors ?? constants?.Colors) as Record; + +const ThemeStore = findByStoreNameLazy("ThemeStore"); +const colorResolver = color.default.meta ??= color.default.internal; + +export function isSemanticColor(sym: any): boolean { + return colorResolver.isSemanticColor(sym); +} + +export function resolveSemanticColor(sym: any, theme = ThemeStore.theme): string { + return colorResolver.resolveSemanticColor(theme, sym); +} diff --git a/ui/components/Codeblock.tsx b/ui/components/Codeblock.tsx new file mode 100644 index 0000000..7b37a9a --- /dev/null +++ b/ui/components/Codeblock.tsx @@ -0,0 +1,37 @@ +import { constants } from "@metro/common"; +import { semanticColors } from "@ui/color"; +import { createStyles } from "@ui/styles"; +import { Platform, Text, TextInput } from "react-native"; + +export interface CodeblockProps { + selectable?: boolean; + style?: import("react-native").TextStyle; + children?: string; +} + +const useStyles = createStyles({ + codeBlock: { + fontFamily: constants.Fonts.CODE_NORMAL, + fontSize: 12, + textAlignVertical: "center", + backgroundColor: semanticColors.BACKGROUND_SECONDARY, + color: semanticColors.TEXT_NORMAL, + borderWidth: 1, + borderRadius: 12, + borderColor: semanticColors.BACKGROUND_TERTIARY, + padding: 10, + }, +}); + +// iOS doesn't support the selectable property on RN.Text... +const InputBasedCodeblock = ({ style, children }: CodeblockProps) => ; +const TextBasedCodeblock = ({ selectable, style, children }: CodeblockProps) => {children}; + +export default function Codeblock({ selectable, style, children }: CodeblockProps) { + if (!selectable) return ; + + return Platform.select({ + ios: , + default: , + }); +} diff --git a/ui/components/ErrorBoundary.tsx b/ui/components/ErrorBoundary.tsx new file mode 100644 index 0000000..e70ef47 --- /dev/null +++ b/ui/components/ErrorBoundary.tsx @@ -0,0 +1,36 @@ +import ErrorCard from "@core/ui/reporter/components/ErrorCard"; +import { React } from "@metro/common"; +import { ThemeContext } from "@ui/styles"; +import { Falsy } from "react-native"; + +type ErrorBoundaryState = { + hasErr: false; +} | { + hasErr: true; + error: Error; +}; + +export interface ErrorBoundaryProps { + children: JSX.Element | Falsy | (JSX.Element | Falsy)[]; +} + +export default class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasErr: false }; + } + + static contextType = ThemeContext; + static getDerivedStateFromError = (error: Error) => ({ hasErr: true, error }); + + render() { + if (!this.state.hasErr) return this.props.children; + + return ( + this.setState({ hasErr: false })} + /> + ); + } +} diff --git a/ui/components/InputAlert.tsx b/ui/components/InputAlert.tsx new file mode 100644 index 0000000..c347857 --- /dev/null +++ b/ui/components/InputAlert.tsx @@ -0,0 +1,58 @@ +import { LegacyAlert, LegacyFormInput } from "@metro/common/components"; +import { findByPropsLazy } from "@metro/wrappers"; + +const Alerts = findByPropsLazy("openLazy", "close"); + +export interface InputAlertProps { + title?: string; + confirmText?: string; + confirmColor?: string; + onConfirm: (input: string) => (void | Promise); + cancelText?: string; + placeholder?: string; + initialValue?: string; + secureTextEntry?: boolean; +} + +export default function InputAlert({ title, confirmText, confirmColor, onConfirm, cancelText, placeholder, initialValue = "", secureTextEntry }: InputAlertProps) { + const [value, setValue] = React.useState(initialValue); + const [error, setError] = React.useState(""); + + function onConfirmWrapper() { + const asyncOnConfirm = Promise.resolve(onConfirm(value)); + + asyncOnConfirm.then(() => { + Alerts.close(); + }).catch((e: Error) => { + setError(e.message); + }); + } + + return ( + Alerts.close()} + > + { + setValue(typeof v === "string" ? v : v.text); + if (error) setError(""); + }} + returnKeyType="done" + onSubmitEditing={onConfirmWrapper} + error={error || undefined} + secureTextEntry={secureTextEntry} + autoFocus={true} + showBorder={true} + style={{ alignSelf: "stretch" }} + /> + + ); +} diff --git a/ui/components/Search.tsx b/ui/components/Search.tsx new file mode 100644 index 0000000..db97b71 --- /dev/null +++ b/ui/components/Search.tsx @@ -0,0 +1,58 @@ +import { React } from "@metro/common"; +import { Strings } from "@core/i18n"; +import { findAssetId } from "@lib/api/assets"; +import { TextInput } from "@metro/common/components"; +import ErrorBoundary from "@ui/components/ErrorBoundary"; +import { Image, View, ViewStyle } from "react-native"; + +export interface SearchProps { + onChangeText?: (v: string) => void; + placeholder?: string; + style?: ViewStyle; + isRound?: boolean; +} + +function SearchIcon() { + return ( + + ); +} + +export default ({ onChangeText, placeholder, style, isRound }: SearchProps) => { + const [query, setQuery] = React.useState(""); + + const onChange = (value: string) => { + setQuery(value); + onChangeText?.(value); + }; + + // When isRound is true, provide an explicit pill-style appearance so the + // input looks rounded regardless of underlying component defaults. + const inputStyle = isRound + ? ({ borderRadius: 9999, overflow: "hidden" } as ViewStyle) + : undefined; + + return ( + + + + + + ); +}; diff --git a/ui/components/Summary.tsx b/ui/components/Summary.tsx new file mode 100644 index 0000000..fb99eac --- /dev/null +++ b/ui/components/Summary.tsx @@ -0,0 +1,32 @@ +import { findAssetId } from "@lib/api/assets"; +import { LegacyFormRow, TableRow } from "@metro/common/components"; +import { LayoutAnimation, View } from "react-native"; + +export interface SummaryProps { + label: string; + icon?: string; + noPadding?: boolean; + noAnimation?: boolean; + children: JSX.Element | JSX.Element[]; +} + +export default function Summary({ label, icon, noPadding = false, noAnimation = false, children }: SummaryProps) { + const [hidden, setHidden] = React.useState(true); + + return ( + <> + } + trailing={} + onPress={() => { + setHidden(!hidden); + if (!noAnimation) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + }} + /> + {!hidden && <> + {children} + } + + ); +} diff --git a/ui/components/index.ts b/ui/components/index.ts new file mode 100644 index 0000000..b6d6f2e --- /dev/null +++ b/ui/components/index.ts @@ -0,0 +1,5 @@ +export * as wrappers from "./wrappers"; +export { default as Codeblock } from "@ui/components/Codeblock"; +export { default as ErrorBoundary } from "@ui/components/ErrorBoundary"; +export { default as Search } from "@ui/components/Search"; +export { default as Summary } from "@ui/components/Summary"; diff --git a/ui/components/wrappers/AlertModal.tsx b/ui/components/wrappers/AlertModal.tsx new file mode 100644 index 0000000..58ab079 --- /dev/null +++ b/ui/components/wrappers/AlertModal.tsx @@ -0,0 +1,39 @@ +import { lazyDestructure } from "@lib/utils/lazy"; +import { findByFilePath, findByProps } from "@metro"; +import { Text } from "@metro/common/components"; +import { Button } from "@metro/common/types/components"; +import { ComponentProps, ComponentType, ReactNode } from "react"; +import { View } from "react-native"; + +type ActionButtonProps = Omit, "onPress"> & { + onPress?: () => void | Promise; +}; + +const { + AlertModal: _AlertModal, + AlertActionButton: _AlertActionButton +} = lazyDestructure(() => findByProps("AlertModal", "AlertActions")); + +export const AlertActionButton = _AlertActionButton as ComponentType; + +export default function AlertModal(props: Record) { + const forwardFailedModal = findByFilePath("modules/forwarding/native/ForwardFailedAlertModal.tsx"); + + // ponyfill for extraContent + if (!forwardFailedModal && "extraContent" in props) { + props.content = ( + + + {props.content as string} + + + {props.extraContent as ReactNode} + + + ); + + delete props.extraContent; + } + + return <_AlertModal {...props} />; +} diff --git a/ui/components/wrappers/index.ts b/ui/components/wrappers/index.ts new file mode 100644 index 0000000..0a6cdd8 --- /dev/null +++ b/ui/components/wrappers/index.ts @@ -0,0 +1,3 @@ +import AlertModal, { AlertActionButton } from "./AlertModal"; + +export { AlertActionButton, AlertModal }; diff --git a/ui/index.ts b/ui/index.ts new file mode 100644 index 0000000..6417d63 --- /dev/null +++ b/ui/index.ts @@ -0,0 +1,7 @@ + +export * as alerts from "./alerts"; +export * as components from "./components"; +export * as settings from "./settings"; +export * as sheets from "./sheets"; +export * as styles from "./styles"; +export * as toasts from "./toasts"; diff --git a/ui/settings/index.tsx b/ui/settings/index.tsx new file mode 100644 index 0000000..0e96627 --- /dev/null +++ b/ui/settings/index.tsx @@ -0,0 +1,37 @@ +// Good luck reading this! +import { lazy } from "react"; +import type { ImageURISource } from "react-native"; + +import { patchTabsUI } from "./patches/tabs"; + +export interface RowConfig { + key: string; + title: () => string; + onPress?: () => any; + render?: Parameters[0]; + icon?: ImageURISource | number; + IconComponent?: React.ReactNode; + usePredicate?: () => boolean; + useTrailing?: () => string | JSX.Element; + rawTabsConfig?: Record; +} + +export const registeredSections = {} as { + [key: string]: RowConfig[]; +}; + +export function registerSection(section: { name: string; items: RowConfig[] }) { + registeredSections[section.name] = section.items; + return () => delete registeredSections[section.name]; +} + +/** + * @internal + */ +export function patchSettings() { + const unpatches = new Array<() => boolean>(); + + patchTabsUI(unpatches); + + return () => unpatches.forEach((u) => u()); +} diff --git a/ui/settings/patches/panel.tsx b/ui/settings/patches/panel.tsx new file mode 100644 index 0000000..a5cf9eb --- /dev/null +++ b/ui/settings/patches/panel.tsx @@ -0,0 +1,123 @@ +import { after } from "@lib/api/patcher"; +import { findInReactTree } from "@lib/utils"; +import { i18n, NavigationNative } from "@metro/common"; +import { + LegacyFormDivider, + LegacyFormIcon, + LegacyFormRow, + LegacyFormSection, +} from "@metro/common/components"; +import { findByNameLazy } from "@metro/wrappers"; +import { registeredSections } from "@ui/settings"; + +import { CustomPageRenderer, wrapOnPress } from "./shared"; + +function SettingsSection() { + const navigation = NavigationNative.useNavigation(); + + return ( + <> + {Object.keys(registeredSections).map( + (sect) => + registeredSections[sect].length > 0 && ( + + {/** Is usePredicate here safe? */} + {registeredSections[sect] + .filter((r) => r.usePredicate?.() ?? true) + .map((row, i, arr) => ( + <> + } + trailing={ + + } + onPress={wrapOnPress( + row.onPress, + navigation, + row.render, + row.title(), + )} + /> + {i !== arr.length - 1 && } + + ))} + + ), + )} + + ); +} + +export function patchPanelUI(unpatches: (() => void | boolean)[]) { + try { + unpatches.push( + after("default", findByNameLazy("getScreens", false), (_a, screens) => ({ + ...screens, + VendettaCustomPage: { + title: "ShiggyCord", + render: () => , + }, + BUNNY_CUSTOM_PAGE: { + title: "ShiggyCord", + render: () => , + }, + SHIGGYCORD_CUSTOM_PAGE: { + title: "ShiggyCord", + render: () => , + }, + })), + ); + + const unpatch = after( + "default", + findByNameLazy("UserSettingsOverviewWrapper", false), + (_a, ret) => { + const UserSettingsOverview = findInReactTree( + ret.props.children, + (n) => n.type?.name === "UserSettingsOverview", + ); + + unpatches.push( + after( + "renderSupportAndAcknowledgements", + UserSettingsOverview.type.prototype, + (_args, { props: { children } }) => { + const index = children.findIndex( + (c: any) => c?.type?.name === "UploadLogsButton", + ); + if (index !== -1) children.splice(index, 1); + }, + ), + ); + + unpatches.push( + after("render", UserSettingsOverview.type.prototype, (_args, res) => { + const titles = [ + i18n.Messages.BILLING_SETTINGS, + i18n.Messages.PREMIUM_SETTINGS, + ]; + + const sections = + findInReactTree( + res.props.children, + (n) => n?.children?.[1]?.type === LegacyFormSection, + )?.children || res.props.children; + + if (sections) { + const index = sections.findIndex((c: any) => + titles.includes(c?.props.label), + ); + sections.splice(-~index || 4, 0, ); + } + }), + ); + }, + true, + ); + + unpatches.push(unpatch); + } catch {} +} diff --git a/ui/settings/patches/shared.tsx b/ui/settings/patches/shared.tsx new file mode 100644 index 0000000..e5ce858 --- /dev/null +++ b/ui/settings/patches/shared.tsx @@ -0,0 +1,44 @@ +import { NavigationNative } from "@metro/common"; +import { findByPropsLazy } from "@metro/wrappers"; +import { ErrorBoundary } from "@ui/components"; +import { RowConfig } from "@ui/settings"; + +const tabsNavigationRef = findByPropsLazy("getRootNavigationRef"); + +export const CustomPageRenderer = React.memo(() => { + const navigation = NavigationNative.useNavigation(); + const route = NavigationNative.useRoute(); + + const { render: PageComponent, ...args } = route.params; + + React.useEffect(() => void navigation.setOptions({ ...args }), []); + return ( + + + + ); +}); + +export function wrapOnPress( + onPress: (() => unknown) | undefined, + navigation?: any, + renderPromise?: RowConfig["render"], + screenOptions?: string | Record, + props?: any, +) { + return async () => { + if (onPress) return void onPress(); + + const Component = await renderPromise!!().then((m) => m.default); + + if (typeof screenOptions === "string") { + screenOptions = { title: screenOptions }; + } + + navigation ??= tabsNavigationRef.getRootNavigationRef(); + navigation.navigate("SHIGGYCORD_CUSTOM_PAGE", { + ...screenOptions, + render: () => , + }); + }; +} diff --git a/ui/settings/patches/tabs.tsx b/ui/settings/patches/tabs.tsx new file mode 100644 index 0000000..959f24f --- /dev/null +++ b/ui/settings/patches/tabs.tsx @@ -0,0 +1,141 @@ +import { after } from "@lib/api/patcher"; +import { TableRow } from "@metro/common/components"; +import { findByNameLazy, findByPropsLazy } from "@metro/wrappers"; +import { registeredSections } from "@ui/settings"; +import { Strings } from "@core/i18n"; + +import { CustomPageRenderer, wrapOnPress } from "./shared"; +import { findInReactTree } from "@lib/utils"; + +const settingConstants = findByPropsLazy("SETTING_RENDERER_CONFIG"); +const createListModule = findByPropsLazy("createList"); +const SettingsOverviewScreen = findByNameLazy("SettingsOverviewScreen", false); + +function useIsFirstRender() { + let firstRender = false; + React.useEffect(() => void (firstRender = true), []); + return firstRender; +} + +export function patchTabsUI(unpatches: (() => void | boolean)[]) { + const getRows = () => + Object.values(registeredSections) + .flatMap((sect) => + sect.map((row) => ({ + [row.key]: { + type: "pressable", + title: row.title, + icon: row.icon, + IconComponent: () => , + usePredicate: row.usePredicate, + useTrailing: row.useTrailing, + onPress: wrapOnPress(row.onPress, null, row.render, row.title()), + withArrow: true, + ...row.rawTabsConfig, + }, + })), + ) + .reduce((a, c) => Object.assign(a, c)); + + const origRendererConfig = settingConstants.SETTING_RENDERER_CONFIG; + let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG; + + Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", { + enumerable: true, + configurable: true, + get: () => ({ + ...rendererConfigValue, + VendettaCustomPage: { + type: "route", + title: () => "ShiggyCord", + screen: { + route: "VendettaCustomPage", + getComponent: () => CustomPageRenderer, + }, + }, + SHIGGYCORD_CUSTOM_PAGE: { + type: "route", + title: () => "ShiggyCord", + screen: { + route: "SHIGGYCORD_CUSTOM_PAGE", + getComponent: () => CustomPageRenderer, + }, + }, + BUNNY_CUSTOM_PAGE: { + type: "route", + title: () => "ShiggyCord", + screen: { + route: "BUNNY_CUSTOM_PAGE", + getComponent: () => CustomPageRenderer, + }, + }, + ...getRows(), + }), + set: (v) => (rendererConfigValue = v), + }); + + unpatches.push(() => { + Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", { + value: origRendererConfig, + writable: true, + get: undefined, + set: undefined, + }); + }); + + try { + unpatches.push( + after("createList", createListModule, function (args, ret) { + const [config] = args; + + if (config?.sections && Array.isArray(config.sections)) { + const sections = config.sections; + + const accountSectionIndex = sections.findIndex((i: any) => + i.settings?.includes("ACCOUNT"), + ); + + if (accountSectionIndex !== -1) { + // Credit to @palmdevs - https://discord.com/channels/1196075698301968455/1243605828783571024/1307940348378742816 + + let index = accountSectionIndex + 1; + + Object.keys(registeredSections).forEach((sect) => { + const alreadyExists = sections.some((s: any) => s.label === sect); + if (!alreadyExists) { + sections.splice(index++, 0, { + label: sect, + title: sect, + settings: registeredSections[sect].map((a) => a.key), + }); + } + }); + } + } + return ret; + }), + ); + } catch { + unpatches.push( + after("default", SettingsOverviewScreen, (_, ret) => { + if (useIsFirstRender()) return; // :shrug: + + const { sections } = findInReactTree( + ret, + (i) => i.props?.sections, + ).props; + // Credit to @palmdevs - https://discord.com/channels/1196075698301968455/1243605828783571024/1307940348378742816 + let index = + -~sections.findIndex((i: any) => i.settings.includes("ACCOUNT")) || 1; + + Object.keys(registeredSections).forEach((sect) => { + sections.splice(index++, 0, { + label: sect, + title: sect, + settings: registeredSections[sect].map((a) => a.key), + }); + }); + }), + ); + } +} diff --git a/ui/sheets.ts b/ui/sheets.ts new file mode 100644 index 0000000..e893f4b --- /dev/null +++ b/ui/sheets.ts @@ -0,0 +1,16 @@ +import { findByPropsLazy } from "@metro/wrappers"; + +const actionSheet = findByPropsLazy("openLazy", "hideActionSheet"); + +export function showSheet>( + key: string, + lazyImport: Promise<{ default: T; }> | T, + props?: React.ComponentProps +) { + if (!("then" in lazyImport)) lazyImport = Promise.resolve({ default: lazyImport }); + actionSheet.openLazy(lazyImport, key, props ?? {}); +} + +export function hideSheet(key: string) { + actionSheet.hideActionSheet(key); +} diff --git a/ui/styles.ts b/ui/styles.ts new file mode 100644 index 0000000..c840d75 --- /dev/null +++ b/ui/styles.ts @@ -0,0 +1,75 @@ +import { lazyDestructure, proxyLazy } from "@lib/utils/lazy"; +import { findByProps, findByPropsLazy } from "@metro/wrappers"; +import { isSemanticColor, resolveSemanticColor } from "@ui/color"; +import { TextStyles } from "@ui/types"; +import { ImageStyle, StyleSheet, TextStyle, ViewStyle } from "react-native"; + +type NamedStyles = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle }; + +const Styles = findByPropsLazy("createStyles"); + +export const { ThemeContext } = lazyDestructure(() => findByProps("ThemeContext"), { hint: "object" }); +export const { TextStyleSheet } = lazyDestructure(() => findByProps("TextStyleSheet")) as unknown as { + TextStyleSheet: Record; +}; + +/** + * Get themed styles based on the current theme + * @returns A hook that returns the themed stylesheet + * @example + * const useStyles = createStyles({ + * container: { + * flex: 1, + * backgroundColor: tokens.colors.BACKGROUND_PRIMARY, + * }, + * }); + * + * function MyComponent() { + * const styles = useStyles(); + * return ; + * } + */ +export function createStyles>(sheet: T | ((props: any) => T)): () => T { + return proxyLazy(() => Styles.createStyles(sheet)); +} + +/** + * Get themed styles based on the current theme for class components + * @example + * const getStyles = createStyles({ + * container: { + * flex: 1, + * backgroundColor: tokens.colors.BACKGROUND_PRIMARY, + * }, + * }); + * + * class MyComponent extends React.Component { + * static contextType = ThemeContext; + * render() { + * const styles = getStyles(this.context); + * return ; + * } + * } + */ +export function createLegacyClassComponentStyles>(sheet: T | ((props: any) => T)): (ctxt: typeof ThemeContext) => T { + return proxyLazy(() => Styles.createLegacyClassComponentStyles(sheet)); +} + +/** + * Reimplementation of Discord's createThemedStyleSheet, which was removed since 204201 + * Not exactly a 1:1 reimplementation, but sufficient to keep compatibility with existing plugins + * @deprecated Use createStyles or createLegacyClassComponentStyles instead +*/ +export function createThemedStyleSheet>(sheet: T) { + for (const key in sheet) { + // @ts-ignore + sheet[key] = new Proxy(StyleSheet.flatten(sheet[key]), { + get(target, prop, receiver) { + const res = Reflect.get(target, prop, receiver); + return isSemanticColor(res) ? resolveSemanticColor(res) : res; + } + }); + } + + return sheet; +} diff --git a/ui/toasts.ts b/ui/toasts.ts new file mode 100644 index 0000000..e0c008d --- /dev/null +++ b/ui/toasts.ts @@ -0,0 +1,20 @@ +import { Strings } from "@core/i18n"; +import { findAssetId } from "@lib/api/assets"; +import { lazyDestructure } from "@lib/utils/lazy"; +import { toasts } from "@metro/common"; +import { findByProps } from "@metro/wrappers"; + +const { uuid4 } = lazyDestructure(() => findByProps("uuid4")); + +export const showToast = (content: string, asset?: number) => toasts.open({ + // ? In build 182205/44707, Discord changed their toasts, source is no longer used, rather icon, and a key is needed. + // TODO: We could probably have the developer specify a key themselves, but this works to fix toasts + key: `vd-toast-${uuid4()}`, + content: content, + source: asset, + icon: asset, +}); + +showToast.showCopyToClipboard = (message = Strings.COPIED_TO_CLIPBOARD) => { + showToast(message, findAssetId("toast_copy_link")); +}; diff --git a/ui/types.ts b/ui/types.ts new file mode 100644 index 0000000..f3e508f --- /dev/null +++ b/ui/types.ts @@ -0,0 +1,6 @@ +import { LiteralUnion } from "type-fest"; + +export type TextStyles = LiteralUnion<"heading-sm/normal" | "heading-sm/medium" | "heading-sm/semibold" | "heading-sm/bold" | "heading-sm/extrabold" | "heading-md/normal" | "heading-md/medium" | "heading-md/semibold" | "heading-md/bold" | "heading-md/extrabold" | "heading-lg/normal" | "heading-lg/medium" | "heading-lg/semibold" | "heading-lg/bold" | "heading-lg/extrabold" | "heading-xl/normal" | "heading-xl/medium" | "heading-xl/semibold" | "heading-xl/bold" | "heading-xl/extrabold" | "heading-xxl/normal" | "heading-xxl/medium" | "heading-xxl/semibold" | "heading-xxl/bold" | "heading-xxl/extrabold" | "eyebrow" | "heading-deprecated-12/normal" | "heading-deprecated-12/medium" | "heading-deprecated-12/semibold" | "heading-deprecated-12/bold" | "heading-deprecated-12/extrabold" | "redesign/heading-18/bold" | "text-xxs/normal" | "text-xxs/medium" | "text-xxs/semibold" | "text-xxs/bold" | "text-xs/normal" | "text-xs/medium" | "text-xs/semibold" | "text-xs/bold" | "text-sm/normal" | "text-sm/medium" | "text-sm/semibold" | "text-sm/bold" | "text-md/normal" | "text-md/medium" | "text-md/semibold" | "text-md/bold" | "text-lg/normal" | "text-lg/medium" | "text-lg/semibold" | "text-lg/bold" | "redesign/message-preview/normal" | "redesign/message-preview/medium" | "redesign/message-preview/semibold" | "redesign/message-preview/bold" | "redesign/channel-title/normal" | "redesign/channel-title/medium" | "redesign/channel-title/semibold" | "redesign/channel-title/bold" | "display-sm" | "display-md" | "display-lg", string>; +export type ThemeColors = LiteralUnion<"action-sheet-gradient-bg" | "activity-card-background" | "activity-card-icon-overlay" | "alert-bg" | "android-navigation-bar-background" | "android-navigation-scrim-background" | "android-ripple" | "autocomplete-bg" | "background-accent" | "background-floating" | "background-mentioned" | "background-mentioned-hover" | "background-message-automod" | "background-message-automod-hover" | "background-message-highlight" | "background-message-highlight-hover" | "background-message-hover" | "background-mobile-primary" | "background-mobile-secondary" | "background-modifier-accent" | "background-modifier-accent-2" | "background-modifier-active" | "background-modifier-hover" | "background-modifier-selected" | "background-nested-floating" | "background-primary" | "background-secondary" | "background-secondary-alt" | "background-tertiary" | "badge-brand-bg" | "badge-brand-text" | "bg-backdrop" | "bg-backdrop-no-opacity" | "bg-base-primary" | "bg-base-secondary" | "bg-base-tertiary" | "bg-brand" | "bg-mod-faint" | "bg-mod-strong" | "bg-mod-subtle" | "bg-surface-overlay" | "bg-surface-overlay-tmp" | "bg-surface-raised" | "black" | "blur-fallback" | "blur-fallback-pressed" | "border-faint" | "border-strong" | "border-subtle" | "bug-reporter-modal-submitting-background" | "button-creator-revenue-background" | "button-danger-background" | "button-danger-background-active" | "button-danger-background-disabled" | "button-danger-background-hover" | "button-outline-brand-background" | "button-outline-brand-background-active" | "button-outline-brand-background-hover" | "button-outline-brand-border" | "button-outline-brand-border-active" | "button-outline-brand-border-hover" | "button-outline-brand-text" | "button-outline-brand-text-active" | "button-outline-brand-text-hover" | "button-outline-danger-background" | "button-outline-danger-background-active" | "button-outline-danger-background-hover" | "button-outline-danger-border" | "button-outline-danger-border-active" | "button-outline-danger-border-hover" | "button-outline-danger-text" | "button-outline-danger-text-active" | "button-outline-danger-text-hover" | "button-outline-positive-background" | "button-outline-positive-background-active" | "button-outline-positive-background-hover" | "button-outline-positive-border" | "button-outline-positive-border-active" | "button-outline-positive-border-hover" | "button-outline-positive-text" | "button-outline-positive-text-active" | "button-outline-positive-text-hover" | "button-outline-primary-background" | "button-outline-primary-background-active" | "button-outline-primary-background-hover" | "button-outline-primary-border" | "button-outline-primary-border-active" | "button-outline-primary-border-hover" | "button-outline-primary-text" | "button-outline-primary-text-active" | "button-outline-primary-text-hover" | "button-positive-background" | "button-positive-background-active" | "button-positive-background-disabled" | "button-positive-background-hover" | "button-secondary-background" | "button-secondary-background-active" | "button-secondary-background-disabled" | "button-secondary-background-hover" | "card-gradient-bg" | "card-gradient-pressed-bg" | "card-primary-bg" | "card-primary-pressed-bg" | "card-secondary-bg" | "card-secondary-pressed-bg" | "channel-icon" | "channel-text-area-placeholder" | "channels-default" | "channeltextarea-background" | "chat-background" | "chat-banner-bg" | "chat-border" | "chat-input-container-background" | "chat-swipe-to-reply-background" | "chat-swipe-to-reply-gradient-background" | "coachmark-bg" | "content-inventory-media-seekbar-container" | "content-inventory-overlay-text-primary" | "content-inventory-overlay-text-secondary" | "content-inventory-overlay-ui-mod" | "content-inventory-overlay-ui-mod-bg" | "context-menu-backdrop-background" | "control-brand-foreground" | "control-brand-foreground-new" | "creator-revenue-icon-gradient-end" | "creator-revenue-icon-gradient-start" | "creator-revenue-info-box-background" | "creator-revenue-info-box-border" | "creator-revenue-locked-channel-icon" | "creator-revenue-progress-bar" | "deprecated-card-bg" | "deprecated-card-editable-bg" | "deprecated-quickswitcher-input-background" | "deprecated-quickswitcher-input-placeholder" | "deprecated-store-bg" | "deprecated-text-input-bg" | "deprecated-text-input-border" | "deprecated-text-input-border-disabled" | "deprecated-text-input-border-hover" | "deprecated-text-input-prefix" | "display-banner-overflow-background" | "divider-strong" | "divider-subtle" | "embed-background" | "embed-background-alternate" | "embed-title" | "expression-picker-bg" | "focus-primary" | "forum-post-extra-media-count-container-background" | "forum-post-tag-background" | "guild-icon-inactive-bg" | "guild-icon-inactive-nested-bg" | "guild-notifications-bottom-sheet-pill-background" | "halo-positive" | "header-muted" | "header-primary" | "header-secondary" | "home-background" | "home-card-resting-border" | "icon-muted" | "icon-primary" | "icon-secondary" | "icon-transparent" | "info-box-background" | "info-danger-background" | "info-danger-foreground" | "info-danger-text" | "info-help-background" | "info-help-foreground" | "info-help-text" | "info-positive-background" | "info-positive-foreground" | "info-positive-text" | "info-warning-background" | "info-warning-foreground" | "info-warning-text" | "input-background" | "input-focused-border" | "input-placeholder-text" | "interactive-active" | "interactive-hover" | "interactive-muted" | "interactive-normal" | "legacy-android-blur-overlay-default" | "legacy-android-blur-overlay-ultra-thin" | "legacy-blur-fallback-default" | "legacy-blur-fallback-ultra-thin" | "live-stage-tile-border" | "logo-primary" | "mention-background" | "mention-foreground" | "menu-item-danger-active-bg" | "menu-item-danger-hover-bg" | "menu-item-default-active-bg" | "menu-item-default-hover-bg" | "modal-background" | "modal-footer-background" | "navigator-header-tint" | "panel-bg" | "polls-normal-fill-hover" | "polls-normal-image-background" | "polls-victor-fill" | "polls-voted-fill" | "premium-nitro-pink-text" | "profile-gradient-card-background" | "profile-gradient-message-input-border" | "profile-gradient-note-background" | "profile-gradient-overlay" | "profile-gradient-overlay-synced-with-user-theme" | "profile-gradient-profile-body-background-hover" | "profile-gradient-role-pill-background" | "profile-gradient-role-pill-border" | "profile-gradient-section-box" | "redesign-activity-card-background" | "redesign-activity-card-background-pressed" | "redesign-activity-card-badge-icon" | "redesign-activity-card-border" | "redesign-activity-card-overflow-background" | "redesign-button-active-background" | "redesign-button-active-pressed-background" | "redesign-button-active-text" | "redesign-button-danger-background" | "redesign-button-danger-pressed-background" | "redesign-button-danger-text" | "redesign-button-destructive-background" | "redesign-button-destructive-pressed-background" | "redesign-button-destructive-text" | "redesign-button-overlay-alpha-background" | "redesign-button-overlay-alpha-pressed-background" | "redesign-button-overlay-alpha-text" | "redesign-button-overlay-background" | "redesign-button-overlay-pressed-background" | "redesign-button-overlay-text" | "redesign-button-positive-background" | "redesign-button-positive-pressed-background" | "redesign-button-positive-text" | "redesign-button-primary-alt-background" | "redesign-button-primary-alt-border" | "redesign-button-primary-alt-on-blurple-background" | "redesign-button-primary-alt-on-blurple-border" | "redesign-button-primary-alt-on-blurple-pressed-background" | "redesign-button-primary-alt-on-blurple-pressed-border" | "redesign-button-primary-alt-on-blurple-text" | "redesign-button-primary-alt-pressed-background" | "redesign-button-primary-alt-pressed-border" | "redesign-button-primary-alt-pressed-text" | "redesign-button-primary-alt-text" | "redesign-button-primary-background" | "redesign-button-primary-on-blurple-pressed-text" | "redesign-button-primary-overlay-background" | "redesign-button-primary-overlay-pressed-background" | "redesign-button-primary-overlay-text" | "redesign-button-primary-pressed-background" | "redesign-button-primary-text" | "redesign-button-secondary-background" | "redesign-button-secondary-border" | "redesign-button-secondary-overlay-background" | "redesign-button-secondary-overlay-pressed-background" | "redesign-button-secondary-overlay-text" | "redesign-button-secondary-pressed-background" | "redesign-button-secondary-pressed-border" | "redesign-button-secondary-text" | "redesign-button-selected-background" | "redesign-button-selected-pressed-background" | "redesign-button-selected-text" | "redesign-button-tertiary-background" | "redesign-button-tertiary-pressed-background" | "redesign-button-tertiary-pressed-text" | "redesign-button-tertiary-text" | "redesign-channel-category-name-text" | "redesign-channel-message-preview-text" | "redesign-channel-name-muted-text" | "redesign-channel-name-text" | "redesign-chat-input-background" | "redesign-image-button-pressed-background" | "redesign-input-control-active-bg" | "redesign-input-control-selected" | "redesign-only-background-active" | "redesign-only-background-default" | "redesign-only-background-overlay" | "redesign-only-background-raised" | "redesign-only-background-sunken" | "scrollbar-auto-scrollbar-color-thumb" | "scrollbar-auto-scrollbar-color-track" | "scrollbar-auto-thumb" | "scrollbar-auto-track" | "scrollbar-thin-thumb" | "scrollbar-thin-track" | "spoiler-hidden-background" | "spoiler-revealed-background" | "stage-card-pill-bg" | "status-danger" | "status-danger-background" | "status-danger-text" | "status-dnd" | "status-idle" | "status-offline" | "status-online" | "status-positive" | "status-positive-background" | "status-positive-text" | "status-speaking" | "status-warning" | "status-warning-background" | "status-warning-text" | "text-brand" | "text-danger" | "text-link" | "text-link-low-saturation" | "text-low-contrast" | "text-message-preview-low-sat" | "text-muted" | "text-muted-on-default" | "text-normal" | "text-positive" | "text-primary" | "text-secondary" | "text-warning" | "textbox-markdown-syntax" | "theme-locked-blur-fallback" | "thread-channel-spine" | "toast-bg" | "typing-indicator-bg" | "user-profile-header-overflow-background" | "voice-video-video-tile-background" | "voice-video-video-tile-blur-fallback" | "white" | "you-bar-bg", string>; + +// this is so fun to read \ No newline at end of file diff --git a/utils/constants.ts b/utils/constants.ts new file mode 100644 index 0000000..d1587b5 --- /dev/null +++ b/utils/constants.ts @@ -0,0 +1,15 @@ +export const DISCORD_SERVER = "https://discord.gg/nQykFF9Ud6"; +export const GITHUB = "https://github.com/kmmiio99o/ShiggyCord"; +export const HTTP_REGEX = + /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/; +export const HTTP_REGEX_MULTI = + /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&\/=]*)/g; + +export const NEXPID_PLUGINS_REPO_URL = "https://revenge.nexpid.xyz"; +export const OFFICIAL_PLUGINS_REPO_URL = + "https://raw.githubusercontent.com/Purple-EyeZ/Plugins-List/refs/heads/main/src/plugins-data.json"; + +export const VD_PROXY_PREFIX = "https://vd-plugins.github.io/proxy"; +export const VD_DISCORD_SERVER_ID = "1427396621905432699"; +export const VD_PLUGINS_CHANNEL_ID = "1091880384561684561"; +export const VD_THEMES_CHANNEL_ID = "1091880434939482202"; diff --git a/utils/cyrb64.ts b/utils/cyrb64.ts new file mode 100644 index 0000000..23e3307 --- /dev/null +++ b/utils/cyrb64.ts @@ -0,0 +1,29 @@ +// cyrb53 (c) 2018 bryc (github.com/bryc). License: Public domain. Attribution appreciated. +// A fast and simple 64-bit (or 53-bit) string hash function with decent collision resistance. +// Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity. +// See https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript/52171480#52171480 +// https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js +export default function cyrb64(str: string, seed = 0) { + let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + // For a single 53-bit numeric return value we could return + // 4294967296 * (2097151 & h2) + (h1 >>> 0); + // but we instead return the full 64-bit value: + return [h2 >>> 0, h1 >>> 0]; +} + +// An improved, *insecure* 64-bit hash that's short, fast, and has no dependencies. +// Output is always 14 characters. +export function cyrb64Hash(str: string, seed = 0) { + const [h2, h1] = cyrb64(str, seed); + return h2.toString(36).padStart(7, "0") + h1.toString(36).padStart(7, "0"); +} + diff --git a/utils/findInReactTree.ts b/utils/findInReactTree.ts new file mode 100644 index 0000000..29a2b4a --- /dev/null +++ b/utils/findInReactTree.ts @@ -0,0 +1,8 @@ +import { findInTree } from "@lib/utils"; +import { SearchFilter } from "@lib/utils/findInTree"; + +export default function findInReactTree(tree: { [key: string]: any; }, filter: SearchFilter): any { + return findInTree(tree, filter, { + walkable: ["props", "children", "child", "sibling"], + }); +} diff --git a/utils/findInTree.ts b/utils/findInTree.ts new file mode 100644 index 0000000..5dae4a8 --- /dev/null +++ b/utils/findInTree.ts @@ -0,0 +1,53 @@ +// This has been completely reimplemented at this point, but the disclaimer at the end of disclaimers still counts. +// https://github.com/Cordwood/Cordwood/blob/91c0b971bbf05e112927df75415df99fa105e1e7/src/lib/utils/findInTree.ts + +export type SearchTree = Record; +export type SearchFilter = (tree: SearchTree) => boolean; +export interface FindInTreeOptions { + walkable?: string[]; + ignore?: string[]; + maxDepth?: number; +} + +function treeSearch(tree: SearchTree, filter: SearchFilter, opts: Required, depth: number): any { + if (depth > opts.maxDepth) return; + if (!tree) return; + + try { + if (filter(tree)) return tree; + } catch { } + + if (Array.isArray(tree)) { + for (const item of tree) { + if (typeof item !== "object" || item === null) continue; + + try { + const found = treeSearch(item, filter, opts, depth + 1); + if (found) return found; + } catch { } + } + } else if (typeof tree === "object") { + for (const key of Object.keys(tree)) { + if (typeof tree[key] !== "object" || tree[key] === null) continue; + if (opts.walkable.length && !opts.walkable.includes(key)) continue; + if (opts.ignore.includes(key)) continue; + + try { + const found = treeSearch(tree[key], filter, opts, depth + 1); + if (found) return found; + } catch { } + } + } +} + +export default function findInTree( + tree: SearchTree, + filter: SearchFilter, + { + walkable = [], + ignore = [], + maxDepth = 100 + }: FindInTreeOptions = {}, +): any | undefined { + return treeSearch(tree, filter, { walkable, ignore, maxDepth }, 0); +} diff --git a/utils/hookDefineProperty.ts b/utils/hookDefineProperty.ts new file mode 100644 index 0000000..1d2f156 --- /dev/null +++ b/utils/hookDefineProperty.ts @@ -0,0 +1,30 @@ +import { LiteralUnion } from "type-fest"; + +type KeyOfOrAny = P extends keyof T ? T[P] : any; + +export default function hookDefineProperty< + T extends object, + P extends LiteralUnion +>(target: T, property: LiteralUnion, cb: (val: KeyOfOrAny) => KeyOfOrAny) { + const targetAsAny = target as any; + + if (property in target) { + return void cb(targetAsAny[property]); + } + + let value: any; + + Object.defineProperty(targetAsAny, property, { + get: () => value, + set(v) { + value = cb(v) ?? v; + }, + configurable: true, + enumerable: false + }); + + return () => { + delete targetAsAny[property]; + targetAsAny[property] = value; + }; +} diff --git a/utils/index.ts b/utils/index.ts new file mode 100644 index 0000000..75f6d25 --- /dev/null +++ b/utils/index.ts @@ -0,0 +1,20 @@ +import * as constants from "./constants"; +import cyrb64 from "./cyrb64"; +import findInReactTree from "./findInReactTree"; +import findInTree from "./findInTree"; +import hookDefineProperty from "./hookDefineProperty"; +import invariant from "./invariant"; +import * as lazy from "./lazy"; +import * as logger from "./logger"; +import safeFetch from "./safeFetch"; + +export { + constants, + cyrb64, + findInReactTree, + findInTree, + hookDefineProperty, + invariant, + lazy, + logger, + safeFetch }; diff --git a/utils/invariant.ts b/utils/invariant.ts new file mode 100644 index 0000000..7dc4ea6 --- /dev/null +++ b/utils/invariant.ts @@ -0,0 +1,23 @@ +/** + * `invariant` is used to [assert](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions) that the `condition` is [truthy](https://github.com/getify/You-Dont-Know-JS/blob/bdbe570600d4e1107d0b131787903ca1c9ec8140/up%20%26%20going/ch2.md#truthy--falsy). + * 💥 `invariant` will `throw` an `Error` if the `condition` is [falsey](https://github.com/getify/You-Dont-Know-JS/blob/bdbe570600d4e1107d0b131787903ca1c9ec8140/up%20%26%20going/ch2.md#truthy--falsy) + * 🤏 `message`s are not displayed in production environments to help keep bundles small + * + * ```ts + * const value: Person | null = { name: "Alex" }; + * invariant(value, "Expected value to be a person"); + * // type of `value`` has been narrowed to `Person` + * ``` + */ +export default function invariant( + condition: any, + message?: string | (() => string), +): asserts condition { + if (condition) return; + + const resolvedMessage: string | undefined = typeof message === "function" ? message() : message; + const prefix = "[Invariant Violation]"; + const value = resolvedMessage ? `${prefix}: ${resolvedMessage}` : prefix; + + throw new Error(value); +} diff --git a/utils/isValidHttpUrl.ts b/utils/isValidHttpUrl.ts new file mode 100644 index 0000000..067df3d --- /dev/null +++ b/utils/isValidHttpUrl.ts @@ -0,0 +1,12 @@ +// https://stackoverflow.com/a/43467144/15031462 +export default function isValidHttpUrl(input: string) { + let url: URL; + + try { + url = new URL(input); + } catch { + return false; + } + + return url.protocol === "http:" || url.protocol === "https:"; +} diff --git a/utils/lazy.ts b/utils/lazy.ts new file mode 100644 index 0000000..3ad8a62 --- /dev/null +++ b/utils/lazy.ts @@ -0,0 +1,166 @@ +/* eslint-disable func-call-spacing */ + +type ExemptedEntries = Record; + +interface LazyOptions { + hint?: "function" | "object"; + exemptedEntries?: E; +} + +interface ContextHolder { + options: LazyOptions; + factory: any; +} + +const unconfigurable = new Set(["arguments", "caller", "prototype"]); +const isUnconfigurable = (key: PropertyKey) => + typeof key === "string" && unconfigurable.has(key); + +const factories = new WeakMap any>(); +const proxyContextHolder = new WeakMap(); + +const lazyHandler: ProxyHandler = { + ...Object.fromEntries( + Object.getOwnPropertyNames(Reflect).map((fnName) => { + return [ + fnName, + (target: any, ...args: any[]) => { + const contextHolder = proxyContextHolder.get(target); + const resolved = contextHolder?.factory(); + if (!resolved) + throw new Error( + `Trying to Reflect.${fnName} of ${typeof resolved}`, + ); + // @ts-expect-error + return Reflect[fnName](resolved, ...args); + }, + ]; + }), + ), + has(target, p) { + const contextHolder = proxyContextHolder.get(target); + + if (contextHolder?.options) { + const { exemptedEntries: isolatedEntries } = contextHolder.options; + if (isolatedEntries && p in isolatedEntries) return true; + } + + const resolved = contextHolder?.factory(); + if (!resolved) + throw new Error(`Trying to Reflect.has of ${typeof resolved}`); + return Reflect.has(resolved, p); + }, + get(target, p, receiver) { + if (__DEV__ && p === "__IS_BUNNY_LAZY_PROXY__") return true; + + const contextHolder = proxyContextHolder.get(target); + + if (contextHolder?.options) { + const { exemptedEntries: isolatedEntries } = contextHolder.options; + if (isolatedEntries?.[p]) return isolatedEntries[p]; + } + + const resolved = contextHolder?.factory(); + if (!resolved) + throw new Error(`Trying to Reflect.get of ${typeof resolved}`); + return Reflect.get(resolved, p, receiver); + }, + ownKeys: (target) => { + const contextHolder = proxyContextHolder.get(target); + const resolved = contextHolder?.factory(); + if (!resolved) + throw new Error(`Trying to Reflect.ownKeys of ${typeof resolved}`); + + const cacheKeys = Reflect.ownKeys(resolved); + unconfigurable.forEach( + (key) => !cacheKeys.includes(key) && cacheKeys.push(key), + ); + return cacheKeys; + }, + getOwnPropertyDescriptor: (target, p) => { + if (isUnconfigurable(p)) return Reflect.getOwnPropertyDescriptor(target, p); + + const contextHolder = proxyContextHolder.get(target); + const resolved = contextHolder?.factory(); + if (!resolved) + throw new Error( + `Trying to getOwnPropertyDescriptor of ${typeof resolved}`, + ); + + const descriptor = Reflect.getOwnPropertyDescriptor(resolved, p); + if (descriptor) Object.defineProperty(target, p, descriptor); + return descriptor; + }, +}; + +/** + * Lazy proxy that will only call the factory function when needed (when a property is accessed) + * @param factory Factory function to create the object + * @param asFunction Mock the proxy as a function + * @returns A proxy that will call the factory function only when needed + * @example const ChannelStore = proxyLazy(() => findByProps("getChannelId")); + */ +export function proxyLazy( + factory: () => T, + opts: LazyOptions = {}, +): T { + let cache: T; + + const dummy = opts.hint !== "object" ? function () {} : {}; + const proxyFactory = () => (cache ??= factory()); + + const proxy = new Proxy(dummy, lazyHandler) as T & I; + factories.set(proxy, proxyFactory); + // Use the proxyFactory in the context holder so the lazy handler calls the cached + // resolver instead of the raw factory. This avoids attempting to Reflect.* on + // an unresolved/undefined value when the original factory hasn't produced the object yet. + proxyContextHolder.set(dummy, { + factory: proxyFactory, + options: opts, + }); + + return proxy; +} + +/** + * Lazily destructure an object with all the properties being lazified. This assumes all the properties are either an object or a function + * @param factory Factory function which resolves to the object (and caches it) + * @param asFunction Mock the proxy as a function + * @example + * + * const { uuid4 } = lazyDestructure(() => findByProps("uuid4")) + * uuid4; // <- is a lazy proxy! + */ +export function lazyDestructure< + T extends Record, + I extends ExemptedEntries, +>(factory: () => T, opts: LazyOptions = {}): T { + const proxiedObject = proxyLazy(factory); + + return new Proxy( + {}, + { + get(_, property) { + if (property === Symbol.iterator) { + return function* () { + yield proxiedObject; + yield new Proxy( + {}, + { + get: (_, p) => proxyLazy(() => proxiedObject[p], opts), + }, + ); + throw new Error( + "This is not a real iterator, this is likely used incorrectly", + ); + }; + } + return proxyLazy(() => proxiedObject[property], opts); + }, + }, + ) as T; +} + +export function getProxyFactory(obj: T): (() => T) | void { + return factories.get(obj) as (() => T) | void; +} diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..67a161c --- /dev/null +++ b/utils/logger.ts @@ -0,0 +1,15 @@ +import { findByNameLazy } from "@metro/wrappers"; + +type LoggerFunction = (...messages: any[]) => void; +export interface Logger { + log: LoggerFunction; + info: LoggerFunction; + warn: LoggerFunction; + error: LoggerFunction; + time: LoggerFunction; + trace: LoggerFunction; + verbose: LoggerFunction; +} + +export const LoggerClass = findByNameLazy("Logger"); +export const logger: Logger = new LoggerClass("ShiggyCord"); diff --git a/utils/safeFetch.ts b/utils/safeFetch.ts new file mode 100644 index 0000000..45be8ad --- /dev/null +++ b/utils/safeFetch.ts @@ -0,0 +1,16 @@ +// A really basic fetch wrapper which throws on non-ok response codes +export default async function safeFetch(input: RequestInfo | URL, options?: RequestInit, timeout = 10000) { + const req = await fetch(input, { + signal: timeoutSignal(timeout), + ...options + }); + + if (!req.ok) throw new Error(`Request returned non-ok: ${req.status} ${req.statusText}`); + return req; +} + +function timeoutSignal(ms: number): AbortSignal { + const controller = new AbortController(); + setTimeout(() => controller.abort(`Timed out after ${ms}ms`), ms); + return controller.signal; +} diff --git a/utils/types.ts b/utils/types.ts new file mode 100644 index 0000000..649532b --- /dev/null +++ b/utils/types.ts @@ -0,0 +1 @@ +export type Nullish = null | undefined; diff --git a/wrappers.ts b/wrappers.ts new file mode 100644 index 0000000..c3a1914 --- /dev/null +++ b/wrappers.ts @@ -0,0 +1,25 @@ +import { byDisplayName, byFilePath, byName, byProps, byStoreName, byTypeName } from "./filters"; +import { findAllExports, findExports } from "./finders"; +import { createLazyModule } from "./lazy"; + +export const findByProps = (...props: string[]) => findExports(byProps(...props)); +export const findByPropsLazy = (...props: string[]) => createLazyModule(byProps(...props)); +export const findByPropsAll = (...props: string[]) => findAllExports(byProps(...props)); + +export const findByName = (name: string, expDefault = true) => findExports(expDefault ? byName(name) : byName.byRaw(name)); +export const findByNameLazy = (name: string, expDefault = true) => createLazyModule(expDefault ? byName(name) : byName.byRaw(name)); +export const findByNameAll = (name: string, expDefault = true) => findAllExports(expDefault ? byName(name) : byName.byRaw(name)); + +export const findByDisplayName = (name: string, expDefault = true) => findExports(expDefault ? byDisplayName(name) : byDisplayName.byRaw(name)); +export const findByDisplayNameLazy = (name: string, expDefault = true) => createLazyModule(expDefault ? byDisplayName(name) : byDisplayName.byRaw(name)); +export const findByDisplayNameAll = (name: string, expDefault = true) => findAllExports(expDefault ? byDisplayName(name) : byDisplayName.byRaw(name)); + +export const findByTypeName = (name: string, expDefault = true) => findExports(expDefault ? byTypeName(name) : byTypeName.byRaw(name)); +export const findByTypeNameLazy = (name: string, expDefault = true) => createLazyModule(expDefault ? byTypeName(name) : byTypeName.byRaw(name)); +export const findByTypeNameAll = (name: string, expDefault = true) => findAllExports(expDefault ? byTypeName(name) : byTypeName.byRaw(name)); + +export const findByStoreName = (name: string) => findExports(byStoreName(name)); +export const findByStoreNameLazy = (name: string) => createLazyModule(byStoreName(name)); + +export const findByFilePath = (path: string, expDefault = false) => findExports(byFilePath(path, expDefault)); +export const findByFilePathLazy = (path: string, expDefault = false) => createLazyModule(byFilePath(path, expDefault));