Add files via upload
This commit is contained in:
parent
55233fda91
commit
0ac9ee6dd3
93 changed files with 6786 additions and 1 deletions
117
addons/fonts/index.ts
Normal file
117
addons/fonts/index.ts
Normal 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
59
addons/plugins/api.ts
Normal 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
823
addons/plugins/index.ts
Normal 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
55
addons/plugins/types.ts
Normal 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;
|
||||
};
|
||||
18
addons/themes/colors/index.ts
Normal file
18
addons/themes/colors/index.ts
Normal 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());
|
||||
}
|
||||
64
addons/themes/colors/keyboard.ts
Normal file
64
addons/themes/colors/keyboard.ts
Normal 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?.(() => {});
|
||||
}
|
||||
157
addons/themes/colors/parser.ts
Normal file
157
addons/themes/colors/parser.ts
Normal 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();
|
||||
}
|
||||
59
addons/themes/colors/patches/background.tsx
Normal file
59
addons/themes/colors/patches/background.tsx
Normal 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());
|
||||
}
|
||||
92
addons/themes/colors/patches/resolver.ts
Normal file
92
addons/themes/colors/patches/resolver.ts
Normal 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]];
|
||||
}
|
||||
38
addons/themes/colors/patches/storage.ts
Normal file
38
addons/themes/colors/patches/storage.ts
Normal 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());
|
||||
}
|
||||
18
addons/themes/colors/preferences.ts
Normal file
18
addons/themes/colors/preferences.ts
Normal 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
|
||||
}
|
||||
}
|
||||
);
|
||||
57
addons/themes/colors/statusbar.ts
Normal file
57
addons/themes/colors/statusbar.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
52
addons/themes/colors/types.ts
Normal file
52
addons/themes/colors/types.ts
Normal 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;
|
||||
72
addons/themes/colors/updater.ts
Normal file
72
addons/themes/colors/updater.ts
Normal 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
168
addons/themes/index.ts
Normal 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
17
addons/types.ts
Normal 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
79
api/assets/index.ts
Normal 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
25
api/assets/patches.ts
Normal 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
84
api/commands/index.ts
Normal 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
76
api/commands/types.ts
Normal 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
201
api/debug.ts
Normal 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
49
api/flux/index.ts
Normal 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
9
api/index.ts
Normal 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
76
api/native/fs.ts
Normal 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
1
api/native/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * as fs from "./fs";
|
||||
254
api/native/loader.ts
Normal file
254
api/native/loader.ts
Normal 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");
|
||||
}
|
||||
82
api/native/modules/Loader.ts
Normal file
82
api/native/modules/Loader.ts
Normal 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);
|
||||
}
|
||||
16
api/native/modules/bubblemodule.ts
Normal file
16
api/native/modules/bubblemodule.ts
Normal 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]))],
|
||||
},
|
||||
});
|
||||
38
api/native/modules/index.ts
Normal file
38
api/native/modules/index.ts
Normal 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"
|
||||
)!;
|
||||
82
api/native/modules/loader.ts
Normal file
82
api/native/modules/loader.ts
Normal 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
175
api/native/modules/types.ts
Normal 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
67
api/patcher.ts
Normal 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
1
api/react/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * as jsx from "./jsx";
|
||||
49
api/react/jsx.ts
Normal file
49
api/react/jsx.ts
Normal 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
42
api/settings.ts
Normal 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
190
api/storage/index.ts
Normal 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
41
commands/debug.ts
Normal 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
43
commands/eval.ts
Normal 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
199
commands/plugins.ts
Normal 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
13
commands/reload.ts
Normal 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
120
common/components.ts
Normal 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
50
common/index.ts
Normal 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
3
common/stores.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { findByStoreNameLazy } from "@metro/wrappers";
|
||||
|
||||
export const UserStore = findByStoreNameLazy("UserStore");
|
||||
265
common/types/components.ts
Normal file
265
common/types/components.ts
Normal 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
18
common/types/flux.ts
Normal 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;
|
||||
}
|
||||
160
debug/patches/patchErrorBoundary.tsx
Normal file
160
debug/patches/patchErrorBoundary.tsx
Normal 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
17
debug/safeMode.ts
Normal 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
157
entry.ts
Normal 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
40
factories.ts
Normal 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
40
filters.ts
Normal 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
113
finders.ts
Normal 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
18
global.d.ts
vendored
Normal 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
49
index.ts
Normal 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
130
internals/caches.ts
Normal 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
10
internals/enums.ts
Normal 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
266
internals/modules.ts
Normal 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
74
lazy.ts
Normal 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
18
modules.d.ts
vendored
Normal 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
136
polyfills/redesign.ts
Normal 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;
|
||||
|
|
@ -74,7 +74,7 @@ const config = {
|
|||
constModules: {
|
||||
globals: {
|
||||
"bunny-build-info": {
|
||||
version: `"1.1.11"`,
|
||||
version: `"Jolly 1.2"`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
159
types.ts
Normal file
159
types.ts
Normal 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
4
ui/alerts.ts
Normal 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
30
ui/color.ts
Normal 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);
|
||||
}
|
||||
37
ui/components/Codeblock.tsx
Normal file
37
ui/components/Codeblock.tsx
Normal 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 />,
|
||||
});
|
||||
}
|
||||
36
ui/components/ErrorBoundary.tsx
Normal file
36
ui/components/ErrorBoundary.tsx
Normal 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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
58
ui/components/InputAlert.tsx
Normal file
58
ui/components/InputAlert.tsx
Normal 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
58
ui/components/Search.tsx
Normal 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
32
ui/components/Summary.tsx
Normal 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
5
ui/components/index.ts
Normal 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";
|
||||
39
ui/components/wrappers/AlertModal.tsx
Normal file
39
ui/components/wrappers/AlertModal.tsx
Normal 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} />;
|
||||
}
|
||||
3
ui/components/wrappers/index.ts
Normal file
3
ui/components/wrappers/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import AlertModal, { AlertActionButton } from "./AlertModal";
|
||||
|
||||
export { AlertActionButton, AlertModal };
|
||||
7
ui/index.ts
Normal file
7
ui/index.ts
Normal 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
37
ui/settings/index.tsx
Normal 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());
|
||||
}
|
||||
123
ui/settings/patches/panel.tsx
Normal file
123
ui/settings/patches/panel.tsx
Normal 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 {}
|
||||
}
|
||||
44
ui/settings/patches/shared.tsx
Normal file
44
ui/settings/patches/shared.tsx
Normal 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} />,
|
||||
});
|
||||
};
|
||||
}
|
||||
141
ui/settings/patches/tabs.tsx
Normal file
141
ui/settings/patches/tabs.tsx
Normal 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
16
ui/sheets.ts
Normal 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
75
ui/styles.ts
Normal 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
20
ui/toasts.ts
Normal 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
6
ui/types.ts
Normal file
File diff suppressed because one or more lines are too long
15
utils/constants.ts
Normal file
15
utils/constants.ts
Normal 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
29
utils/cyrb64.ts
Normal 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
8
utils/findInReactTree.ts
Normal 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
53
utils/findInTree.ts
Normal 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);
|
||||
}
|
||||
30
utils/hookDefineProperty.ts
Normal file
30
utils/hookDefineProperty.ts
Normal 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
20
utils/index.ts
Normal 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
23
utils/invariant.ts
Normal 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
12
utils/isValidHttpUrl.ts
Normal 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
166
utils/lazy.ts
Normal 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
15
utils/logger.ts
Normal 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
16
utils/safeFetch.ts
Normal 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
1
utils/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type Nullish = null | undefined;
|
||||
25
wrappers.ts
Normal file
25
wrappers.ts
Normal 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));
|
||||
Loading…
Add table
Reference in a new issue