Add files via upload

This commit is contained in:
OTKUSteyler 2025-12-10 17:20:21 +01:00
parent 55233fda91
commit 0ac9ee6dd3
93 changed files with 6786 additions and 1 deletions

117
addons/fonts/index.ts Normal file
View file

@ -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<string, string>;
export type FontDefinition = {
spec: 1;
name: string;
description?: string;
main: FontMap;
source?: string
}
type FontStorage = Record<string, FontDefinition> & { __selected?: string; };
export const fonts = wrapSync(createStorage<FontStorage>(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)
)
);
}

59
addons/plugins/api.ts Normal file
View file

@ -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<F extends DisposableFn>(unpatches: (() => void)[], f: F): F {
const dummy = ((...props: Parameters<F>) => {
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<DisposableFn>;
// 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: <T extends object = any>() => createStorage<T>(`plugins/storage/${id}.json`),
manifest: registeredPlugins.get(id),
logger
}
} as unknown as BunnyPluginObject;
return {
object,
disposers,
};
}

823
addons/plugins/index.ts Normal file
View file

@ -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<string, t.PluginInstanceInternal>();
export const registeredPlugins = new Map<string, t.BunnyPluginManifest>();
export const pluginInstances = new Map<string, t.PluginInstanceInternal>();
export const apiObjects = new Map<
string,
ReturnType<typeof createBunnyPluginApi>
>();
/**
* 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<t.PluginRepoStorage>(
"plugins/repositories.json",
);
export const pluginSettings = createStorage<t.PluginSettingsStorage>(
"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<T>(
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<any> | 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<t.BunnyPluginManifest>(
`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<unknown>[];
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<void>((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);
}
}
}

55
addons/plugins/types.ts Normal file
View file

@ -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<string, {
version: string;
// For plugin developing convenience, plugins with this on will always get fetched
alwaysFetch?: boolean;
}> & {
$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<T extends object>(): ReturnType<typeof createStorage<T>>;
}
export type BunnyPluginObject = typeof window.bunny & {
plugin: BunnyPluginProperty;
};

View file

@ -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());
}

View file

@ -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?.(() => {});
}

View file

@ -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<string, string>) {
if (!rawColors) return;
// these are native Discord Android keys
const alphaMap: Record<string, [string, number]> = {
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();
}

View file

@ -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 <ImageBackground
style={{ flex: 1, height: "100%" }}
source={{ uri: _colorRef.current.background?.url }}
blurRadius={_colorRef.current.background?.blur}
>
{children}
</ImageBackground>;
}
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 <ThemeBackground>{ret}</ThemeBackground>;
})
];
return () => patches.forEach(x => x());
}

View file

@ -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<string, string> = {
"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]];
}

View file

@ -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());
}

View file

@ -0,0 +1,18 @@
import { createStorage } from "@lib/api/storage";
interface BunnyColorPreferencesStorage {
selected: string | null;
type?: "dark" | "light" | null;
customBackground: "hidden" | null;
per?: Record<string, { autoUpdate?: string; } | undefined>;
}
export const colorsPref = createStorage<BunnyColorPreferencesStorage>(
"themes/colors/preferences.json",
{
dflt: {
selected: null,
customBackground: null
}
}
);

View file

@ -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;
}
}

View file

@ -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<string, string | SemanticReference>;
raw?: Record<string, string>;
background?: BackgroundDefinition;
}
}
export interface VendettaThemeManifest {
spec: 2;
name: string;
description?: string;
authors?: Author[];
semanticColors?: Record<string, (string | false)[]>;
rawColors?: Record<string, string>;
background?: {
url: string;
blur?: number;
alpha?: number;
};
}
/** @internal */
export interface InternalColorDefinition {
spec: 1 | 2 | 3;
reference: "darker" | "light";
semantic: Record<string, {
value: string;
opacity: number;
}>;
raw: Record<string, string>;
background?: BackgroundDefinition;
}
export type ColorManifest = BunnyColorManifest | VendettaThemeManifest;

View file

@ -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<string, string>;
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");
}
}

168
addons/themes/index.ts Normal file
View file

@ -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<Record<string, VdThemeInfo>>(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);
}
}

17
addons/types.ts Normal file
View file

@ -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;
};
}

79
api/assets/index.ts Normal file
View file

@ -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<string, Asset>;
export function* iterateAssets() {
const { flagsIndex } = getMetroCache();
const yielded = new Set<number>();
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<Asset>;
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;
}

25
api/assets/patches.ts Normal file
View file

@ -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;
}

84
api/commands/index.ts Normal file
View file

@ -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));
}

76
api/commands/types.ts Normal file
View file

@ -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<CommandResult> | Promise<void>;
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;
}

201
api/debug.ts Normal file
View file

@ -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);
}
}
}

49
api/flux/index.ts Normal file
View file

@ -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<string, any> & { 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);
};
}

9
api/index.ts Normal file
View file

@ -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";

76
api/native/fs.ts Normal file
View file

@ -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<void> {
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<string> {
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");
}

1
api/native/index.ts Normal file
View file

@ -0,0 +1 @@
export * as fs from "./fs";

254
api/native/loader.ts Normal file
View file

@ -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<string, any> };
if (isRa1nLoader() == true) {
loader = {
name: rainLoaderIdentity.loaderName,
features: {} as Record<string, any>,
};
} else {
loader = {
name: pyonLoaderIdentity.loaderName,
features: {} as Record<string, any>,
};
}
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");
}

View file

@ -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<unknown>;
}
type ModuleProps<T extends LoaderModule> = {
name: string;
argumentProcessors?: Partial<{ [key in keyof T]: (args: any[]) => any[] }>;
};
export function getModule<T extends LoaderModule>({ name, argumentProcessors: processArgs }: ModuleProps<T>) {
const module = loaderPayload.loader.modules[name];
if (!module) {
throw new Error(`Module ${name} is not registered`);
}
const baseObject: Record<string, any> = {
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);
}

View file

@ -0,0 +1,16 @@
import { processColor } from "react-native";
import { getModule } from "./loader";
import type { LoaderModule } from "./loader";
interface BubbleModule extends LoaderModule {
hookBubbles(): Promise<void>;
unhookBubbles(): Promise<void>;
configure(avatarRadius?: number, bubbleRadius?: number, bubbleColor?: string): Promise<void>;
}
export default getModule<BubbleModule>({
name: "BubbleModule",
argumentProcessors: {
configure: args => [args[0], args[1], Number(processColor(args[2]))],
},
});

View file

@ -0,0 +1,38 @@
import { RNModules } from "./types";
const nmp = window.nativeModuleProxy;
function getNativeModule<T = any>(...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<RNModules.MMKVManager>(
"NativeCacheModule", "MMKVManager"
)!;
export const NativeFileModule = getNativeModule<RNModules.FileManager>(
"NativeFileModule", "RTNFileManager", "DCDFileManager"
)!;
export const NativeClientInfoModule = getNativeModule<RNModules.ClientInfoModule>(
"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"
)!;

View file

@ -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<unknown>;
}
type ModuleProps<T extends LoaderModule> = {
name: string;
argumentProcessors?: Partial<{ [key in keyof T]: (args: any[]) => any[] }>;
};
export function getModule<T extends LoaderModule>({ name, argumentProcessors: processArgs }: ModuleProps<T>) {
const module = loaderPayload.loader.modules[name];
if (!module) {
throw new Error(`Module ${name} is not registered`);
}
const baseObject: Record<string, any> = {
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);
}

175
api/native/modules/types.ts Normal file
View file

@ -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<string | null>;
/**
* 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<Record<string, string>>;
/**
* 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<boolean>;
/**
* 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<boolean>;
/**
* @param path **Full** path to file
* @param encoding Set to `base64` in order to encode response
*/
readFile(path: string, encoding: "base64" | "utf8"): Promise<string>;
saveFileToGallery?(uri: string, fileName: string, fileType: "PNG" | "JPEG"): Promise<string>;
/**
* @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<string>;
/**
* 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<unknown>;
/**
* 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<boolean>;
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;
};
}

67
api/patcher.ts Normal file
View file

@ -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<Callback> {
(func: string, parent: any, callback: Callback, once?: boolean): Unpatcher;
await(func: string, parent: Promise<unknown>, 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 };

1
api/react/index.ts Normal file
View file

@ -0,0 +1 @@
export * as jsx from "./jsx";

49
api/react/jsx.ts Normal file
View file

@ -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<string, Callback[]>();
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());
}

42
api/settings.ts Normal file
View file

@ -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<Settings>(createMMKVBackend("VENDETTA_SETTINGS")));
export const loaderConfig = wrapSync(createStorage<LoaderConfig>(
createFileBackend(getLoaderConfigPath(), {
customLoadUrl: {
enabled: false,
url: "http://localhost:4040/kettu.js"
}
})
));
export interface UpdaterSettings {
fetchPluginsOnStart?: boolean;
repoAutoFetchOverrides?: Record<string, boolean>;
}
export const updaterSettings = wrapSync(createStorage<UpdaterSettings>(createMMKVBackend("VENDETTA_UPDATER_SETTINGS")));

190
api/storage/index.ts Normal file
View file

@ -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<string, any>;
function createFileBackend<T extends object>(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<T extends object = {}>(path: string, value: T): Promise<void> {
_loadedStorage[path] = value;
createFileBackend<T>(path).set(value);
}
export function createStorageAndCallback<T extends object = {}>(
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<T>(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<T extends object> = Parameters<typeof createStorageAndCallback<T>>[2];
export async function createStorageAsync<T extends object = {}>(
path: string,
opts: StorageOptions<T> = {}
): Promise<T> {
return new Promise(r => createStorageAndCallback(path, r, opts));
}
export const createStorage = <T extends object = {}>(
path: string,
opts: StorageOptions<T> = {}
): 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<boolean> {
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<T extends object = {}>(path: string): T | undefined {
return _loadedStorage[path];
}

41
commands/debug.ts Normal file
View file

@ -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 () =>
<ApplicationCommand>{
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,
});
}
},
};

43
commands/eval.ts Normal file
View file

@ -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 () => <ApplicationCommand>{
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));
}
}
};

199
commands/plugins.ts Normal file
View file

@ -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 () =>
<ApplicationCommand>{
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<string>;
enabled: boolean; // overall enabled if any source reports enabled (Vendetta) or pluginSettings for Bunny
};
const aggregated = new Map<string, ExtEntry>();
// 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,
});
}
},
};

13
commands/reload.ts Normal file
View file

@ -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 () =>
<ApplicationCommand>{
name: "reload",
description: Strings.COMMAND_RELOAD_DESC,
options: [],
execute() {
BundleUpdaterManager.reload();
},
};

120
common/components.ts Normal file
View file

@ -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");

50
common/index.ts Normal file
View file

@ -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");

3
common/stores.ts Normal file
View file

@ -0,0 +1,3 @@
import { findByStoreNameLazy } from "@metro/wrappers";
export const UserStore = findByStoreNameLazy("UserStore");

265
common/types/components.ts Normal file
View file

@ -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<ButtonVariant, string>;
text?: string;
size?: LiteralUnion<InteractiveSize, string>;
iconPosition?: "start" | "end";
scaleAmountInPx?: number;
icon?: ImageSourcePropType | ReactNode;
style?: Style;
grow?: boolean;
}
export type Button = React.ForwardRefExoticComponent<ButtonProps>;
interface TwinButtonsProps {
children: ReactNode;
}
export type TwinButtons = React.ComponentType<TwinButtonsProps>;
// 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<number>;
pagerRef: RefObject<unknown>;
scrollTarget: SharedValue<number>;
scrollOverflow: SharedValue<number>;
scrollOffset: SharedValue<number>;
items: SegmentedControlItem[];
itemDimensions: SharedValue<unknown[]>;
pageWidth: number;
pressedIndex: SharedValue<number>;
onPageChangeRef: MutableRefObject<unknown>; // { 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<SegmentedControlProps>;
interface SegmentedControlPagesProps {
state: SegmentedControlState;
}
export type SegmentedControlPages = React.FC<SegmentedControlPagesProps>;
interface CompatSegmentedControlProps {
values: string[];
selectedSegmentIndex?: number;
onValueChange?: (index: number) => void;
onSetActiveIndex?: (index: number) => void;
}
export type CompatSegmentedControl = React.FC<CompatSegmentedControlProps>;
// TODO: Confirm if this is real
interface TextInputProps extends Omit<RN.TextInputProps, "onChange" | "onChangeText" | "value"> {
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<any>;
leadingPressableProps?: PressableProps;
leadingText?: string;
onChange?: (text: string) => void;
size?: "sm" | "md" | "lg";
state?: "error" | "default";
style?: Style;
trailingIcon?: React.FC<any>;
trailingPressableProps?: PressableProps;
trailingText?: string;
value?: string | Nullish;
}
export type TextInput = React.FC<TextInputProps>;
interface RowButtonProps {
variant?: LiteralUnion<ButtonVariant, string>;
style?: Style;
icon?: ImageSourcePropType | ReactNode;
label: string | ReactNode;
subLabel?: string | ReactNode;
onPress: () => void;
disabled?: boolean;
}
export type RowButton = React.FC<RowButtonProps>;
interface StackProps {
/** defaults to vertical */
direction?: "vertical" | "horizontal";
/** defaults to 8 */
spacing?: number;
}
export type Stack = React.FC<React.PropsWithChildren<StackProps> & RN.ViewProps>;
interface FABProps {
icon: ImageSourcePropType | ReactNode;
style?: Style;
onPress: () => void;
positionBottom?: number;
}
export type FloatingActionButton = React.FC<FABProps>;
interface ActionSheetProps {
scrollable?: boolean;
}
export type ActionSheet = React.FC<React.PropsWithChildren<ActionSheetProps>>;
type TextProps = React.ComponentProps<typeof RN.Text> & {
variant?: TextStyles;
color?: ThemeColors;
lineClamp?: number;
maxFontSizeMultiplier?: number;
style?: RN.TextStyle;
};
export type Text = React.FC<TextProps>;
interface IconButtonProps {
label?: string;
icon: ImageSourcePropType | ReactNode;
onPress: () => void;
disabled?: boolean;
size?: InteractiveSize;
variant?: ButtonVariant;
style?: Style;
}
export type IconButton = React.FC<IconButtonProps>;
export type PressableScale = React.FC<PropsWithoutRef<typeof RN.Pressable>>;
interface TableRowBaseProps {
arrow?: boolean;
label: string | ReactNode;
subLabel?: string | ReactNode;
variant?: LiteralUnion<"danger", string>;
icon?: JSX.Element | Falsey;
disabled?: boolean;
trailing?: ReactNode | React.ComponentType<any>;
}
interface TableRowProps extends TableRowBaseProps {
onPress?: () => void;
}
export type TableRow = React.FC<TableRowProps> & {
Icon: TableRowIcon;
TrailingText: TableRowTrailingText;
Arrow: FC<{}>;
};
interface TableRowTrailingTextProps {
text: string;
}
export type TableRowTrailingText = FC<TableRowTrailingTextProps>;
interface TableRowIconProps {
style?: RN.ImageStyle;
variant?: LiteralUnion<"danger", string>,
source: ImageSourcePropType | undefined;
}
export type TableRowIcon = React.FC<TableRowIconProps>;
interface TableRowGroupProps {
title: string;
children: ReactNode;
}
export type TableRowGroup = React.FC<TableRowGroupProps>;
interface TableRadioGroupProps {
title: string;
value: string;
hasIcons?: boolean;
onChange: <T extends string>(type: T) => void;
children: ReactNode;
}
export type TableRadioGroup = FC<TableRadioGroupProps>;
interface TableRadioRowProps extends TableRowBaseProps {
value: string;
}
export type TableRadioRow = FC<TableRadioRowProps>;
interface TableSwitchRowProps extends TableRowBaseProps {
value: boolean;
onValueChange: (value: boolean) => void;
}
export type TableSwitchRow = FC<TableSwitchRowProps>;
interface TableCheckboxRowProps extends TableRowBaseProps {
checked: boolean;
onPress: () => void;
}
export type TableCheckboxRow = FC<TableCheckboxRowProps>;
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<Record<"onPress" | "onLongPress" | "accessibilityActions" | "onAccessibilityAction", any>>;
}
export type ContextMenu = FC<ContextMenuProps>;

18
common/types/flux.ts Normal file
View file

@ -0,0 +1,18 @@
export interface Dispatcher {
_actionHandlers: unknown;
_interceptors?: ((payload: any) => void | boolean)[];
_currentDispatchActionType: undefined | string;
_processingWaitQueue: boolean;
_subscriptions: Record<string, Set<(payload: any) => void>>;
_waitQueue: unknown[];
addDependencies(node1: any, node2: any): void;
dispatch(payload: any): Promise<void>;
flushWaitQueue(): void;
isDispatching(): boolean;
register(name: string, actionHandler: Record<string, (e: any) => 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;
}

View file

@ -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<any> {
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;
}

17
debug/safeMode.ts Normal file
View file

@ -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);
}

157
entry.ts Normal file
View file

@ -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<DeferredQueue> = [];
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();
}

40
factories.ts Normal file
View file

@ -0,0 +1,40 @@
import { FilterCheckDef, FilterDefinition, ModuleExports } from "./types";
export function createFilterDefinition<A extends unknown[]>(
fn: FilterCheckDef<A>,
uniqMaker: (args: A) => string
): FilterDefinition<A> {
function createHolder<T extends Function>(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}`
)();
}

40
filters.ts Normal file
View file

@ -0,0 +1,40 @@
import { createFilterDefinition } from "./factories";
import { metroModules } from "./internals/modules";
export const byProps = createFilterDefinition<string[]>(
(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})`
);

113
finders.ts Normal file
View file

@ -0,0 +1,113 @@
import { getCacherForUniq } from "./internals/caches";
import { getModules, requireModule } from "./internals/modules";
import { FilterFn } from "./types";
function filterExports<A extends unknown[]>(
moduleExports: any,
moduleId: number,
filter: FilterFn<A>,
) {
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<A extends unknown[]>(filter: FilterFn<A>) {
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<A extends unknown[]>(filter: FilterFn<A>) {
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<A extends unknown[]>(filter: FilterFn<A>) {
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<A extends unknown[]>(filter: FilterFn<A>) {
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<A extends unknown[]>(filter: FilterFn<A>) {
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<A extends unknown[]>(filter: FilterFn<A>) {
return findAllModule(filter).map(ret => {
if (!ret.id) return;
const { id, defaultExport } = ret;
return defaultExport ? requireModule(id).default : requireModule(id);
});
}

18
global.d.ts vendored Normal file
View file

@ -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<string, any>;
var __turboModuleProxy: (name: string) => any;
interface Window {
[key: string]: any;
vendetta: any;
bunny: typeof import("@lib");
}
}
export { };

49
index.ts Normal file
View file

@ -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);
};

130
internals/caches.ts Normal file
View file

@ -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<typeof buildInitCache>;
export const getMetroCache = () => _metroCache;
function buildInitCache() {
const cache = {
_v: CACHE_VERSION,
_buildNumber: NativeClientInfoModule.getConstants().Build,
_modulesCount: Object.keys(window.modules).length,
flagsIndex: {} as Record<string, number>,
findIndex: {} as Record<string, ModulesMap | undefined>,
polyfillIndex: {} as Record<string, ModulesMap | undefined>
} 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();
}
};
}

10
internals/enums.ts Normal file
View file

@ -0,0 +1,10 @@
export enum ModuleFlags {
EXISTS = 1 << 0,
BLACKLISTED = 1 << 1,
ASSET = 1 << 2,
}
export enum ModulesMapInternal {
FULL_LOOKUP,
NOT_FOUND
}

266
internals/modules.ts Normal file
View file

@ -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<number, Set<() => void>>();
const blacklistedIds = new Set<number>();
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<Metro.FactoryFn>,
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<string, any> = {};
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["<!@ pylix was here :fuyusquish: \n Hi pylix! -cocobo1!@>"] ===
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];
}
}
}

74
lazy.ts Normal file
View file

@ -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<any, LazyModuleContext>();
function getIndexedFind<A extends unknown[]>(filter: FilterFn<A>) {
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<A extends unknown[]>(proxy: any): LazyModuleContext<A> | void {
return _lazyContexts.get(proxy) as unknown as LazyModuleContext<A>;
}
export function createLazyModule<A extends unknown[]>(filter: FilterFn<A>) {
let cache: any = undefined;
const moduleId = getIndexedFind(filter);
const context: LazyModuleContext<A> = {
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<any>);
return proxy;
}

18
modules.d.ts vendored Normal file
View file

@ -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<void>;
export default _default;
}

136
polyfills/redesign.ts Normal file
View file

@ -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<typeof redesignProps extends Set<infer U> ? U : string, string>;
const _module = {} as Record<Keys, any>;
const _source = {} as Record<Keys, number>;
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;

View file

@ -74,7 +74,7 @@ const config = {
constModules: {
globals: {
"bunny-build-info": {
version: `"1.1.11"`,
version: `"Jolly 1.2"`,
},
},
},

159
types.ts Normal file
View file

@ -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<ModuleID> & {
readonly paths?: Readonly<Record<ModuleID, string>> | undefined;
};
/** Only available on Discord's development environment, will never be defined on release builds */
export type InverseDependencyMap = Record<ModuleID, ModuleID[]>;
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<ModuleID, ModuleDefinition | Nullish>;
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<ModuleID[]> | 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<A extends unknown[]> = (
args: A,
module: any,
modulesId: number,
defaultCheck: boolean
) => boolean;
export interface FilterFn<A extends unknown[]> {
(m: any, id: number, defaultCheck: boolean): boolean;
filter: FilterCheckDef<A>;
raw: boolean;
uniq: string;
}
export interface FilterDefinition<A extends unknown[]> {
(...args: A): FilterFn<A>;
byRaw(...args: A): FilterFn<A>;
uniqMaker(args: A): string;
}
export interface LazyModuleContext<A extends unknown[] = unknown[]> {
filter: FilterFn<A>;
indexed: boolean;
moduleId?: number;
getExports(cb: (exports: any) => void): () => void;
subscribe(cb: (exports: any) => void): () => void;
forceLoad(): any;
get cache(): any;
}

4
ui/alerts.ts Normal file
View file

@ -0,0 +1,4 @@
import { lazyDestructure } from "@lib/utils/lazy";
import { findByProps } from "@metro";
export const { openAlert, dismissAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));

30
ui/color.ts Normal file
View file

@ -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<string, any>;
// ? 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<string, string>;
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);
}

View file

@ -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) => <TextInput editable={false} multiline style={[useStyles().codeBlock, style && style]} value={children} />;
const TextBasedCodeblock = ({ selectable, style, children }: CodeblockProps) => <Text selectable={selectable} style={[useStyles().codeBlock, style && style]}>{children}</Text>;
export default function Codeblock({ selectable, style, children }: CodeblockProps) {
if (!selectable) return <TextBasedCodeblock style={style} children={children} />;
return Platform.select({
ios: <InputBasedCodeblock style={style} children={children} />,
default: <TextBasedCodeblock style={style} children={children} selectable />,
});
}

View file

@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
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 (
<ErrorCard
error={this.state.error}
onRetryRender={() => this.setState({ hasErr: false })}
/>
);
}
}

View file

@ -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<void>);
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 (
<LegacyAlert
title={title}
confirmText={confirmText}
confirmColor={confirmColor}
isConfirmButtonDisabled={error.length !== 0}
onConfirm={onConfirmWrapper}
cancelText={cancelText}
onCancel={() => Alerts.close()}
>
<LegacyFormInput
placeholder={placeholder}
value={value}
onChange={(v: string | { text: string; }) => {
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" }}
/>
</LegacyAlert>
);
}

58
ui/components/Search.tsx Normal file
View file

@ -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 (
<Image
style={{ width: 16, height: 16 }}
source={findAssetId("icon-search")!}
/>
);
}
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 (
<ErrorBoundary>
<View style={style}>
<TextInput
grow={true}
isClearable={true}
leadingIcon={SearchIcon}
placeholder={placeholder ?? Strings.SEARCH}
onChange={onChange}
returnKeyType="search"
size="md"
autoCapitalize="none"
autoCorrect={false}
isRound={isRound}
style={inputStyle}
value={query}
/>
</View>
</ErrorBoundary>
);
};

32
ui/components/Summary.tsx Normal file
View file

@ -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 (
<>
<TableRow
label={label}
icon={icon && <TableRow.Icon source={findAssetId(icon)} />}
trailing={<LegacyFormRow.Arrow style={{ transform: [{ rotate: `${hidden ? 180 : 90}deg` }] }} />}
onPress={() => {
setHidden(!hidden);
if (!noAnimation) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
}}
/>
{!hidden && <>
<View style={!noPadding && { paddingHorizontal: 15 }}>{children}</View>
</>}
</>
);
}

5
ui/components/index.ts Normal file
View file

@ -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";

View file

@ -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<ComponentProps<Button>, "onPress"> & {
onPress?: () => void | Promise<unknown>;
};
const {
AlertModal: _AlertModal,
AlertActionButton: _AlertActionButton
} = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
export const AlertActionButton = _AlertActionButton as ComponentType<ActionButtonProps>;
export default function AlertModal(props: Record<string, unknown>) {
const forwardFailedModal = findByFilePath("modules/forwarding/native/ForwardFailedAlertModal.tsx");
// ponyfill for extraContent
if (!forwardFailedModal && "extraContent" in props) {
props.content = (
<View style={{ gap: 16 }}>
<Text variant="text-md/medium" color="text-muted">
{props.content as string}
</Text>
<View>
{props.extraContent as ReactNode}
</View>
</View>
);
delete props.extraContent;
}
return <_AlertModal {...props} />;
}

View file

@ -0,0 +1,3 @@
import AlertModal, { AlertActionButton } from "./AlertModal";
export { AlertActionButton, AlertModal };

7
ui/index.ts Normal file
View file

@ -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";

37
ui/settings/index.tsx Normal file
View file

@ -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<typeof lazy>[0];
icon?: ImageURISource | number;
IconComponent?: React.ReactNode;
usePredicate?: () => boolean;
useTrailing?: () => string | JSX.Element;
rawTabsConfig?: Record<string, any>;
}
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());
}

View file

@ -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 && (
<LegacyFormSection key={sect} title={sect}>
{/** Is usePredicate here safe? */}
{registeredSections[sect]
.filter((r) => r.usePredicate?.() ?? true)
.map((row, i, arr) => (
<>
<LegacyFormRow
label={row.title()}
leading={<LegacyFormIcon source={row.icon} />}
trailing={
<LegacyFormRow.Arrow
label={row.useTrailing?.() || undefined}
/>
}
onPress={wrapOnPress(
row.onPress,
navigation,
row.render,
row.title(),
)}
/>
{i !== arr.length - 1 && <LegacyFormDivider />}
</>
))}
</LegacyFormSection>
),
)}
</>
);
}
export function patchPanelUI(unpatches: (() => void | boolean)[]) {
try {
unpatches.push(
after("default", findByNameLazy("getScreens", false), (_a, screens) => ({
...screens,
VendettaCustomPage: {
title: "ShiggyCord",
render: () => <CustomPageRenderer />,
},
BUNNY_CUSTOM_PAGE: {
title: "ShiggyCord",
render: () => <CustomPageRenderer />,
},
SHIGGYCORD_CUSTOM_PAGE: {
title: "ShiggyCord",
render: () => <CustomPageRenderer />,
},
})),
);
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, <SettingsSection />);
}
}),
);
},
true,
);
unpatches.push(unpatch);
} catch {}
}

View file

@ -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 (
<ErrorBoundary>
<PageComponent />
</ErrorBoundary>
);
});
export function wrapOnPress(
onPress: (() => unknown) | undefined,
navigation?: any,
renderPromise?: RowConfig["render"],
screenOptions?: string | Record<string, any>,
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: () => <Component {...props} />,
});
};
}

View file

@ -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: () => <TableRow.Icon source={row.icon} />,
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),
});
});
}),
);
}
}

16
ui/sheets.ts Normal file
View file

@ -0,0 +1,16 @@
import { findByPropsLazy } from "@metro/wrappers";
const actionSheet = findByPropsLazy("openLazy", "hideActionSheet");
export function showSheet<T extends React.ComponentType<any>>(
key: string,
lazyImport: Promise<{ default: T; }> | T,
props?: React.ComponentProps<T>
) {
if (!("then" in lazyImport)) lazyImport = Promise.resolve({ default: lazyImport });
actionSheet.openLazy(lazyImport, key, props ?? {});
}
export function hideSheet(key: string) {
actionSheet.hideActionSheet(key);
}

75
ui/styles.ts Normal file
View file

@ -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<T> = { [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<TextStyles, TextStyle>;
};
/**
* 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 <View style={styles.container} />;
* }
*/
export function createStyles<T extends NamedStyles<T>>(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 <View style={styles.container} />;
* }
* }
*/
export function createLegacyClassComponentStyles<T extends NamedStyles<T>>(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<T extends StyleSheet.NamedStyles<T>>(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;
}

20
ui/toasts.ts Normal file
View file

@ -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"));
};

6
ui/types.ts Normal file

File diff suppressed because one or more lines are too long

15
utils/constants.ts Normal file
View file

@ -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";

29
utils/cyrb64.ts Normal file
View file

@ -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");
}

8
utils/findInReactTree.ts Normal file
View file

@ -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"],
});
}

53
utils/findInTree.ts Normal file
View file

@ -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<string, any>;
export type SearchFilter = (tree: SearchTree) => boolean;
export interface FindInTreeOptions {
walkable?: string[];
ignore?: string[];
maxDepth?: number;
}
function treeSearch(tree: SearchTree, filter: SearchFilter, opts: Required<FindInTreeOptions>, 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);
}

View file

@ -0,0 +1,30 @@
import { LiteralUnion } from "type-fest";
type KeyOfOrAny<P, T extends object> = P extends keyof T ? T[P] : any;
export default function hookDefineProperty<
T extends object,
P extends LiteralUnion<keyof T, PropertyKey>
>(target: T, property: LiteralUnion<keyof T, PropertyKey>, cb: (val: KeyOfOrAny<P, T>) => KeyOfOrAny<P, T>) {
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;
};
}

20
utils/index.ts Normal file
View file

@ -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 };

23
utils/invariant.ts Normal file
View file

@ -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);
}

12
utils/isValidHttpUrl.ts Normal file
View file

@ -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:";
}

166
utils/lazy.ts Normal file
View file

@ -0,0 +1,166 @@
/* eslint-disable func-call-spacing */
type ExemptedEntries = Record<symbol | string, unknown>;
interface LazyOptions<E extends ExemptedEntries> {
hint?: "function" | "object";
exemptedEntries?: E;
}
interface ContextHolder {
options: LazyOptions<any>;
factory: any;
}
const unconfigurable = new Set(["arguments", "caller", "prototype"]);
const isUnconfigurable = (key: PropertyKey) =>
typeof key === "string" && unconfigurable.has(key);
const factories = new WeakMap<any, () => any>();
const proxyContextHolder = new WeakMap<any, ContextHolder>();
const lazyHandler: ProxyHandler<any> = {
...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<T, I extends ExemptedEntries>(
factory: () => T,
opts: LazyOptions<I> = {},
): 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<PropertyKey, unknown>,
I extends ExemptedEntries,
>(factory: () => T, opts: LazyOptions<I> = {}): 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<T>(obj: T): (() => T) | void {
return factories.get(obj) as (() => T) | void;
}

15
utils/logger.ts Normal file
View file

@ -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");

16
utils/safeFetch.ts Normal file
View file

@ -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;
}

1
utils/types.ts Normal file
View file

@ -0,0 +1 @@
export type Nullish = null | undefined;

25
wrappers.ts Normal file
View file

@ -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));