From fef67515352543494230f886f0ad3db60a45891f Mon Sep 17 00:00:00 2001 From: cocobo1 Date: Fri, 7 Nov 2025 22:12:36 +0200 Subject: [PATCH] modules: rudimentry bubblechat support --- src/index.ts | 2 - src/lib/api/native/modules/bubblemodule.ts | 16 ++++ src/lib/api/native/modules/index.ts | 3 + src/lib/api/native/modules/loader.ts | 82 ++++++++++++++++++ src/lib/api/native/modules/types.ts | 39 +++++++++ src/metro/internals/modules.ts | 97 ++++++++++++++++++++++ 6 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 src/lib/api/native/modules/bubblemodule.ts create mode 100644 src/lib/api/native/modules/loader.ts diff --git a/src/index.ts b/src/index.ts index 341b32f..ec767b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,8 +19,6 @@ import * as lib from "./lib"; export default async () => { // Load everything in parallel - updatePlugins(); - await Promise.all([ initThemes(), injectFluxInterceptor(), diff --git a/src/lib/api/native/modules/bubblemodule.ts b/src/lib/api/native/modules/bubblemodule.ts new file mode 100644 index 0000000..8b84133 --- /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]))], + }, +}); \ 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/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); + }); +}