From dba11af542867baaa2b272db03718f1b455c635b Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Mon, 3 Aug 2026 09:46:46 -0400 Subject: [PATCH] fix: restore compilability and add Bridgeless-mode injection hook - Fix BunnyLog compile error in Sideloading.x (leftover from Bunny->Revenge rename; only RevengeLog is defined) - Remove duplicated -loadConfig/+defaultConfig methods in LoaderConfig.m - Repoint CI's ipa-patcher download at a live mirror with a pinned checksum (Cristiandis/ipa-patcher was deleted upstream) - Add a second injection hook on RCTInstance/-_loadScriptFromSource: for React Native's Bridgeless mode, alongside the existing RCTCxxBridge hook. Discord's Android build already runs Bridgeless (confirmed via revenge-xposed's hook targets) and revenge-bundle now pins RN 0.81, which defaults to it - so the legacy bridge hook this tweak relied on was very likely dead. Unverified against a real device; see AUDIT.md. See AUDIT.md for the full iOS-vs-Android comparison and remaining gaps. --- .github/workflows/deploy.yml | 10 +++-- AUDIT.md | 84 ++++++++++++++++++++++++++++++++++++ Headers/Utils.h | 9 +++- Sources/LoaderConfig.m | 42 ------------------ Sources/Sideloading.x | 2 +- Sources/Tweak.x | 77 +++++++++++++++++++++++++++------ Sources/Utils.m | 32 ++++++++++++++ 7 files changed, 195 insertions(+), 61 deletions(-) create mode 100644 AUDIT.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ec4c4a6..f7f07b1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -136,12 +136,14 @@ jobs: - name: Run patcher run: | - curl -L -o patcher https://github.com/Cristiandis/ipa-patcher/releases/download/release-pyon/patcher.mac-amd64 + curl -L -o patcher https://github.com/bunny-mod/ipa-patcher/releases/download/release-pyon/patcher.mac-amd64 + echo "08a92b45bb00cea91c2d08d4ac48f7ef3f6bf352fe1e3aa204f953ec2db9766e patcher" > patcher.sha256 + if ! shasum -a 256 -c patcher.sha256; then + echo "SHA256 verification failed" + exit 1 + fi chmod +x patcher ./patcher -d discord.ipa -o discord.ipa -i ./ipa-icons.zip - - - name: Install cyan - run: pip install --force-reinstall https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip Pillow - name: Install cyan run: pip install --force-reinstall https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip Pillow diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..7c5b25f --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,84 @@ +# iOS parity audit (2026-08-03) + +Revenge dropped official iOS support; this repo was archived upstream in Nov 2024 +(`revenge-mod/revenge-tweak`, last commit `35ad28b`) while the Android loader +(`revenge-mod/revenge-xposed`) kept shipping. This audit compares the two and fixes +what could be fixed without a jailbroken device to test against. + +## Fixed in this fork + +- **`Sources/Sideloading.x`**: `BunnyLog(...)` referenced a macro that no longer + exists (the project was renamed Bunny → Revenge; only `RevengeLog` is defined in + `Headers/Logger.h`). This made the file **fail to compile**. Fixed by using + `RevengeLog`. +- **`Sources/LoaderConfig.m`**: `-loadConfig` and `+defaultConfig` were each defined + twice in the same `@implementation` (leftover merge conflict). Removed the + duplicates. +- **`.github/workflows/deploy.yml`**: the CI patcher step pointed at + `Cristiandis/ipa-patcher` release `release-pyon`, which 404s — that repo no longer + exists. Repointed at the still-live fork `bunny-mod/ipa-patcher` (same tag, + release build) and pinned it with a verified SHA-256 checksum. + (These three match the fixes proposed in the never-merged upstream PR #8, which + the sole maintainer confirmed worked but never landed before the repo was archived.) + +## The real blocker: this tweak's hook point is very likely dead + +`Sources/Tweak.x` hooks `RCTCxxBridge -executeApplicationScript:url:async:` — the +script-injection point for React Native's **legacy bridge**. Evidence points to +Discord having moved off that architecture entirely: + +- `revenge-bundle`'s `package.json` pins `react-native: 0.81.4`, which defaults to + the New Architecture ("Bridgeless mode"). +- `revenge-xposed` (the actively-maintained Android loader) hooks + `com.facebook.react.runtime.ReactInstance$loadJSBundle$1` — the Bridgeless-mode + class — not the legacy `CatalystInstanceImpl`, and its `DiscordDevSupport.kt` + explicitly targets `com.facebook.react.devsupport.BridgelessDevSupportManager`. + +Cross-platform RN apps built from one codebase almost always enable/disable the New +Architecture together, so if Android Discord is Bridgeless, iOS Discord almost +certainly is too — meaning `RCTCxxBridge` is likely never instantiated on current +Discord iOS builds, and the tweak's only injection point silently never fires. + +**Fix applied (unverified):** added a second hook, `%hook RCTInstance` / +`-_loadScriptFromSource:`, the Bridgeless-mode equivalent, confirmed against React +Native's actual upstream source +(`ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm`). Both hooks +now share one injection pipeline (`runRevengeInjection`, factored out of the old +single hook body); Logos skips a `%hook` target gracefully if the class isn't +present at runtime, so shipping both is safe regardless of which architecture the +installed Discord build actually uses. + +Since `RCTSource` (the type `_loadScriptFromSource:` takes) has no public +initializer, `makeRCTSource()` (`Sources/Utils.m`) builds one by poking its private +ivars directly — the same technique RN's own private `RCTSourceCreate()` uses +internally. + +**This has not been run against a real device or the current Discord iOS binary.** +It's a best-effort port grounded in verified upstream RN source and the confirmed +Android hook target, not a tested fix. Needs a jailbreak + Xcode/Theos to validate; +if `_loadScriptFromSource:` turns out to have a different selector name in Discord's +actual build (obfuscation is unlikely for framework code, but app-specific +subclasses could differ), it'll need adjusting via class-dump/Frida against the +live binary. + +## Remaining feature gap vs. Android (not attempted here) + +Beyond the injection hook, `revenge-xposed` has grown a much larger feature set +that iOS has no equivalent of. Porting these is real native engineering work, not +something to guess blind without a device to test each step against: + +| Feature | Android | iOS | +|---|---|---| +| Script injection | `RevengeScriptLoader` + Bridgeless hook | Fixed above (unverified) | +| Theme/font/sideloading | `themes`/`fonts`/`sysColors`, resource-ID fix | `Theme.m`/`Fonts.x`/`Sideloading.x` (present, untouched) | +| **Native↔JS RPC bridge** | `RevengeBridge` — bidirectional calls smuggled through existing exposed RN native modules (`RNSVGRenderableManager.getBBox`, `FileReaderModule.readAsDataURL`) | **none** | +| **Plugin system** | External plugin loading + repos (`PluginLoader`, `RepoStore`, `RepoInstall`, `Resolver`, ...) | **none** | +| Internal plugins | Recovery mode, no-track, OTA-update prevention, example plugin | **none** | +| Dev support | LogBox + shake-to-recovery wired into RN's dev menu | **none** | +| Caches/lifecycle management | `Caches.kt`, `LifecycleSupport.kt` | **none** | + +The bridge + plugin system is the biggest gap and the most architecturally novel +part of the Android side — it's what everything else (internal plugins, dev +support) is built on. Recommend treating it as its own follow-up project once the +injection hook above is confirmed working on-device, rather than attempting it +speculatively. diff --git a/Headers/Utils.h b/Headers/Utils.h index 0b1fb2f..0946ad2 100644 --- a/Headers/Utils.h +++ b/Headers/Utils.h @@ -3,4 +3,11 @@ NSURL *getPyoncordDirectory(void); UIColor *hexToUIColor(NSString *hex); -void showErrorAlert(NSString *title, NSString *message); \ No newline at end of file +void showErrorAlert(NSString *title, NSString *message); + +/** + * Builds an `RCTSource` (react/runtime's bridgeless script container) by poking its private + * ivars directly, since React Native doesn't expose a public initializer for it. + * Returns nil if the `RCTSource` class isn't present in the running binary. + */ +id makeRCTSource(NSURL *url, NSData *data); \ No newline at end of file diff --git a/Sources/LoaderConfig.m b/Sources/LoaderConfig.m index 40ce65b..629887d 100644 --- a/Sources/LoaderConfig.m +++ b/Sources/LoaderConfig.m @@ -55,48 +55,6 @@ return config; } -- (BOOL)loadConfig { - NSURL *loaderConfigUrl = [getPyoncordDirectory() URLByAppendingPathComponent:@"loader.json"]; - RevengeLog(@"Attempting to load config from: %@", loaderConfigUrl.path); - - if ([[NSFileManager defaultManager] fileExistsAtPath:loaderConfigUrl.path]) { - NSError *error = nil; - NSData *data = [NSData dataWithContentsOfURL:loaderConfigUrl]; - NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; - - if (error) { - RevengeLog(@"Error parsing loader config: %@", error); - return NO; - } - - if (json) { - NSDictionary *customLoadUrl = json[@"customLoadUrl"]; - if (customLoadUrl) { - self.customLoadUrlEnabled = [customLoadUrl[@"enabled"] boolValue]; - NSString *urlString = customLoadUrl[@"url"]; - if (urlString) { - self.customLoadUrl = [NSURL URLWithString:urlString]; - } - } - - RevengeLog(@"Loader config loaded - Custom URL %@: %@", - self.customLoadUrlEnabled ? @"enabled" : @"disabled", - self.customLoadUrl.absoluteString); - return YES; - } - } - - RevengeLog(@"Using default loader config: %@", self.customLoadUrl.absoluteString); - return NO; -} - -+ (instancetype)defaultConfig { - LoaderConfig *config = [[LoaderConfig alloc] init]; - config.customLoadUrlEnabled = NO; - config.customLoadUrl = [NSURL URLWithString:@"http://localhost:4040/revenge.js"]; - return config; -} - + (instancetype)getLoaderConfig { RevengeLog(@"Getting loader config"); diff --git a/Sources/Sideloading.x b/Sources/Sideloading.x index 9d08c24..8383cea 100644 --- a/Sources/Sideloading.x +++ b/Sources/Sideloading.x @@ -66,7 +66,7 @@ static BOOL isSelfCall(void) { %hook NSFileManager - (NSURL *)containerURLForSecurityApplicationGroupIdentifier:(NSString *)groupIdentifier { - BunnyLog(@"containerURLForSecurityApplicationGroupIdentifier called! %@", groupIdentifier ?: @"nil"); + RevengeLog(@"containerURLForSecurityApplicationGroupIdentifier called! %@", groupIdentifier ?: @"nil"); NSArray *paths = [self URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask]; NSURL *lastPath = [paths lastObject]; diff --git a/Sources/Tweak.x b/Sources/Tweak.x index ca25390..8a7eb5c 100644 --- a/Sources/Tweak.x +++ b/Sources/Tweak.x @@ -12,30 +12,33 @@ static NSString *revengePatchesBundlePath; static NSURL *pyoncordDirectory; static LoaderConfig *loaderConfig; -%hook RCTCxxBridge - -- (void)executeApplicationScript:(NSData *)script url:(NSURL *)url async:(BOOL)async { - if (![url.absoluteString containsString:@"main.jsbundle"]) { - return %orig; - } - +/** + * Runs the full Revenge injection sequence (loader payload, downloaded bundle, stored theme, + * fonts, preloads) and hands each script off to `execute`, then finally executes the host's + * own `script`/`url`/`async` once everything else has run. + * + * Factored out so it can be driven by either the legacy-bridge hook (RCTCxxBridge, used when + * the host app runs React Native's old bridge) or the bridgeless-mode hook (RCTInstance, used + * under React Native's New Architecture) below - both eventually funnel through here. + */ +static BOOL runRevengeInjection(NSData *script, NSURL *url, BOOL async, void (^execute)(NSData *data, NSURL *url, BOOL async)) { NSBundle *revengePatchesBundle = [NSBundle bundleWithPath:revengePatchesBundlePath]; if (!revengePatchesBundle) { RevengeLog(@"Failed to load RevengePatches bundle from path: %@", revengePatchesBundlePath); showErrorAlert(@"Loader Error", @"Failed to initialize mod loader. Please reinstall the tweak."); - return %orig; + return NO; } NSURL *patchPath = [revengePatchesBundle URLForResource:@"payload-base" withExtension:@"js"]; if (!patchPath) { RevengeLog(@"Failed to find payload-base.js in bundle"); showErrorAlert(@"Loader Error", @"Failed to initialize mod loader. Please reinstall the tweak."); - return %orig; + return NO; } NSData *patchData = [NSData dataWithContentsOfURL:patchPath]; RevengeLog(@"Injecting loader"); - %orig(patchData, source, YES); + execute(patchData, source, YES); __block NSData *bundle = [NSData dataWithContentsOfURL:[pyoncordDirectory URLByAppendingPathComponent:@"bundle.js"]]; @@ -89,7 +92,7 @@ static LoaderConfig *loaderConfig; error:nil]; if (themeString) { NSString *jsCode = [NSString stringWithFormat:@"globalThis.__PYON_LOADER__.storedTheme=%@", themeString]; - %orig([jsCode dataUsingEncoding:NSUTF8StringEncoding], source, async); + execute([jsCode dataUsingEncoding:NSUTF8StringEncoding], source, async); } NSData *fontData = [NSData dataWithContentsOfURL:[pyoncordDirectory URLByAppendingPathComponent:@"fonts.json"]]; @@ -104,7 +107,7 @@ static LoaderConfig *loaderConfig; if (bundle) { RevengeLog(@"Executing JS bundle"); - %orig(bundle, source, async); + execute(bundle, source, async); } NSURL *preloadsDirectory = [pyoncordDirectory URLByAppendingPathComponent:@"preloads"]; @@ -120,7 +123,7 @@ static LoaderConfig *loaderConfig; RevengeLog(@"Executing preload JS file %@", fileURL.absoluteString); NSData *data = [NSData dataWithContentsOfURL:fileURL]; if (data) { - %orig(data, source, async); + execute(data, source, async); } } } @@ -129,11 +132,59 @@ static LoaderConfig *loaderConfig; } } + return YES; +} + +%hook RCTCxxBridge + +// Legacy-bridge entry point. Fires when the host app runs React Native's old (non-Bridgeless) bridge. +- (void)executeApplicationScript:(NSData *)script url:(NSURL *)url async:(BOOL)async { + if (![url.absoluteString containsString:@"main.jsbundle"]) { + return %orig; + } + + BOOL injected = runRevengeInjection(script, url, async, ^(NSData *data, NSURL *scriptUrl, BOOL scriptAsync) { + %orig(data, scriptUrl, scriptAsync); + }); + if (!injected) return %orig; + %orig(script, url, async); } %end +%hook RCTInstance + +/** + * Bridgeless-mode (React Native's New Architecture) entry point. Discord's Android build moved + * to this architecture (see RevengeXposed's `com.facebook.react.runtime.ReactInstance$loadJSBundle$1` + * and `BridgelessDevSupportManager` hook targets), and the JS bundle now pins react-native 0.81, + * which defaults to it too - so this is likely the entry point that actually fires on current + * Discord iOS builds, not `RCTCxxBridge` above. + * + * `RCTInstance` has no public initializer for `RCTSource`, so `makeRCTSource` pokes its private + * ivars directly to build synthetic sources for the loader payload/bundle/theme/preload scripts. + * UNVERIFIED against a real device/current Discord binary - test before shipping. + */ +- (void)_loadScriptFromSource:(id)source { + NSURL *url = [source valueForKey:@"url"]; + NSData *script = [source valueForKey:@"data"]; + + if (![url.absoluteString containsString:@"main.jsbundle"]) { + return %orig; + } + + BOOL injected = runRevengeInjection(script, url, YES, ^(NSData *data, NSURL *scriptUrl, BOOL scriptAsync) { + id syntheticSource = makeRCTSource(scriptUrl, data); + if (syntheticSource) %orig(syntheticSource); + }); + if (!injected) return %orig; + + %orig(source); +} + +%end + %ctor { @autoreleasepool { source = [NSURL URLWithString:@"revenge"]; diff --git a/Sources/Utils.m b/Sources/Utils.m index e370a54..f24c2b7 100644 --- a/Sources/Utils.m +++ b/Sources/Utils.m @@ -1,4 +1,36 @@ #import "Utils.h" +#import + +id makeRCTSource(NSURL *url, NSData *data) { + Class RCTSourceClass = NSClassFromString(@"RCTSource"); + if (!RCTSourceClass) return nil; + + id source = [RCTSourceClass new]; + + // RCTSource has no public initializer (see RCTJavaScriptLoader.mm's private + // RCTSourceCreate()), so its private ivars are poked directly instead. + Ivar urlIvar = class_getInstanceVariable(RCTSourceClass, "_url"); + Ivar dataIvar = class_getInstanceVariable(RCTSourceClass, "_data"); + Ivar lengthIvar = class_getInstanceVariable(RCTSourceClass, "_length"); + Ivar filesChangedCountIvar = class_getInstanceVariable(RCTSourceClass, "_filesChangedCount"); + + if (urlIvar) object_setIvar(source, urlIvar, url); + if (dataIvar) object_setIvar(source, dataIvar, data); + + // _length and _filesChangedCount are scalars (NSUInteger / NSInteger), not objects, + // so they need direct memory writes at the ivar's offset instead of object_setIvar. + if (lengthIvar) { + ptrdiff_t offset = ivar_getOffset(lengthIvar); + *(NSUInteger *)((uint8_t *)(__bridge void *)source + offset) = data.length; + } + if (filesChangedCountIvar) { + ptrdiff_t offset = ivar_getOffset(filesChangedCountIvar); + // -2 == RCTSourceFilesChangedCountNotBuiltByBundler, matching sources not built by Metro. + *(NSInteger *)((uint8_t *)(__bridge void *)source + offset) = -2; + } + + return source; +} NSURL *getPyoncordDirectory(void) { NSFileManager *fileManager = [NSFileManager defaultManager];