From c9223d44f35dd76aa7703fd08c0127e596f6cc2f Mon Sep 17 00:00:00 2001 From: kmmiio99o <164557248+kmmiio99o@users.noreply.github.com> Date: Fri, 7 Nov 2025 21:43:00 +0100 Subject: [PATCH] pull chatbubbles plugin support from kettu --- scripts/build.mjs | 2 +- src/lib/api/native/modules/Loader.ts | 82 ++++++++++++++++++ src/lib/api/native/modules/bubblemodule.ts | 16 ++++ src/lib/api/native/modules/index.ts | 3 + src/lib/api/native/modules/types.ts | 39 +++++++++ src/metro/internals/modules.ts | 97 ++++++++++++++++++++++ 6 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/lib/api/native/modules/Loader.ts create mode 100644 src/lib/api/native/modules/bubblemodule.ts diff --git a/scripts/build.mjs b/scripts/build.mjs index 183e0e1..2b429d0 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -77,7 +77,7 @@ const config = { constModules: { globals: { "bunny-build-info": { - version: `"1.1.10"`, + version: `"1.1.10-1"`, }, }, }, diff --git a/src/lib/api/native/modules/Loader.ts b/src/lib/api/native/modules/Loader.ts new file mode 100644 index 0000000..57e4284 --- /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); +} diff --git a/src/lib/api/native/modules/bubblemodule.ts b/src/lib/api/native/modules/bubblemodule.ts new file mode 100644 index 0000000..c6e3d85 --- /dev/null +++ b/src/lib/api/native/modules/bubblemodule.ts @@ -0,0 +1,16 @@ +import { processColor } from "react-native"; +import { getModule } from "./loader"; +import type { LoaderModule } from "./loader"; + +interface BubbleModule extends LoaderModule { + hookBubbles(): Promise; + unhookBubbles(): Promise; + configure(avatarRadius?: number, bubbleRadius?: number, bubbleColor?: string): Promise; +} + +export default getModule({ + name: "BubbleModule", + argumentProcessors: { + configure: args => [args[0], args[1], Number(processColor(args[2]))], + }, +}); diff --git a/src/lib/api/native/modules/index.ts b/src/lib/api/native/modules/index.ts index d6919cc..a8d8f65 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" +)!; diff --git a/src/lib/api/native/modules/types.ts b/src/lib/api/native/modules/types.ts index fdb3a32..a20d02e 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; + }; +} diff --git a/src/metro/internals/modules.ts b/src/metro/internals/modules.ts index f62020f..afabcf3 100644 --- a/src/metro/internals/modules.ts +++ b/src/metro/internals/modules.ts @@ -264,3 +264,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); + }); +}