diff --git a/README.md b/README.md
index 7c9a9fa..116de2d 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,14 @@ A mod for Discord on Android
- **Root** with Xposed - [KettuXposed](https://github.com/C0C0B01/KettuXposed/releases/latest)
- **Non-root** - [KettuManager](https://github.com/C0C0B01/KettuManager/releases/latest)
+If you do not know what root is, go with the manager
+
### iOS
-- [KettuTweak](https://github.com/C0C0B01/KettuTweak)
-- [BTLoader](https://github.com/CloudySn0w/BTLoader)
+
+- **Jailbroken** - [KettuTweak](https://github.com/C0C0B01/KettuTweak)
+- **Jailed** - [BTLoader](https://github.com/CloudySn0w/BTLoader)
+
+if you dont know what jailbreak is, go with BTLoader
## Building
1. Install a Kettu loader with loader config support (any mentioned in the [Installing](#installing) section).
diff --git a/src/core/i18n/default.json b/src/core/i18n/default.json
index d97d968..3ab3350 100644
--- a/src/core/i18n/default.json
+++ b/src/core/i18n/default.json
@@ -35,6 +35,7 @@
"COPY_URL": "Copy URL",
"DEBUG": "Debug",
"DEBUGGER_URL": "Debugger URL",
+ "DEVTOOLS_URL": "DevTools URL",
"DELETE": "Delete",
"DESC_EXTRACT_FONTS_FROM_THEME": "Looks out for \"fonts\" field in your currently applied theme and install it.",
"DEVELOPER": "Developer",
diff --git a/src/core/ui/settings/pages/Developer/AssetBrowser.tsx b/src/core/ui/settings/pages/Developer/AssetBrowser.tsx
index b1bdcbb..785cb78 100644
--- a/src/core/ui/settings/pages/Developer/AssetBrowser.tsx
+++ b/src/core/ui/settings/pages/Developer/AssetBrowser.tsx
@@ -1,30 +1,70 @@
import AssetDisplay from "@core/ui/settings/pages/Developer/AssetDisplay";
-import { iterateAssets } from "@lib/api/assets";
+import { iterateAssets, findAssetId } from "@lib/api/assets";
import { Text } from "@metro/common/components";
import { ErrorBoundary, Search } from "@ui/components";
import { useMemo } from "react";
-import { FlatList, View } from "react-native";
+import { FlatList, View, ScrollView, TouchableOpacity, Image } from "react-native";
+
+const displayable = new Set(['png', 'jpg', 'svg']);
export default function AssetBrowser() {
const [search, setSearch] = React.useState("");
+ const [showNonImages, setShowNonImages] = React.useState(false);
+
const all = useMemo(() => Array.from(iterateAssets()), []);
+ const filteredData = useMemo(() => {
+ let result = all.filter(a => a.name.includes(search) || a.id.toString() === search);
+
+ if (!showNonImages) {
+ result = result.filter(a => displayable.has(a.type));
+ }
+
+ return result;
+ }, [all, search, showNonImages]);
+
+
return (
- setSearch(v)}
- />
-
- Some assets types cannot be displayed and will be marked in red.
- a.name.includes(search) || a.id.toString() === search)}
- renderItem={({ item }: any) => }
- contentContainerStyle={{ overflow: 'hidden', backgroundColor: 'transparent', borderRadius: 16 }}
- keyExtractor={a => a.name}
+
+
+ setSearch(v)}
/>
+ setShowNonImages(!showNonImages)}
+ >
+
+
+
+
+
+ {showNonImages && (
+ Some assets types cannot be displayed and will be marked in red.
+ )}
+ }
+ contentContainerStyle={{ overflow: 'hidden', backgroundColor: 'transparent', borderRadius: 16 }}
+ scrollEnabled={false}
+ keyExtractor={a => a.id.toString()}
+ />
+
+
);
diff --git a/src/core/ui/settings/pages/Developer/index.tsx b/src/core/ui/settings/pages/Developer/index.tsx
index 6f66a81..788f0b1 100644
--- a/src/core/ui/settings/pages/Developer/index.tsx
+++ b/src/core/ui/settings/pages/Developer/index.tsx
@@ -15,13 +15,14 @@ import { ErrorBoundary } from "@ui/components";
import { createStyles, TextStyleSheet } from "@ui/styles";
import { NativeModules } from "react-native";
import { ScrollView, StyleSheet } from "react-native";
+import { showToast } from "@ui/toasts";
const { hideActionSheet } = lazyDestructure(() => findByProps("openLazy", "hideActionSheet"));
const { showSimpleActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet"));
const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
const { AlertModal, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
-const RDT_EMBED_LINK = "";
+const RDT_EMBED_LINK = "https://cdn.bwlok.dev/rdt/devTools.js";
const useStyles = createStyles({
leadingText: {
@@ -44,31 +45,75 @@ export default function Developer() {
- ws://}
- defaultValue={settings.debuggerUrl}
- onChange={(v: string) => settings.debuggerUrl = v}
- />
-
- }
- onPress={() => connectToDebugger(settings.debuggerUrl)}
- />
- {isReactDevToolsPreloaded() && <>
- }
- onPress={() => window[getReactDevToolsProp() || "__vendetta_rdc"]?.connectToDevTools({
- host: settings.debuggerUrl.split(":")?.[0],
- resolveRNStyle: StyleSheet.flatten,
- })}
- />
- >}
+
+
+ ws://}
+ defaultValue={settings.debuggerUrl}
+ onChange={(v: string) => settings.debuggerUrl = v}
+ />
+
+ }
+ onPress={() => connectToDebugger(settings.debuggerUrl)}
+ />
+
+
+ {isReactDevToolsPreloaded() && <>
+
+
+ ws://}
+ defaultValue={settings.devToolsUrl}
+ onChange={(v: string) => settings.devToolsUrl = v}
+ />
+
+ }
+ onPress={async () => {
+ if (!settings.devToolsUrl?.trim()) {
+ showToast("Invalid devTools URL!", findAssetId("Small"));
+ return;
+ }
+
+ try {
+ 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 (error) {
+ showToast("Invalid devTools URL!", findAssetId("Small"));
+ }
+ }}
+
+ />
+
+
+ >}
{isLoaderConfigSupported() && <>
{
// Load everything in parallel
- updatePlugins();
-
await Promise.all([
initThemes(),
injectFluxInterceptor(),
diff --git a/src/lib/addons/themes/colors/parser.ts b/src/lib/addons/themes/colors/parser.ts
index e7e5c40..e2d7ced 100644
--- a/src/lib/addons/themes/colors/parser.ts
+++ b/src/lib/addons/themes/colors/parser.ts
@@ -90,7 +90,6 @@ export function parseColorManifest(manifest: ColorManifest): InternalColorDefini
manifest.rawColors = draft;
}
-
return {
spec: 2,
reference: resolveType(),
@@ -100,9 +99,97 @@ export function parseColorManifest(manifest: ColorManifest): InternalColorDefini
};
}
+ if (manifest.spec === 1 || (manifest.theme_color_map && !manifest.spec)) {
+
+ const semanticDefinitions: InternalColorDefinition["semantic"] = {};
+ const rawDefinitions: Record = {};
+
+ const themeIndex = resolveType() === "light" ? 1 : 0;
+
+ if (manifest.theme_color_map) {
+ for (const [key, colorArray] of Object.entries(manifest.theme_color_map)) {
+ if (!Array.isArray(colorArray) || colorArray.length < 2) {
+ console.warn(`[Parser] Invalid color array for ${key}:`, colorArray);
+ continue;
+ }
+
+ const colorValue = colorArray[themeIndex];
+
+ if (!colorValue) {
+ console.warn(`[Parser] Missing color value for ${key} at index ${themeIndex}`);
+ continue;
+ }
+
+ const normalized = normalizeToHex(colorValue);
+ if (normalized) {
+ semanticDefinitions[key] = {
+ value: normalized,
+ opacity: 1
+ };
+ }
+ }
+ }
+
+ const rawColors = manifest.colours || manifest.colors;
+ if (rawColors) {
+ for (const [key, colorValue] of Object.entries(rawColors)) {
+ if (typeof colorValue !== "string") continue;
+
+ const normalized = normalizeToHex(colorValue);
+ if (normalized) {
+ const discordKey = convertEnmityToDiscordRawKey(key);
+ rawDefinitions[discordKey] = normalized;
+
+ if (discordKey !== key) {
+ rawDefinitions[key] = normalized;
+ }
+ }
+ }
+ }
+
+ if (manifest.unsafe_colors) {
+ for (const [key, colorValue] of Object.entries(manifest.unsafe_colors)) {
+ if (typeof colorValue !== "string") continue;
+
+ const normalized = normalizeToHex(colorValue);
+ if (normalized) {
+ rawDefinitions[key] = normalized;
+ }
+ }
+ }
+
+ if (Platform.OS === "android") applyAndroidAlphaKeys(rawDefinitions);
+
+ return {
+ spec: 2,
+ reference: resolveType(),
+ semantic: semanticDefinitions,
+ raw: rawDefinitions,
+ background: manifest.background
+ };
+ }
throw new Error("Invalid theme spec");
}
+ function convertEnmityToDiscordRawKey(enmityKey: string): string {
+ const conversions: Record = {
+ "PRIMARY_DARK": "PRIMARY_100",
+ "PRIMARY_DARK_100": "PRIMARY_100",
+ "PRIMARY_DARK_200": "PRIMARY_200",
+ "PRIMARY_DARK_300": "PRIMARY_300",
+ "PRIMARY_DARK_360": "PRIMARY_360",
+ "PRIMARY_DARK_400": "PRIMARY_400",
+ "PRIMARY_DARK_500": "PRIMARY_500",
+ "PRIMARY_DARK_600": "PRIMARY_600",
+ "PRIMARY_DARK_630": "PRIMARY_630",
+ "PRIMARY_DARK_700": "PRIMARY_700",
+ "PRIMARY_DARK_800": "PRIMARY_800",
+ "PRIMARY_DARK_900": "PRIMARY_900",
+ };
+
+ return conversions[enmityKey] || enmityKey;
+ }
+
export function applyAndroidAlphaKeys(rawColors?: Record) {
if (!rawColors) return;
@@ -129,6 +216,11 @@ export function applyAndroidAlphaKeys(rawColors?: Record) {
export function normalizeToHex(colorString: string | undefined): string | undefined {
if (colorString === undefined) return undefined;
+
+ if (colorString.toLowerCase() === "transparent") {
+ return "#00000000";
+ }
+
if (chroma.valid(colorString)) return chroma(colorString).hex();
const color = Number(processColor(colorString));
@@ -140,4 +232,3 @@ export function normalizeToHex(colorString: string | undefined): string | undefi
color >> 24 & 0xff // alpha
).hex();
}
-
diff --git a/src/lib/addons/themes/colors/types.ts b/src/lib/addons/themes/colors/types.ts
index 99b6e39..203f3c0 100644
--- a/src/lib/addons/themes/colors/types.ts
+++ b/src/lib/addons/themes/colors/types.ts
@@ -39,7 +39,7 @@ export interface VendettaThemeManifest {
/** @internal */
export interface InternalColorDefinition {
- spec: 2 | 3;
+ spec: 1 | 2 | 3;
reference: "darker" | "light";
semantic: Record;
+ unhookBubbles(): Promise;
+ configure(avatarRadius?: number, bubbleRadius?: number, bubbleColor?: string): Promise;
+}
+
+export default getModule({
+ name: "BubbleModule",
+ argumentProcessors: {
+ configure: args => [args[0], args[1], Number(processColor(args[2]))],
+ },
+});
\ No newline at end of file
diff --git a/src/lib/api/native/modules/index.ts b/src/lib/api/native/modules/index.ts
index d6919cc..2c63415 100644
--- a/src/lib/api/native/modules/index.ts
+++ b/src/lib/api/native/modules/index.ts
@@ -33,3 +33,6 @@ export const NativeThemeModule = getNativeModule(
export const BundleUpdaterManager = getNativeModule(
"BundleUpdaterManager"
)!;
+export const ImageLoader = getNativeModule(
+ "ImageLoader"
+)!;
\ No newline at end of file
diff --git a/src/lib/api/native/modules/loader.ts b/src/lib/api/native/modules/loader.ts
new file mode 100644
index 0000000..7a6fd65
--- /dev/null
+++ b/src/lib/api/native/modules/loader.ts
@@ -0,0 +1,82 @@
+import { ImageLoader } from "./";
+import type { LoaderPayload } from "./types";
+
+type InteropReturn = Promise<{ ret: string } | { err: string } | { cancelled: true; reason: string }>;
+
+export const loaderPayload = window.__PYON_LOADER__ as LoaderPayload;
+
+export function isModuleRegistered(module: string) {
+ return loaderPayload.loader.modules[module] !== undefined;
+}
+
+export function isFunctionRegistered(module: string, function_: string) {
+ return loaderPayload.loader.modules[module]?.functions[function_] !== undefined;
+}
+
+export async function callFunction(module: string, functionName: string, args: unknown[]) {
+ if (!isFunctionRegistered(module, functionName)) {
+ throw new Error(`Function ${module}.${functionName} is not registered`);
+ }
+
+ const promise: InteropReturn = ImageLoader.queryCache([
+ "__pyon_bridge",
+ JSON.stringify({ m: module, f: functionName, a: args }),
+ ]);
+
+ const result = await promise;
+
+ if ("ret" in result) {
+ return JSON.parse(result.ret);
+ }
+
+ if ("cancelled" in result) {
+ throw new Error(`Function ${module}.${functionName} was cancelled: ${result.reason}`);
+ }
+
+ if ("err" in result) {
+ throw new Error(result.err);
+ }
+}
+
+export interface LoaderModule {
+ hasFunction(functionName: string): boolean;
+ callFunction(functionName: string, args: unknown[]): Promise;
+}
+
+type ModuleProps = {
+ name: string;
+ argumentProcessors?: Partial<{ [key in keyof T]: (args: any[]) => any[] }>;
+};
+
+export function getModule({ name, argumentProcessors: processArgs }: ModuleProps) {
+ const module = loaderPayload.loader.modules[name];
+ if (!module) {
+ throw new Error(`Module ${name} is not registered`);
+ }
+
+ const baseObject: Record = {
+ hasFunction: (functionName: string) => isFunctionRegistered(name, functionName),
+ callFunction: (functionName: string, args: unknown[]) => callFunction(name, functionName, args),
+ };
+
+ for (const [functionName] of Object.entries(module.functions)) {
+ baseObject[functionName] = (...args: unknown[]) =>
+ callFunction(
+ name,
+ functionName,
+ // @ts-expect-error
+ processArgs && functionName in processArgs ? processArgs[functionName](args) : args,
+ );
+ }
+
+ return baseObject as T;
+}
+
+/**
+ * Determines if safe mode is currently enabled. The result remains consistent over time.
+ * This will not change until the app is restarted. For draft changes, use the InitConfigStore.
+ * @returns True if safe mode is enabled for the current instance, otherwise false.
+ */
+export function isSafeModeEnabled() {
+ return Boolean(loaderPayload.loader.initConfig.safeMode);
+}
\ No newline at end of file
diff --git a/src/lib/api/native/modules/types.ts b/src/lib/api/native/modules/types.ts
index fdb3a32..cf54956 100644
--- a/src/lib/api/native/modules/types.ts
+++ b/src/lib/api/native/modules/types.ts
@@ -134,3 +134,42 @@ export namespace RNModules {
}
}
}
+
+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;
+ };
+}
\ No newline at end of file
diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts
index d222a5e..85e5eeb 100644
--- a/src/lib/api/settings.ts
+++ b/src/lib/api/settings.ts
@@ -3,6 +3,7 @@ import { getLoaderConfigPath } from "@lib/api/native/loader";
export interface Settings {
debuggerUrl: string;
+ devToolsUrl: string;
developerSettings: boolean;
enableDiscordDeveloperSettings: boolean;
safeMode?: {
diff --git a/src/metro/internals/modules.ts b/src/metro/internals/modules.ts
index 964b116..81aa485 100644
--- a/src/metro/internals/modules.ts
+++ b/src/metro/internals/modules.ts
@@ -238,3 +238,100 @@ export function* getCachedPolyfillModules(name: string) {
}
}
}
+
+export interface WaitForOptions {
+ count?: number;
+}
+
+export type ModuleFilter = {
+ (exports: any): T | undefined;
+ key?: string;
+}
+
+export function waitFor(
+ filter: ModuleFilter,
+ callback: (exports: T, id: Metro.ModuleID) => void,
+ options: WaitForOptions = {}
+): () => void {
+ const { count = 1 } = options;
+ let currentCount = 0;
+ const unsubscribers: Array<() => void> = [];
+ let isActive = true;
+
+ const cleanup = () => {
+ if (!isActive) return;
+ isActive = false;
+ unsubscribers.forEach(unsub => unsub());
+ unsubscribers.length = 0;
+ };
+
+ function checkModule(id: Metro.ModuleID): boolean {
+ if (!isActive) return true;
+
+ const exports = requireModule(id);
+ if (isBadExports(exports)) return false;
+
+ const result = filter(exports);
+ if (!result) return false;
+
+ callback(result, id);
+
+ if (++currentCount >= count) {
+ cleanup();
+ return true;
+ }
+
+ return false;
+ }
+
+ if (filter.key) {
+ const cache = getMetroCache().findIndex[filter.key];
+ if (cache) {
+ for (const id in cache) {
+ if (id[0] === "_") continue;
+ const numId = Number(id);
+
+ if (metroModules[numId]?.isInitialized) {
+ if (checkModule(numId)) return cleanup;
+ } else {
+ const unsub = subscribeModule(numId, () => {
+ checkModule(numId);
+ });
+ unsubscribers.push(unsub);
+ }
+ }
+ }
+ }
+
+ for (const id in metroModules) {
+ if (!isActive) break;
+ const numId = Number(id);
+
+ if (metroModules[numId]?.isInitialized && !metroModules[numId]?.hasError) {
+ if (checkModule(numId)) return cleanup;
+ }
+ }
+
+ if (isActive) {
+ for (const id in metroModules) {
+ const numId = Number(id);
+ if (!metroModules[numId]?.isInitialized) {
+ const unsub = subscribeModule(numId, () => {
+ checkModule(numId);
+ });
+ unsubscribers.push(unsub);
+ }
+ }
+ }
+
+ return cleanup;
+}
+
+export function waitForModule(
+ filter: ModuleFilter,
+ options: WaitForOptions = {}
+): Promise {
+ return new Promise((resolve) => {
+ waitFor(filter, (exports) => resolve(exports), options);
+ });
+}