Compare commits

..

45 commits
v237.0 ... main

Author SHA1 Message Date
Rosie Val
c452e52809
Merge pull request #1 from everestmcarthur/ios-parity-audit
Ios parity audit
2026-08-03 10:14:17 -04:00
0044c2c12a fix: replace speculative Bridgeless hook with RainTweak's verified approach
The RCTInstance/-_loadScriptFromSource: + fake-RCTSource hook from the
previous commit was a plausible guess from RN's public source, but
codeberg.org/raincord/RainTweak - an actively-shipping loader for a sibling
Revenge/Bunny fork - proves both the Bridgeless diagnosis correct (its %ctor
refuses to run below Discord v332, i.e. pre-Bridgeless) and shows a cleaner,
verified mechanism: hook RCTHost/-instance:didInitializeRuntime:, let
Discord's own bundle load untouched via %orig, then evaluate Revenge's
scripts directly against the live JSI runtime instead of faking another
bundle load.

- Add Headers/JSI.h + Sources/JSI.mm (ported from RainTweak's JSI.mm):
  evaluates NSData as JS or Hermes bytecode directly against a
  facebook::jsi::Runtime.
- Add Headers/RCTHost.h declaring the hook target's signature (RN ships no
  public header for this internal class).
- Vendor Headers/jsi/{jsi.h,jsi-inl.h,instrumentation.h} - Meta's MIT-licensed
  JSI headers, which Theos doesn't provide; RainTweak vendors the same files.
- Rewrite Tweak.x -> Tweak.xm (needs C++ for jsi::Runtime&): runRevengeInjection
  now takes a plain data/async execute callback, driven by either the new
  RCTHost+JSI hook or the legacy RCTCxxBridge hook (kept as a no-cost
  fallback), guarded by a didInject flag so only one path actually injects.
- Makefile: build .xm/.mm sources, add -std=c++17 CCFLAGS matching RainTweak.

Still unverified against a real device, but the mechanism itself is no longer
a guess - it's the one running in a comparable, currently-shipping mod.
2026-08-03 10:10:32 -04:00
dba11af542 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.
2026-08-03 09:46:46 -04:00
Palm
35ad28b910
fix: loader config and rebase upstream (#7) 2024-11-16 03:38:54 +07:00
Cristiandis
bf11700709
bump version 2024-11-15 18:50:13 +01:00
pylix
501c161bbe Merge pull request #22 from castdrian/fix-loader-config
fix: loader config oversight
2024-11-15 18:42:04 +01:00
Adrian Castro
8c3447958a fix: git moment 2024-11-15 18:36:17 +01:00
pylix
3bdbb33efe Merge pull request #21 from castdrian/objc-rewrite
feat: improved sideloading hooks
2024-11-15 18:36:17 +01:00
Adrian Castro
3d86fe0f75 fix: fix loader config 2024-11-15 18:36:17 +01:00
Adrian Castro
e5fdfbf00c chore: set gnu make path 2024-11-15 18:34:59 +01:00
Adrian Castro
1c606bfc11 feat: improved sideloading hooks 2024-11-15 18:34:59 +01:00
github-actions
24d61d8dad chore: update app-repo.json 2024-11-15 13:43:38 +00:00
Palm
eeb12659af
chore: merge from upstream (#6)
Co-authored-by: pylix <82711525+pylixonly@users.noreply.github.com>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Adrian Castro <22133246+castdrian@users.noreply.github.com>
2024-11-15 20:36:10 +07:00
Cristiandis
ecfe3647cb
change ipa-patcher to revenge 2024-11-15 12:02:20 +01:00
Cristiandis
3f5723ea20 Change bunny to revenge 2024-11-15 11:45:12 +01:00
Cristiandis
051816832e fix conflicts 2024-11-15 11:37:57 +01:00
Cristiandis
0a9bfdbce3 update bunny.plist and makefile 2024-11-15 11:17:44 +01:00
Cristiandis
0effa1ae25 rebase from upstream 2024-11-15 09:47:28 +01:00
pylix
0ddeccfcfe
Merge pull request #20 from castdrian/objc-rewrite
refactor: rewrite to objc and logos
2024-11-15 14:14:13 +08:00
Adrian Castro
bdf3792be9
feat: finish font impl 2024-11-14 22:50:08 +01:00
Adrian Castro
c568969333
feat: rewrite from orion to logos 2024-11-14 19:33:19 +01:00
pylixonly
a71b1b9a7d fix(ci): properly check release condition
https://github.com/actions/runner/issues/1483
2024-11-09 16:29:20 +08:00
pylixonly
0b929c9bf0 refactor: merge workflow scripts 2024-11-09 15:21:00 +08:00
pylixonly
82e3c078dd chore: bump ver to 0.3.2
for testing
2024-11-09 14:50:47 +08:00
pylix
d177bc57fb chore: meddling with patcher 2024-11-09 14:50:47 +08:00
github-actions
2528e53866 chore: update app-repo.json 2024-10-20 18:10:10 +00:00
Cristiandis
f613fd80a1
fix: update bundle link and add branding changes (#4)
Co-authored-by: Palm <git@palmdevs.me>
2024-10-21 01:01:26 +07:00
github-actions
e3f70ea75f chore: update app-repo.json 2024-10-17 11:37:34 +00:00
github-actions
586861569e chore: update app-repo.json 2024-09-20 15:27:22 +00:00
pylix
7c1e53ab1d feat: add some flags for pyzule 2024-09-20 15:09:05 +00:00
pylix
c0889328cc Revert "feat: swap pyzule for cyan
"

This reverts commit b0793740f5.
2024-09-20 14:29:40 +00:00
github-actions
8eec8a761c chore: update app-repo.json 2024-09-18 00:07:17 +00:00
pylix
b0793740f5
feat: swap pyzule for cyan
and pass some additional flags
2024-09-18 07:51:38 +08:00
github-actions
cd84e583e0 chore: update app-repo.json 2024-09-15 21:22:50 +00:00
cool
a7c95b6d8d
ci: swap pyzule for cyan (#3) 2024-09-16 00:18:04 +03:00
Cristiandis
74e74200ee
ci: implement fix for altstore and trollstore for release.yml 2024-09-15 21:33:39 +02:00
Cristiandis
d5f4085afd
ci: implement fix for altstore and trollstore on build.yml 2024-09-15 21:33:08 +02:00
Cristiandis
04e4543afa
ci: swap pyzule for cyan in release.yml too 2024-09-15 21:11:56 +02:00
Cristiandis
b3c598c219
ci: swap pyzule for cyan 2024-09-15 21:01:52 +02:00
Cristiandis
0c69adf534
docs: Fix image links for buttons (#2) 2024-09-15 00:37:17 +02:00
Palm
908949ad3a
fix: remove duplicate version object 2024-09-15 04:23:18 +07:00
github-actions
ddece2b047 chore: update app-repo.json 2024-09-14 21:17:47 +00:00
Cristiandis
d21801c05e
feat: change to support Revenge
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Palm <git@palmdevs.me>
2024-09-15 04:09:09 +07:00
github-actions
c6e9b31037 chore: update app-repo.json 2024-09-14 16:40:38 +00:00
github-actions
88fc495600 chore: update app-repo.json 2024-07-13 08:09:33 +00:00
43 changed files with 3548 additions and 779 deletions

View file

@ -1,135 +0,0 @@
name: Build
on:
workflow_dispatch:
inputs:
ipa_url:
description: "Direct link to the decrypted IPA"
default: ""
required: true
type: string
env:
GH_TOKEN: ${{ github.token }}
jobs:
build-tweak:
runs-on: macos-14
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: true
- name: Download Tweak
run: |
set +e
release_info=$(gh api --header 'Accept: application/vnd.github+json' repos/${{ github.repository }}/releases/latest)
status_code=$(echo $release_info | jq -r ".status")
if [ "$status_code" != "null" ]; then
echo "No releases found or request failed, status code: $status_code"
echo "DEB_DOWNLOADED=false" >> $GITHUB_ENV
exit 0
fi
set -e
release_version=$(echo "$release_info" | jq -r '.assets[] | select(.name | contains("iphoneos-arm.deb")) | .name' | grep -o '_[0-9.]\+_' | tr -d '_')
control_version=$(grep '^Version:' control | cut -d ' ' -f 2)
if [ "$release_version" = "$control_version" ]; then
echo "Versions match. Downloading DEB files..."
echo "$release_info" | jq -r '.assets[] | select(.name | endswith(".deb")) | .browser_download_url' | xargs -I {} curl -L -O {}
echo "DEB_DOWNLOADED=true" >> $GITHUB_ENV
else
echo "Versions do not match. No files will be downloaded."
echo "DEB_DOWNLOADED=false" >> $GITHUB_ENV
exit 0
fi
- name: Check cache
if: env.DEB_DOWNLOADED == 'false'
run: echo upstream_heads=`git ls-remote https://github.com/theos/theos | head -n 1 | cut -f 1`-`git ls-remote https://github.com/theos/sdks | head -n 1 | cut -f 1` >> $GITHUB_ENV
- name: Use cache
if: env.DEB_DOWNLOADED == 'false'
id: cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/theos
key: ${{ runner.os }}-${{ env.upstream_heads }}
- name: Prepare Theos
if: env.DEB_DOWNLOADED == 'false'
uses: Randomblock1/theos-action@v1
- name: Build packages
if: env.DEB_DOWNLOADED == 'false'
run: make package FINALPACKAGE=1 && make package FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
- name: Set DEB path
run: |
if [ "${{ env.DEB_DOWNLOADED }}" == "true" ]; then
echo "DEB_PATH=${{ github.workspace }}/*-arm.deb" >> $GITHUB_ENV
echo "ROOTLESS_DEB_PATH=${{ github.workspace }}/*-arm64.deb" >> $GITHUB_ENV
else
echo "DEB_PATH=${{ github.workspace }}/packages/*-arm.deb" >> $GITHUB_ENV
echo "ROOTLESS_DEB_PATH=${{ github.workspace }}/packages/*-arm64.deb" >> $GITHUB_ENV
fi
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: package
path: ${{ env.DEB_PATH }}
- name: Upload rootless package
uses: actions/upload-artifact@v4
with:
name: rootless package
path: ${{ env.ROOTLESS_DEB_PATH }}
build-ipa:
runs-on: macos-14
needs: build-tweak
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
- name: Download Discord IPA
run: |
curl -L -o discord.ipa ${{ github.event.inputs.ipa_url }}
- name: Download IPA icons
run: |
curl -L -o ipa-icons.zip https://raw.githubusercontent.com/pyoncord/assets/main/ipa-icons.zip
unzip ipa-icons.zip
- name: Extract Values
run: |
NAME=$(grep '^Name:' control | cut -d ' ' -f 2)
PACKAGE=$(grep '^Package:' control | cut -d ' ' -f 2)
VERSION=$(grep '^Version:' control | cut -d ' ' -f 2)
DEB_FILE_NAME="${PACKAGE}_${VERSION}_iphoneos-arm.deb"
echo "DEB_FILE_NAME=$DEB_FILE_NAME" >> $GITHUB_ENV
echo "APP_NAME=$NAME" >> $GITHUB_ENV
- name: Inject tweak
run: |
bash -c "$(curl https://raw.githubusercontent.com/asdfzxcvbn/pyzule/main/install-pyzule.sh)"
pyzule -n ${{ env.APP_NAME }} -k PyoncordIcon60x60@2x.png -d -i discord.ipa -o ${{ github.workspace }}/${{ env.APP_NAME }}.ipa -f ${{ github.workspace }}/${{ env.DEB_FILE_NAME }}
- name: Upload ipa as artifact
uses: actions/upload-artifact@v4
with:
name: ipa
path: ${{ github.workspace }}/${{ env.APP_NAME }}.ipa

View file

@ -1,4 +1,5 @@
name: Release name: Deploy
run-name: ${{ github.event.inputs.release == 'true' && 'Release' || 'Build' }} for ${{ github.event.inputs.ipa_url }}
on: on:
workflow_dispatch: workflow_dispatch:
@ -8,10 +9,15 @@ on:
default: "" default: ""
required: true required: true
type: string type: string
release:
description: 'Release the build'
required: true
type: boolean
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
jobs: jobs:
build-tweak: build-tweak:
runs-on: macos-14 runs-on: macos-14
@ -66,6 +72,10 @@ jobs:
if: env.DEB_DOWNLOADED == 'false' if: env.DEB_DOWNLOADED == 'false'
uses: Randomblock1/theos-action@v1 uses: Randomblock1/theos-action@v1
- name: Set GNU Make path
run: |
echo "PATH=$(brew --prefix make)/libexec/gnubin:$PATH" >> $GITHUB_ENV
- name: Build packages - name: Build packages
if: env.DEB_DOWNLOADED == 'false' if: env.DEB_DOWNLOADED == 'false'
run: make package FINALPACKAGE=1 && make package FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless run: make package FINALPACKAGE=1 && make package FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
@ -112,7 +122,6 @@ jobs:
- name: Download IPA icons - name: Download IPA icons
run: | run: |
curl -L -o ipa-icons.zip https://raw.githubusercontent.com/pyoncord/assets/main/ipa-icons.zip curl -L -o ipa-icons.zip https://raw.githubusercontent.com/pyoncord/assets/main/ipa-icons.zip
unzip ipa-icons.zip
- name: Extract Values - name: Extract Values
run: | run: |
@ -125,10 +134,23 @@ jobs:
echo "ROOTLESS_DEB_FILE_NAME=$ROOTLESS_DEB_FILE_NAME" >> $GITHUB_ENV echo "ROOTLESS_DEB_FILE_NAME=$ROOTLESS_DEB_FILE_NAME" >> $GITHUB_ENV
echo "APP_NAME=$NAME" >> $GITHUB_ENV echo "APP_NAME=$NAME" >> $GITHUB_ENV
- name: Run patcher
run: |
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: Inject tweak - name: Inject tweak
run: | run: |
bash -c "$(curl https://raw.githubusercontent.com/asdfzxcvbn/pyzule/main/install-pyzule.sh)" cyan -duws -i discord.ipa -o ${{ github.workspace }}/${{ env.APP_NAME }}.ipa -f ${{ github.workspace }}/${{ env.DEB_FILE_NAME }}
pyzule -n ${{ env.APP_NAME }} -k PyoncordIcon60x60@2x.png -d -i discord.ipa -o ${{ github.workspace }}/${{ env.APP_NAME }}.ipa -f ${{ github.workspace }}/${{ env.DEB_FILE_NAME }}
- name: Upload ipa as artifact - name: Upload ipa as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -137,6 +159,7 @@ jobs:
path: ${{ github.workspace }}/${{ env.APP_NAME }}.ipa path: ${{ github.workspace }}/${{ env.APP_NAME }}.ipa
release-app: release-app:
if: ${{ github.event.inputs.release == 'true' }}
runs-on: macos-14 runs-on: macos-14
needs: build-ipa needs: build-ipa
permissions: permissions:
@ -183,6 +206,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
app-repo: app-repo:
if: ${{ github.event.inputs.release == 'true' }}
continue-on-error: true continue-on-error: true
runs-on: macos-14 runs-on: macos-14
needs: release-app needs: release-app

View file

@ -3,5 +3,12 @@
"MD029": false, "MD029": false,
"MD033": false, "MD033": false,
"MD045": false, "MD045": false,
} },
"VsCodeTaskButtons.tasks": [
{
"label": "$(tools) Make and Copy",
"task": "Make and Copy Tweak",
"tooltip": "Make and copy Tweak"
}
],
} }

35
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,35 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "shell",
"label": "Make and Copy Tweak",
"command": "zsh",
"args": [
"-c",
"rm -rf packages && make clean && make package FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless && cp ./packages/app.pyoncord_*arm64.deb ~/Library/Mobile\\ Documents/com~apple~CloudDocs/"
],
"problemMatcher": {
"owner": "cpp",
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(warning|error):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
}
},
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"panel": "shared",
"showReuseMessage": false,
"clear": true,
"close": true
}
}
]
}

101
AUDIT.md Normal file
View file

@ -0,0 +1,101 @@
# 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.
**This is now confirmed, not just theorized.** [RainTweak](https://codeberg.org/raincord/RainTweak)
is the iOS loader for `raincord/rain`, a sibling Revenge/Bunny-derived mod that is
*actively shipping* (last commit 2026-07-17, vs. this repo's Nov 2024). Its `%ctor`
explicitly checks `RCTNewArchEnabled` from Info.plist and refuses to run with
"Please update Discord to v332 or above" if it's off — i.e. current Discord iOS is
Bridgeless-only, exactly as the Android-side evidence predicted, and RainTweak has
real users validating this on-device.
**Fix applied**, ported from RainTweak's verified mechanism (better than the
`RCTInstance -_loadScriptFromSource:` + fake-`RCTSource` approach this fork
originally tried — see below for why that was replaced):
- `%hook RCTHost` / `-instance:didInitializeRuntime:` (`Headers/RCTHost.h`,
`Sources/Tweak.xm`) — fires once the Bridgeless JS runtime is ready. Discord's own
bundle is left completely untouched via `%orig` first; Revenge's own scripts
(loader payload, downloaded bundle, theme, preloads) are then run directly against
the live `facebook::jsi::Runtime` via `[JSI evaluate:tag:runtime:]`
(`Headers/JSI.h` / `Sources/JSI.mm`, ported near-verbatim from RainTweak's
`JSI.mm`) instead of being smuggled in as fake bundle loads.
- This sidesteps the problem the first attempt ran into: `RCTSource` (what
`-_loadScriptFromSource:` takes) has no public initializer, so faking one meant
poking private ivars — fragile and, per RainTweak's actual production code, not
even the path they use. Evaluating directly against the runtime via JSI is both
simpler and how the proven-working reference implementation does it.
- The legacy `RCTCxxBridge -executeApplicationScript:...` hook is kept as a
no-cost fallback for any host still on the old bridge (Logos skips hooking
classes that aren't present at runtime, and a `didInject` guard stops both hooks
from double-firing if they somehow both resolve).
- `Headers/jsi/{jsi.h,jsi-inl.h,instrumentation.h}` are vendored copies of Meta's
JSI headers (MIT-licensed, same as RN itself) — Theos doesn't ship them, and
RainTweak vendors the identical files for the same reason.
**Still unverified against a real device or the current Discord binary** — I don't
have a jailbreak or Xcode/Theos here. But unlike the first attempt, this is no
longer a guess extrapolated from RN's public source: the mechanism (`RCTHost`
hook + JSI evaluation) is the exact one running in a comparable, currently-shipping
mod. Confidence is meaningfully higher; it should still be tested before relying on
it, since Discord's specific build could in principle differ.
## 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.

5
Headers/Fonts.h Normal file
View file

@ -0,0 +1,5 @@
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
extern NSMutableDictionary<NSString *, NSString *> *fontMap;
void patchFonts(NSDictionary<NSString *, NSString *> *mainFonts, NSString *fontDefName);

16
Headers/JSI.h Normal file
View file

@ -0,0 +1,16 @@
#pragma once
#import <Foundation/Foundation.h>
#import <jsi/jsi.h>
/**
* Minimal JSI helper: evaluates a script directly against a live `facebook::jsi::Runtime`,
* instead of re-entering React Native's own bundle-loading machinery. Ported from RainTweak
* (codeberg.org/raincord/RainTweak), an actively-maintained loader for a sibling Revenge/Bunny
* fork that verified this approach works on current (Bridgeless) Discord iOS builds.
*/
@interface JSI : NSObject
+ (void)evaluate:(NSData *)scriptData tag:(NSString *)tag runtime:(facebook::jsi::Runtime &)runtime;
@end

10
Headers/LoaderConfig.h Normal file
View file

@ -0,0 +1,10 @@
#import <Foundation/Foundation.h>
@interface LoaderConfig : NSObject
@property (nonatomic, assign) BOOL customLoadUrlEnabled;
@property (nonatomic, strong) NSURL *customLoadUrl;
+ (instancetype)defaultConfig;
+ (instancetype)getLoaderConfig;
- (instancetype)init;
- (BOOL)loadConfig;
@end

4
Headers/Logger.h Normal file
View file

@ -0,0 +1,4 @@
#import <Foundation/Foundation.h>
#define LOG_PREFIX @"[Revenge]"
#define RevengeLog(fmt, ...) NSLog((LOG_PREFIX @" " fmt), ##__VA_ARGS__)

View file

@ -1,7 +1,3 @@
@import Foundation;
@interface RCTCxxBridge : NSObject @interface RCTCxxBridge : NSObject
- (void)executeApplicationScript:(NSData *)script url:(NSURL *)url async:(BOOL)async; - (void)executeApplicationScript:(NSData *)script url:(NSURL *)url async:(BOOL)async;
@end @end

15
Headers/RCTHost.h Normal file
View file

@ -0,0 +1,15 @@
#import <Foundation/Foundation.h>
#import <jsi/jsi.h>
/**
* Forward declaration of React Native's Bridgeless-mode host class, just enough of it for Logos
* to hook `-instance:didInitializeRuntime:`. React Native ships no public header exposing this
* (it's an internal `ReactCommon/react/runtime` class), so the signature is declared by hand here -
* matches `RCTHost.h` in RainTweak (codeberg.org/raincord/RainTweak), which hooks the same method
* on a currently-shipping Discord iOS build.
*/
@interface RCTHost : NSObject
- (void)instance:(id)instance didInitializeRuntime:(facebook::jsi::Runtime &)runtime;
@end

5
Headers/Theme.h Normal file
View file

@ -0,0 +1,5 @@
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
void swizzleDCDThemeColor(NSDictionary<NSString *, NSArray<NSString *> *> *semanticColors);
void swizzleUIColor(NSDictionary<NSString *, NSString *> *rawColors);

9
Headers/Utils.h Normal file
View file

@ -0,0 +1,9 @@
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NSURL *getPyoncordDirectory(void);
UIColor *hexToUIColor(NSString *hex);
void showErrorAlert(NSString *title, NSString *message);
/** Detects the Hermes bytecode magic header ("Herm"), as opposed to plain-text JS. */
BOOL isHermesBytecode(NSData *data);

View file

@ -0,0 +1,132 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#pragma once
#include <chrono>
#include <iosfwd>
#include <string>
#include <tuple>
#include <unordered_map>
#include <jsi/jsi.h>
namespace facebook {
namespace jsi {
/// Methods for starting and collecting instrumentation, an \c Instrumentation
/// instance is associated with a particular \c Runtime instance, which it
/// controls the instrumentation of.
/// None of these functions should return newly created jsi values, nor should
/// it modify the values of any jsi values in the heap (although GCs are fine).
class JSI_EXPORT Instrumentation {
public:
/// Additional options controlling what to include when capturing a heap
/// snapshot.
struct HeapSnapshotOptions {
bool captureNumericValue{false};
};
virtual ~Instrumentation() = default;
/// Returns GC statistics as a JSON-encoded string, with an object containing
/// "type" and "version" fields outermost. "type" is a string, unique to a
/// particular implementation of \c jsi::Instrumentation, and "version" is a
/// number to indicate any revision to that implementation and its output
/// format.
///
/// \pre This call can only be made on the instrumentation instance of a
/// runtime initialised to collect GC statistics.
///
/// \post All cumulative measurements mentioned in the output are accumulated
/// across the entire lifetime of the Runtime.
///
/// \return the GC statistics collected so far, as a JSON-encoded string.
virtual std::string getRecordedGCStats() = 0;
/// Request statistics about the current state of the runtime's heap. This
/// function can be called at any time, and should produce information that is
/// correct at the instant it is called (i.e, not stale).
///
/// \return a map from a string key to a number associated with that
/// statistic.
virtual std::unordered_map<std::string, int64_t> getHeapInfo(
bool includeExpensive) = 0;
/// Perform a full garbage collection.
/// \param cause The cause of this collection, as it should be reported in
/// logs.
virtual void collectGarbage(std::string cause) = 0;
/// A HeapStatsUpdate is a tuple of the fragment index, the number of objects
/// in that fragment, and the number of bytes used by those objects.
/// A "fragment" is a view of all objects allocated within a time slice.
using HeapStatsUpdate = std::tuple<uint64_t, uint64_t, uint64_t>;
/// Start capturing JS stack-traces for all JS heap allocated objects. These
/// can be accessed via \c ::createSnapshotToFile().
/// \param fragmentCallback If present, invoke this callback every so often
/// with the most recently seen object ID, and a list of fragments that have
/// been updated. This callback will be invoked on the same thread that the
/// runtime is using.
virtual void startTrackingHeapObjectStackTraces(
std::function<void(
uint64_t lastSeenObjectID,
std::chrono::microseconds timestamp,
std::vector<HeapStatsUpdate> stats)> fragmentCallback) = 0;
/// Stop capture JS stack-traces for JS heap allocated objects.
virtual void stopTrackingHeapObjectStackTraces() = 0;
/// Start a heap sampling profiler that will sample heap allocations, and the
/// stack trace they were allocated at. Reports a summary of which functions
/// allocated the most.
/// \param samplingInterval The number of bytes allocated to wait between
/// samples. This will be used as the expected value of a poisson
/// distribution.
virtual void startHeapSampling(size_t samplingInterval) = 0;
/// Turns off the heap sampling profiler previously enabled via
/// \c startHeapSampling. Writes the output of the sampling heap profiler to
/// \p os. The output is a JSON formatted string.
virtual void stopHeapSampling(std::ostream& os) = 0;
/// Captures the heap to a file
///
/// \param path to save the heap capture.
/// \param options additional options for what to capture.
virtual void createSnapshotToFile(
const std::string& path,
const HeapSnapshotOptions& options = {false}) = 0;
/// Captures the heap to an output stream
///
/// \param os output stream to write to.
/// \param options additional options for what to capture.
virtual void createSnapshotToStream(
std::ostream& os,
const HeapSnapshotOptions& options = {false}) = 0;
/// If the runtime has been created to trace to a temp file, flush
/// any unwritten parts of the trace of bridge traffic to the file,
/// and return the name of the file. Otherwise, return the empty string.
/// Tracing is disabled after this call.
virtual std::string flushAndDisableBridgeTrafficTrace() = 0;
/// Write basic block profile trace to the given file name.
virtual void writeBasicBlockProfileTraceToFile(
const std::string& fileName) const = 0;
/// Write the opcode stats to the given stream.
virtual void dumpOpcodeStats(std::ostream& os) const = 0;
/// Dump external profiler symbols to the given file name.
virtual void dumpProfilerSymbolsToFile(const std::string& fileName) const = 0;
};
} // namespace jsi
} // namespace facebook

405
Headers/jsi/jsi-inl.h Normal file
View file

@ -0,0 +1,405 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#pragma once
namespace facebook {
namespace jsi {
namespace detail {
inline Value toValue(Runtime&, std::nullptr_t) {
return Value::null();
}
inline Value toValue(Runtime&, bool b) {
return Value(b);
}
inline Value toValue(Runtime&, double d) {
return Value(d);
}
inline Value toValue(Runtime&, float f) {
return Value(static_cast<double>(f));
}
inline Value toValue(Runtime&, int i) {
return Value(i);
}
inline Value toValue(Runtime& runtime, const char* str) {
return String::createFromAscii(runtime, str);
}
inline Value toValue(Runtime& runtime, const std::string& str) {
return String::createFromUtf8(runtime, str);
}
template <typename T>
inline Value toValue(Runtime& runtime, const T& other) {
static_assert(
std::is_base_of<Pointer, T>::value,
"This type cannot be converted to Value");
return Value(runtime, other);
}
inline Value toValue(Runtime& runtime, const Value& value) {
return Value(runtime, value);
}
inline Value&& toValue(Runtime&, Value&& value) {
return std::move(value);
}
inline PropNameID toPropNameID(Runtime& runtime, const char* name) {
return PropNameID::forAscii(runtime, name);
}
inline PropNameID toPropNameID(Runtime& runtime, const std::string& name) {
return PropNameID::forUtf8(runtime, name);
}
inline PropNameID&& toPropNameID(Runtime&, PropNameID&& name) {
return std::move(name);
}
/// Helper to throw while still compiling with exceptions turned off.
template <typename E, typename... Args>
[[noreturn]] inline void throwOrDie(Args&&... args) {
std::rethrow_exception(
std::make_exception_ptr(E{std::forward<Args>(args)...}));
}
} // namespace detail
template <typename T>
inline T Runtime::make(Runtime::PointerValue* pv) {
return T(pv);
}
inline Runtime::PointerValue* Runtime::getPointerValue(jsi::Pointer& pointer) {
return pointer.ptr_;
}
inline const Runtime::PointerValue* Runtime::getPointerValue(
const jsi::Pointer& pointer) {
return pointer.ptr_;
}
inline const Runtime::PointerValue* Runtime::getPointerValue(
const jsi::Value& value) {
return value.data_.pointer.ptr_;
}
inline void Runtime::setRuntimeData(
const UUID& uuid,
const std::shared_ptr<void>& data) {
auto* dataPtr = new std::shared_ptr<void>(data);
setRuntimeDataImpl(uuid, dataPtr, [](const void* data) {
delete (const std::shared_ptr<void>*)data;
});
}
inline std::shared_ptr<void> Runtime::getRuntimeData(const UUID& uuid) {
auto* data = (const std::shared_ptr<void>*)getRuntimeDataImpl(uuid);
return data ? *data : nullptr;
}
Value Object::getPrototype(Runtime& runtime) const {
return runtime.getPrototypeOf(*this);
}
inline Value Object::getProperty(Runtime& runtime, const char* name) const {
return getProperty(runtime, String::createFromAscii(runtime, name));
}
inline Value Object::getProperty(Runtime& runtime, const String& name) const {
return runtime.getProperty(*this, name);
}
inline Value Object::getProperty(Runtime& runtime, const PropNameID& name)
const {
return runtime.getProperty(*this, name);
}
inline Value Object::getProperty(Runtime& runtime, const Value& name) const {
return runtime.getProperty(*this, name);
}
inline bool Object::hasProperty(Runtime& runtime, const char* name) const {
return hasProperty(runtime, String::createFromAscii(runtime, name));
}
inline bool Object::hasProperty(Runtime& runtime, const String& name) const {
return runtime.hasProperty(*this, name);
}
inline bool Object::hasProperty(Runtime& runtime, const PropNameID& name)
const {
return runtime.hasProperty(*this, name);
}
inline bool Object::hasProperty(Runtime& runtime, const Value& name) const {
return runtime.hasProperty(*this, name);
}
template <typename T>
void Object::setProperty(Runtime& runtime, const char* name, T&& value) const {
setProperty(
runtime, String::createFromAscii(runtime, name), std::forward<T>(value));
}
template <typename T>
void Object::setProperty(Runtime& runtime, const String& name, T&& value)
const {
setPropertyValue(
runtime, name, detail::toValue(runtime, std::forward<T>(value)));
}
template <typename T>
void Object::setProperty(Runtime& runtime, const PropNameID& name, T&& value)
const {
setPropertyValue(
runtime, name, detail::toValue(runtime, std::forward<T>(value)));
}
template <typename T>
void Object::setProperty(Runtime& runtime, const Value& name, T&& value) const {
setPropertyValue(
runtime, name, detail::toValue(runtime, std::forward<T>(value)));
}
inline void Object::deleteProperty(Runtime& runtime, const char* name) const {
deleteProperty(runtime, String::createFromAscii(runtime, name));
}
inline void Object::deleteProperty(Runtime& runtime, const String& name) const {
runtime.deleteProperty(*this, name);
}
inline void Object::deleteProperty(Runtime& runtime, const PropNameID& name)
const {
runtime.deleteProperty(*this, name);
}
inline void Object::deleteProperty(Runtime& runtime, const Value& name) const {
runtime.deleteProperty(*this, name);
}
inline Array Object::getArray(Runtime& runtime) const& {
assert(runtime.isArray(*this));
(void)runtime; // when assert is disabled we need to mark this as used
return Array(runtime.cloneObject(ptr_));
}
inline Array Object::getArray(Runtime& runtime) && {
assert(runtime.isArray(*this));
(void)runtime; // when assert is disabled we need to mark this as used
Runtime::PointerValue* value = ptr_;
ptr_ = nullptr;
return Array(value);
}
inline ArrayBuffer Object::getArrayBuffer(Runtime& runtime) const& {
assert(runtime.isArrayBuffer(*this));
(void)runtime; // when assert is disabled we need to mark this as used
return ArrayBuffer(runtime.cloneObject(ptr_));
}
inline ArrayBuffer Object::getArrayBuffer(Runtime& runtime) && {
assert(runtime.isArrayBuffer(*this));
(void)runtime; // when assert is disabled we need to mark this as used
Runtime::PointerValue* value = ptr_;
ptr_ = nullptr;
return ArrayBuffer(value);
}
inline Function Object::getFunction(Runtime& runtime) const& {
assert(runtime.isFunction(*this));
return Function(runtime.cloneObject(ptr_));
}
inline Function Object::getFunction(Runtime& runtime) && {
assert(runtime.isFunction(*this));
(void)runtime; // when assert is disabled we need to mark this as used
Runtime::PointerValue* value = ptr_;
ptr_ = nullptr;
return Function(value);
}
template <typename T>
inline bool Object::isHostObject(Runtime& runtime) const {
return runtime.isHostObject(*this) &&
std::dynamic_pointer_cast<T>(runtime.getHostObject(*this));
}
template <>
inline bool Object::isHostObject<HostObject>(Runtime& runtime) const {
return runtime.isHostObject(*this);
}
template <typename T>
inline std::shared_ptr<T> Object::getHostObject(Runtime& runtime) const {
assert(isHostObject<T>(runtime));
return std::static_pointer_cast<T>(runtime.getHostObject(*this));
}
template <typename T>
inline std::shared_ptr<T> Object::asHostObject(Runtime& runtime) const {
if (!isHostObject<T>(runtime)) {
detail::throwOrDie<JSINativeException>(
"Object is not a HostObject of desired type");
}
return std::static_pointer_cast<T>(runtime.getHostObject(*this));
}
template <>
inline std::shared_ptr<HostObject> Object::getHostObject<HostObject>(
Runtime& runtime) const {
assert(runtime.isHostObject(*this));
return runtime.getHostObject(*this);
}
template <typename T>
inline bool Object::hasNativeState(Runtime& runtime) const {
return runtime.hasNativeState(*this) &&
std::dynamic_pointer_cast<T>(runtime.getNativeState(*this));
}
template <>
inline bool Object::hasNativeState<NativeState>(Runtime& runtime) const {
return runtime.hasNativeState(*this);
}
template <typename T>
inline std::shared_ptr<T> Object::getNativeState(Runtime& runtime) const {
assert(hasNativeState<T>(runtime));
return std::static_pointer_cast<T>(runtime.getNativeState(*this));
}
inline void Object::setNativeState(
Runtime& runtime,
std::shared_ptr<NativeState> state) const {
runtime.setNativeState(*this, state);
}
inline void Object::setExternalMemoryPressure(Runtime& runtime, size_t amt)
const {
runtime.setExternalMemoryPressure(*this, amt);
}
inline Array Object::getPropertyNames(Runtime& runtime) const {
return runtime.getPropertyNames(*this);
}
inline Value WeakObject::lock(Runtime& runtime) const {
return runtime.lockWeakObject(*this);
}
template <typename T>
void Array::setValueAtIndex(Runtime& runtime, size_t i, T&& value) const {
setValueAtIndexImpl(
runtime, i, detail::toValue(runtime, std::forward<T>(value)));
}
inline Value Array::getValueAtIndex(Runtime& runtime, size_t i) const {
return runtime.getValueAtIndex(*this, i);
}
inline Function Function::createFromHostFunction(
Runtime& runtime,
const jsi::PropNameID& name,
unsigned int paramCount,
jsi::HostFunctionType func) {
return runtime.createFunctionFromHostFunction(
name, paramCount, std::move(func));
}
inline Value Function::call(Runtime& runtime, const Value* args, size_t count)
const {
return runtime.call(*this, Value::undefined(), args, count);
}
inline Value Function::call(Runtime& runtime, std::initializer_list<Value> args)
const {
return call(runtime, args.begin(), args.size());
}
template <typename... Args>
inline Value Function::call(Runtime& runtime, Args&&... args) const {
// A more awesome version of this would be able to create raw values
// which can be used directly without wrapping and unwrapping, but
// this will do for now.
return call(runtime, {detail::toValue(runtime, std::forward<Args>(args))...});
}
inline Value Function::callWithThis(
Runtime& runtime,
const Object& jsThis,
const Value* args,
size_t count) const {
return runtime.call(*this, Value(runtime, jsThis), args, count);
}
inline Value Function::callWithThis(
Runtime& runtime,
const Object& jsThis,
std::initializer_list<Value> args) const {
return callWithThis(runtime, jsThis, args.begin(), args.size());
}
template <typename... Args>
inline Value Function::callWithThis(
Runtime& runtime,
const Object& jsThis,
Args&&... args) const {
// A more awesome version of this would be able to create raw values
// which can be used directly without wrapping and unwrapping, but
// this will do for now.
return callWithThis(
runtime, jsThis, {detail::toValue(runtime, std::forward<Args>(args))...});
}
template <typename... Args>
inline Array Array::createWithElements(Runtime& runtime, Args&&... args) {
return createWithElements(
runtime, {detail::toValue(runtime, std::forward<Args>(args))...});
}
template <typename... Args>
inline std::vector<PropNameID> PropNameID::names(
Runtime& runtime,
Args&&... args) {
return names({detail::toPropNameID(runtime, std::forward<Args>(args))...});
}
template <size_t N>
inline std::vector<PropNameID> PropNameID::names(
PropNameID (&&propertyNames)[N]) {
std::vector<PropNameID> result;
result.reserve(N);
for (auto& name : propertyNames) {
result.push_back(std::move(name));
}
return result;
}
inline Value Function::callAsConstructor(
Runtime& runtime,
const Value* args,
size_t count) const {
return runtime.callAsConstructor(*this, args, count);
}
inline Value Function::callAsConstructor(
Runtime& runtime,
std::initializer_list<Value> args) const {
return callAsConstructor(runtime, args.begin(), args.size());
}
template <typename... Args>
inline Value Function::callAsConstructor(Runtime& runtime, Args&&... args)
const {
return callAsConstructor(
runtime, {detail::toValue(runtime, std::forward<Args>(args))...});
}
String BigInt::toString(Runtime& runtime, int radix) const {
return runtime.bigintToString(*this, radix);
}
} // namespace jsi
} // namespace facebook

1867
Headers/jsi/jsi.h Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,31 @@
ifeq ($(THEOS_PACKAGE_SCHEME),rootless) TARGET := iphone:clang:latest:14.0
TARGET := iphone:clang:latest:15.0 ARCHS = arm64
else INSTALL_TARGET_PROCESSES = Discord
TARGET := iphone:clang:latest:12.2
endif
include $(THEOS)/makefiles/common.mk include $(THEOS)/makefiles/common.mk
TWEAK_NAME = BunnyTweak TWEAK_NAME = Revenge
BUNDLE_NAME = RevengeResources
BunnyTweak_FILES = $(shell find Sources/BunnyTweak -name '*.swift') $(shell find Sources/BunnyTweakC -name '*.m' -o -name '*.c' -o -name '*.mm' -o -name '*.cpp') Revenge_FILES = $(wildcard Sources/*.x Sources/*.xm Sources/*.m Sources/*.mm Sources/**/*.x Sources/**/*.xm Sources/**/*.m Sources/**/*.mm)
BunnyTweak_SWIFTFLAGS = -ISources/BunnyTweakC/include Revenge_CFLAGS = -fobjc-arc -DPACKAGE_VERSION='@"$(THEOS_PACKAGE_BASE_VERSION)"' -I$(THEOS_PROJECT_DIR)/Headers
BunnyTweak_CFLAGS = -fobjc-arc -ISources/BunnyTweakC/include Revenge_CCFLAGS = -std=c++17 -Wno-c++11-narrowing -I$(THEOS_PROJECT_DIR)/Headers
Revenge_FRAMEWORKS = Foundation UIKit CoreGraphics CoreText CoreFoundation
BunnyTweak_BUNDLE_NAME = BunnyPatches RevengeResources_INSTALL_PATH = "/Library/Application\ Support/"
BunnyTweak_BUNDLE_RESOURCE_DIRS = Resources RevengeResources_RESOURCE_DIRS = Resources
include $(THEOS_MAKE_PATH)/tweak.mk include $(THEOS_MAKE_PATH)/tweak.mk
include $(THEOS_MAKE_PATH)/bundle.mk
before-all::
$(ECHO_NOTHING)mkdir -p Resources$(ECHO_END)
$(ECHO_NOTHING)sed -e 's/@PACKAGE_VERSION@/$(THEOS_PACKAGE_BASE_VERSION)/g' \
-e 's/@TWEAK_NAME@/$(TWEAK_NAME)/g' \
Sources/payload-base.template.js > Resources/payload-base.js$(ECHO_END)
after-stage::
$(ECHO_NOTHING)find $(THEOS_STAGING_DIR) -name ".DS_Store" -delete$(ECHO_END)
after-package::
$(ECHO_NOTHING)rm -rf Resources$(ECHO_END)

View file

@ -1,88 +0,0 @@
// swift-tools-version:5.2
import PackageDescription
import Foundation
let projectDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
@dynamicMemberLookup struct TheosConfiguration {
private let dict: [String: String]
init(at path: String) {
let configURL = URL(fileURLWithPath: path, relativeTo: projectDir)
guard let infoString = try? String(contentsOf: configURL) else {
fatalError("""
Could not find Theos SPM config. Have you run `make spm` yet?
""")
}
let pairs = infoString.split(separator: "\n").map {
$0.split(
separator: "=", maxSplits: 1,
omittingEmptySubsequences: false
).map(String.init)
}.map { ($0[0], $0[1]) }
dict = Dictionary(uniqueKeysWithValues: pairs)
}
subscript(
key: String,
or defaultValue: @autoclosure () -> String? = nil
) -> String {
if let value = dict[key] {
return value
} else if let def = defaultValue() {
return def
} else {
fatalError("""
Could not get value of key '\(key)' from Theos SPM config. \
Try running `make spm` again.
""")
}
}
subscript(dynamicMember key: String) -> String { self[key] }
}
let conf = TheosConfiguration(at: ".theos/spm_config")
let theosPath = conf.theos
let sdk = conf.sdk
let resourceDir = conf.swiftResourceDir
let deploymentTarget = conf.deploymentTarget
let triple = "arm64-apple-ios\(deploymentTarget)"
let libFlags: [String] = [
"-F\(theosPath)/vendor/lib", "-F\(theosPath)/lib",
"-I\(theosPath)/vendor/include", "-I\(theosPath)/include"
]
let cFlags: [String] = libFlags + [
"-target", triple, "-isysroot", sdk,
"-Wno-unused-command-line-argument", "-Qunused-arguments",
]
let cxxFlags: [String] = [
]
let swiftFlags: [String] = libFlags + [
"-target", triple, "-sdk", sdk, "-resource-dir", resourceDir,
]
let package = Package(
name: "BunnyTweak",
platforms: [.iOS(deploymentTarget)],
products: [
.library(
name: "BunnyTweak",
targets: ["BunnyTweak"]
),
],
targets: [
.target(
name: "BunnyTweakC",
cSettings: [.unsafeFlags(cFlags)],
cxxSettings: [.unsafeFlags(cxxFlags)]
),
.target(
name: "BunnyTweak",
dependencies: ["BunnyTweakC"],
swiftSettings: [.unsafeFlags(swiftFlags)]
),
]
)

View file

@ -1,6 +1,44 @@
# BunnyTweak <p align="center">
<picture>
<source
width="512px"
media="(prefers-color-scheme: dark)"
srcset="assets/wordmark/wordmark+slogan-dark.svg"
>
<img
width="512px"
src="assets/wordmark/wordmark+slogan-light.svg"
>
</picture>
<br>
<a href="https://revenge-mod.github.io/">
<picture>
<source height="32px" media="(prefers-color-scheme: dark)" srcset="assets/logo/logo-dark.svg" />
<img height="32px" src="assets/logo/logo-light.svg" />
</picture>
</a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://discord.com/invite/ddcQf3s2Uq">
<picture>
<source height="32px" media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/13122796/178032563-d4e084b7-244e-4358-af50-26bde6dd4996.png" />
<img height="32px" src="https://user-images.githubusercontent.com/13122796/178032563-d4e084b7-244e-4358-af50-26bde6dd4996.png" />
</picture>
</a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://github.com/revenge-mod">
<picture>
<source height="32px" media="(prefers-color-scheme: dark)" srcset="https://i.ibb.co/dMMmCrW/Git-Hub-Mark.png" />
<img height="32px" src="https://i.ibb.co/9wV3HGF/Git-Hub-Mark-Light.png" />
</picture>
</a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
Tweak to inject [Bunny](https://github.com/pyoncord/Bunny) into Discord. Forked [VendettaTweak](https://github.com/vendetta-mod/VendettaTweak), modified to match with [BunnyXposed](https://github.com/pyoncord/BunnyXposed) behavior. There are still slight differences between these two, and this tweak may be missing some loader features. </a>
</p>
# 💊 RevengeTweak
![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/revenge-mod/revenge-tweak/release.yml)
![Open Software License 3](https://img.shields.io/badge/License-OSL%20v3-yellow.svg)
Tweak to inject [Revenge](https://github.com/revenge-mod/Revenge) into Discord. Forked [BunnyTweak](https://github.com/pyoncord/BunnyTweak), modified to match with [RevengeXposed](https://github.com/revenge-mod/Revenge-xposed) behavior. There are still slight differences between these two, and this tweak may be missing some loader features.
> [!NOTE] > [!NOTE]
> As of right now this tweak does not encompass some functionalities when running in a jailed environment with a wildcard certificate \ > As of right now this tweak does not encompass some functionalities when running in a jailed environment with a wildcard certificate \
@ -8,9 +46,9 @@ Tweak to inject [Bunny](https://github.com/pyoncord/Bunny) into Discord. Forked
> - setAlternateAppIcon does not work, thus breaking dynamic app icons > - setAlternateAppIcon does not work, thus breaking dynamic app icons
> - sharing files to the application/selecting items via the Files app does not work > - sharing files to the application/selecting items via the Files app does not work
## Installation ## 🔽 Installation
Builds can be found in the [Releases](https://github.com/pyoncord/BunnyTweak/releases/latest) tab. Builds can be found in the [Releases](https://github.com/revenge-mod/revenge-tweak/releases/latest) tab.
> [!NOTE] > [!NOTE]
> Raw decrypted IPAs which are used to provide prepatched IPAs are sourced from the [Enmity](https://github.com/enmity-mod/) community. These raw decrypted IPAs are also used throughout Enmity related projects such as [enmity-mod/tweak](https://github.com/enmity-mod/tweak/) and [acquitelol/rosiecord](https://github.com/acquitelol/rosiecord).\ > Raw decrypted IPAs which are used to provide prepatched IPAs are sourced from the [Enmity](https://github.com/enmity-mod/) community. These raw decrypted IPAs are also used throughout Enmity related projects such as [enmity-mod/tweak](https://github.com/enmity-mod/tweak/) and [acquitelol/rosiecord](https://github.com/acquitelol/rosiecord).\
@ -19,19 +57,19 @@ Builds can be found in the [Releases](https://github.com/pyoncord/BunnyTweak/rel
### Jailbroken ### Jailbroken
1. Install the Orion runtime via your preferred package manager, by adding `https://repo.chariz.com/` to your sources, then finding `Orion Runtime`. 1. Install the Orion runtime via your preferred package manager, by adding `https://repo.chariz.com/` to your sources, then finding `Orion Runtime`.
1. Install Bunny by downloading the appropriate Debian package (or by building your own, see [Building BunnyTweak locally](#building-bunnytweak-locally)) and adding it to your package manager. Use the file ending in `arm.deb` for rootful jailbreaks, and the file ending in `arm64.deb` for rootless jailbreaks. 1. Install Revenge by downloading the appropriate Debian package (or by building your own, see [Building RevengeTweak locally](#building-revengetweak-locally)) and adding it to your package manager. Use the file ending in `arm.deb` for rootful jailbreaks, and the file ending in `arm64.deb` for rootless jailbreaks.
### Jailed ### Jailed
<a href="https://tinyurl.com/24zjszuf"><img src="https://i.imgur.com/dsbDLK9.png" width="230"></a> <a href="https://tinyurl.com/2jyk87mz"><img src="https://i.imgur.com/dsbDLK9.png" width="230"></a>
<a href="https://tinyurl.com/yh455zk6"><img src="https://i.imgur.com/46qhEAv.png" width="230"></a> <a href="https://tinyurl.com/yjn8fpp3"><img src="https://i.imgur.com/46qhEAv.png" width="230"></a>
> [!NOTE] > [!NOTE]
> TrollStore may display an encryption warning, which you can disregard. > TrollStore may display an encryption warning, which you can disregard.
1. Download and install [Bunny.ipa](https://github.com/pyoncord/BunnyTweak/releases/latest/download/Bunny.ipa) using your preferred sideloading method. 1. Download and install [Revenge.ipa](https://github.com/revenge-mod/revenge-tweak/releases/latest/download/Revenge.ipa) using your preferred sideloading method.
## Building BunnyTweak locally ## 🛠️ Building RevengeTweak locally
> [!NOTE] > [!NOTE]
> These steps assume you use MacOS. > These steps assume you use MacOS.
@ -42,7 +80,7 @@ Builds can be found in the [Releases](https://github.com/pyoncord/BunnyTweak/rel
2. Install the required dependencies. You can do this by running `brew install make ldid` in your terminal. If you do not have brew installed, follow the instructions [here](https://brew.sh/). 2. Install the required dependencies. You can do this by running `brew install make ldid` in your terminal. If you do not have brew installed, follow the instructions [here](https://brew.sh/).
3. Setup your path accordingly. We recommend you run the following before running the next commands, as well as any time you want to build BunnyTweak. 3. Setup your path accordingly. We recommend you run the following before running the next commands, as well as any time you want to build RevengeTweak.
```bash ```bash
export PATH="$(brew --prefix make)/libexec/gnubin:$PATH" export PATH="$(brew --prefix make)/libexec/gnubin:$PATH"
@ -58,17 +96,17 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/theos/theos/master/bin/i
If you've already installed theos, you can run `$THEOS/bin/update-theos` to make sure it's up to date. If you've already installed theos, you can run `$THEOS/bin/update-theos` to make sure it's up to date.
5. Clone this repository with `git clone git@github.com:pyoncord/BunnyTweak.git` and `cd` into it. Replace the URL with your fork if you've forked this repository. 5. Clone this repository with `git clone git@github.com:revenge-mod/RevengeTweak.git` and `cd` into it. Replace the URL with your fork if you've forked this repository.
6. To build BunnyTweak, you can run `rm -rf packages && make clean && make package FINALPACKAGE=1 && make package FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless`. The first command will remove any previous packages, the second will clean the project, the third will build the rootful package (which is denoted by the `arm.deb` ending), and the fourth will build the rootless package (which is denoted by the `arm64.deb` ending). 6. To build RevengeTweak, you can run `rm -rf packages && make clean && make package FINALPACKAGE=1 && make package FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless`. The first command will remove any previous packages, the second will clean the project, the third will build the rootful package (which is denoted by the `arm.deb` ending), and the fourth will build the rootless package (which is denoted by the `arm64.deb` ending).
The first time you run this, it might take a bit longer, but subsequent builds should be much faster. The first time you run this, it might take a bit longer, but subsequent builds should be much faster.
The resulting `.deb` files will be in the `packages` folder. As a reminder, `*arm.deb` is for rootful jailbreaks and sideloading, and `*arm64.deb` is for rootless jailbreaks. The resulting `.deb` files will be in the `packages` folder. As a reminder, `*arm.deb` is for rootful jailbreaks and sideloading, and `*arm64.deb` is for rootless jailbreaks.
## Contributing ## 📙 Contributing
If you want to contribute, you will basically need to follow the steps for [Building BunnyTweak locally](#building-bunnytweak-locally), as well as run `make spm` for the Swift LSP to work. If you want to contribute, you will basically need to follow the steps for [Building RevengeTweak locally](#building-revengetweak-locally), as well as run `make spm` for the Swift LSP to work.
<!-- @vladdy was here, battling all these steps so you don't have to. Have fun! :3 --> <!-- @vladdy was here, battling all these steps so you don't have to. Have fun! :3 -->
<!-- @castdrian also was here simplifying these steps immensely --> <!-- @castdrian also was here simplifying these steps immensely -->

View file

@ -1,99 +0,0 @@
import BunnyTweakC
import Orion
import UIKit
import os
import Foundation
// This is not the full implementation, we only take what we need in here
// For full definition, find it from the core codebase (JS side)
struct FontDefinition: Codable {
let name: String
let main: [String: String]?
}
var fontMap: [String: String] = [:]
class FontsHook: ClassHook<UIFont> {
class func fontWithName(_ name: String, size: CGFloat) -> UIFont {
if let replacementName = fontMap[name] {
let replacementDescriptor = UIFontDescriptor(name: replacementName, size: size)
let fallbackDescriptor = replacementDescriptor.addingAttributes([.name: [name]])
let finalDescriptor = replacementDescriptor.addingAttributes([.cascadeList: [fallbackDescriptor]])
return orig.fontWithDescriptor(finalDescriptor, size: size)
}
return orig.fontWithName(name, size: size)
}
class func fontWithDescriptor(_ descriptor: UIFontDescriptor, size: CGFloat) -> UIFont {
if let replacementName = fontMap[descriptor.postscriptName] {
let replacementDescriptor = UIFontDescriptor(name: replacementName, size: size)
let finalDescriptor = replacementDescriptor.addingAttributes([.cascadeList: [descriptor]])
return orig.fontWithDescriptor(finalDescriptor, size: size)
}
return orig.fontWithDescriptor(descriptor, size: size)
}
}
func patchFonts(_ main: [String: String], fontDefName: String) {
for (fontName, url) in main {
os_log("Replacing font %{public}@ with URL: %{public}@", log: bunnyLog, type: .info, fontName, url)
let fontExtension = URL(string: url)!.pathExtension
let fontCachePath = pyoncordDirectory
.appendingPathComponent("downloads", isDirectory: true)
.appendingPathComponent("fonts", isDirectory: true)
.appendingPathComponent(fontDefName, isDirectory: true)
.appendingPathComponent("\(fontName).\(fontExtension)")
do {
os_log("Attempting to register font %{public}@ from %{public}@", log: bunnyLog, type: .info, fontName, url)
let parent = fontCachePath.deletingLastPathComponent()
if !FileManager.default.fileExists(atPath: parent.path) {
os_log("Creating parent directory: %{public}@", log: bunnyLog, type: .debug, parent.path)
try FileManager.default.createDirectory(
at: parent, withIntermediateDirectories: true, attributes: nil)
}
// JS side should download these already, but just in case...
if !FileManager.default.fileExists(atPath: fontCachePath.path) {
os_log("Downloading font %{public}@ from %{public}@", log: bunnyLog, type: .debug, fontName, url)
if let data = try? Data(contentsOf: URL(string: url)!) {
os_log("Writing font data to: %{public}@", log: bunnyLog, type: .debug, fontCachePath.path)
try? data.write(to: fontCachePath)
}
}
if let data = try? Data(contentsOf: fontCachePath) {
os_log("Registering font %{public}@ with provider", log: bunnyLog, type: .debug, fontName)
let provider = CGDataProvider(data: data as CFData)
let font = CGFont(provider!)
var error: Unmanaged<CFError>?
// This does not work with system/app fonts unfortunately. Throws a CTFontManagerError.systemRequired
if let existingFont = CGFont(font!.postScriptName!) {
var unregisterError: Unmanaged<CFError>?
if !CTFontManagerUnregisterGraphicsFont(existingFont, &unregisterError) {
os_log("Failed to deregister font %{public}@: %{public}@", log: bunnyLog, type: .error,
font!.postScriptName! as String,
String(describing: unregisterError!.takeUnretainedValue()))
}
}
if CTFontManagerRegisterGraphicsFont(font!, &error) {
fontMap[fontName] = font!.postScriptName! as String
os_log("Successfully registered font %{public}@ to %{public}@", log: bunnyLog, type: .info, fontName, font!.postScriptName! as String)
} else {
os_log("Failed to register font %{public}@: %{public}@", log: bunnyLog, type: .error, fontName, String(describing: error!.takeUnretainedValue()))
}
} else {
os_log("Failed to read font data from: %{public}@", log: bunnyLog, type: .error, fontCachePath.path)
}
} catch {
os_log("Failed to register font %{public}@: %{public}@", log: bunnyLog, type: .error, fontName, error.localizedDescription)
}
}
}

View file

@ -1,47 +0,0 @@
import Foundation
import os
enum LoaderConfigError: Error {
case doesNotExist
}
struct CustomLoadUrl: Codable {
let enabled: Bool
let url: URL
}
struct LoaderConfig: Codable {
let customLoadUrl: CustomLoadUrl
}
let defaultLoaderConfig = LoaderConfig(
customLoadUrl: CustomLoadUrl(
enabled: false,
url: URL(string: "http://localhost:4040/bunny.js")!
)
)
let pyoncordDirectory = getPyoncordDirectory()
let loaderConfigUrl = pyoncordDirectory.appendingPathComponent("loader.json")
func getLoaderConfig() -> LoaderConfig {
os_log("Getting loader config", log: bunnyLog, type: .debug)
let fileManager = FileManager.default
do {
if fileManager.fileExists(atPath: loaderConfigUrl.path) {
let data = try Data(contentsOf: loaderConfigUrl)
let loaderConfig = try JSONDecoder().decode(LoaderConfig.self, from: data)
os_log("Got loader config", log: bunnyLog, type: .debug)
return loaderConfig
} else {
throw LoaderConfigError.doesNotExist
}
} catch {
os_log("Couldn't get loader config", log: bunnyLog, type: .error)
return defaultLoaderConfig
}
}

View file

@ -1,152 +0,0 @@
import Orion
import UIKit
import BunnyTweakC
import os
struct Author: Codable {
let name: String
let id: String?
}
struct ThemeData: Codable {
let name: String
let description: String?
let authors: [Author]?
let spec: Int
let semanticColors: [String: [String]]?
let rawColors: [String: String]?
}
struct Theme: Codable {
let id: String
let selected: Bool
let data: ThemeData
}
func hexToUIColor(_ hex: String) -> UIColor? {
let r: CGFloat
let g: CGFloat
let b: CGFloat
let a: CGFloat
if hex.hasPrefix("#") {
let start = hex.index(hex.startIndex, offsetBy: 1)
var hexColor = String(hex[start...])
if hexColor.count == 6 {
hexColor.append("ff")
}
if hexColor.count == 8 {
let scanner = Scanner(string: hexColor)
var hexNumber: UInt64 = 0
if scanner.scanHexInt64(&hexNumber) {
r = CGFloat((hexNumber & 0xff00_0000) >> 24) / 255
g = CGFloat((hexNumber & 0x00ff_0000) >> 16) / 255
b = CGFloat((hexNumber & 0x0000_ff00) >> 8) / 255
a = CGFloat(hexNumber & 0x0000_00ff) / 255
return UIColor(red: r, green: g, blue: b, alpha: a)
}
}
}
return nil
}
func swizzleDCDThemeColor(_ semanticColors: [String: [String]]) {
os_log("Swizzling DCDThemeColor", log: bunnyLog, type: .info)
let DCDTheme: AnyClass = NSClassFromString("DCDTheme")!
let dcdThemeTarget: AnyClass = object_getClass(DCDTheme)!
let themeIndexSelector = NSSelectorFromString("themeIndex")
let themeIndexMethod = class_getClassMethod(dcdThemeTarget, themeIndexSelector)!
let themeIndexImpl = method_getImplementation(themeIndexMethod)
typealias ThemeIndexType = @convention(c) (AnyObject, Selector) -> Int
let themeIndex: ThemeIndexType = unsafeBitCast(themeIndexImpl, to: ThemeIndexType.self)
let DCDThemeColor: AnyClass = NSClassFromString("DCDThemeColor")!
os_log("Found instance of DCDThemeColor", log: bunnyLog, type: .debug)
let target: AnyClass = object_getClass(DCDThemeColor)!
os_log("Found DCDThemeColor", log: bunnyLog, type: .debug)
var methodCount: UInt32 = 0
let methods = class_copyMethodList(target, &methodCount)!
os_log("DCDThemeColor has %{public}d methods", log: bunnyLog, type: .debug, methodCount)
for i in 0..<Int(methodCount) {
let method = methods[i]
let selector = method_getName(method)
let methodName = NSStringFromSelector(selector)
os_log(
"Found method %{public}@", log: bunnyLog, type: .debug, methodName)
if let semanticColor = semanticColors[methodName] {
os_log(
"Swizzling %{public}@", log: bunnyLog, type: .debug, methodName)
let originalImpl = method_getImplementation(method)
typealias OriginalType = @convention(c) (AnyObject, Selector) -> UIColor
let original = unsafeBitCast(originalImpl, to: OriginalType.self)
let semanticColorBlock: @convention(block) (AnyObject) -> UIColor = {
(self: AnyObject) -> UIColor in
let themeIndexVal = themeIndex(dcdThemeTarget, themeIndexSelector)
if semanticColor.count - 1 >= themeIndexVal {
if let semanticUIColor = hexToUIColor(
semanticColor[themeIndexVal])
{
return semanticUIColor
}
}
return original(target, selector)
}
let semanticColorImplementation = imp_implementationWithBlock(
unsafeBitCast(semanticColorBlock, to: AnyObject.self))
method_setImplementation(
method, semanticColorImplementation)
}
}
free(methods)
}
func swizzleUIColor(_ rawColors: [String: String]) {
os_log("Swizzling UIColor", log: bunnyLog, type: .info)
let UIColor: AnyClass = NSClassFromString("UIColor")!
os_log("Found instance of UIColor", log: bunnyLog, type: .debug)
let target: AnyClass = object_getClass(UIColor)!
os_log("Found UIColor", log: bunnyLog, type: .debug)
var methodCount: UInt32 = 0
let methods = class_copyMethodList(target, &methodCount)!
os_log("UIColor has %{public}d methods", log: bunnyLog, type: .debug, methodCount)
for i in 0..<Int(methodCount) {
let method = methods[i]
let selector = method_getName(method)
let methodName = NSStringFromSelector(selector)
os_log(
"Found method %{public}@", log: bunnyLog, type: .debug, methodName)
if let rawColor = rawColors[methodName] {
os_log(
"Swizzling %{public}@", log: bunnyLog, type: .debug, methodName)
let rawColorBlock: @convention(block) (AnyObject) -> UIColor = {
(self: AnyObject) -> UIColor in
return hexToUIColor(rawColor)!
}
let rawColorImplementation = imp_implementationWithBlock(
unsafeBitCast(rawColorBlock, to: AnyObject.self))
method_setImplementation(
method, rawColorImplementation)
}
}
free(methods)
}

View file

@ -1,143 +0,0 @@
import Orion
import BunnyTweakC
import os
let bunnyLog = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "bunny")
let source = URL(string: "bunny")!
let install_prefix = String(cString: get_install_prefix())
let isJailbroken = FileManager.default.fileExists(atPath: "\(install_prefix)/Library/Application Support/BunnyTweak/BunnyPatches.bundle")
let bunnyPatchesBundlePath = isJailbroken ? "\(install_prefix)/Library/Application Support/BunnyTweak/BunnyPatches.bundle" : "\(Bundle.main.bundleURL.path)/BunnyPatches.bundle"
class FileManagerLoadHook: ClassHook<FileManager> {
func containerURLForSecurityApplicationGroupIdentifier(_ groupIdentifier: NSString?) -> URL? {
os_log("containerURLForSecurityApplicationGroupIdentifier called! %{public}@ groupIdentifier", log: bunnyLog, type: .debug, groupIdentifier ?? "nil")
if (isJailbroken) {
return orig.containerURLForSecurityApplicationGroupIdentifier(groupIdentifier)
}
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
let lastPath = paths.last!
return lastPath.appendingPathComponent("AppGroup")
}
}
class LoadHook: ClassHook<RCTCxxBridge> {
func executeApplicationScript(_ script: Data, url: URL, async: Bool) {
os_log("executeApplicationScript called!", log: bunnyLog, type: .debug)
let loaderConfig = getLoaderConfig()
let bunnyPatchesBundle = Bundle(path: bunnyPatchesBundlePath)!
if let patchPath = bunnyPatchesBundle.url(forResource: "payload-base", withExtension: "js") {
let patchData = try! Data(contentsOf: patchPath)
os_log("Executing payload base", log: bunnyLog, type: .debug)
orig.executeApplicationScript(patchData, url: source, async: true)
}
let pyoncordDirectory = getPyoncordDirectory()
var bundle = try? Data(contentsOf: pyoncordDirectory.appendingPathComponent("bundle.js"))
let group = DispatchGroup()
group.enter()
var bundleUrl: URL
if loaderConfig.customLoadUrl.enabled {
os_log(
"Custom load URL enabled, with URL %{public}@ ", log: bunnyLog, type: .info,
loaderConfig.customLoadUrl.url.absoluteString)
bundleUrl = loaderConfig.customLoadUrl.url
} else {
bundleUrl = URL(
string: "https://raw.githubusercontent.com/pyoncord/bunny-builds/main/bunny.js")!
}
os_log("Fetching JS bundle", log: bunnyLog, type: .info)
var bundleRequest = URLRequest(
url: bundleUrl, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 3.0)
if let bundleEtag = try? String(
contentsOf: pyoncordDirectory.appendingPathComponent("etag.txt")), bundle != nil
{
bundleRequest.addValue(bundleEtag, forHTTPHeaderField: "If-None-Match")
}
let fetchTask = URLSession.shared.dataTask(with: bundleRequest) { data, response, error in
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
os_log("Successfully fetched JS Bundle", log: bunnyLog, type: .debug)
bundle = data
try? bundle?.write(to: pyoncordDirectory.appendingPathComponent("bundle.js"))
let etag = httpResponse.allHeaderFields["Etag"] as? String
try? etag?.write(
to: pyoncordDirectory.appendingPathComponent("etag.txt"), atomically: true,
encoding: .utf8)
}
group.leave()
}
fetchTask.resume()
group.wait()
if let themeString = try? String(
contentsOf: pyoncordDirectory.appendingPathComponent("current-theme.json"))
{
orig.executeApplicationScript(
"globalThis.__PYON_LOADER__.storedTheme=\(themeString)".data(using: .utf8)!, url: source, async: async)
}
let preloadsDirectory = pyoncordDirectory.appendingPathComponent("preloads")
if FileManager.default.fileExists(atPath: preloadsDirectory.path) {
do {
let contents = try FileManager.default.contentsOfDirectory(
at: preloadsDirectory, includingPropertiesForKeys: nil, options: [])
for fileURL in contents {
if fileURL.pathExtension == "js" {
os_log(
"Executing preload JS file %{public}@ ", log: bunnyLog, type: .info, fileURL.absoluteString)
if let data = try? Data(contentsOf: fileURL) {
orig.executeApplicationScript(data, url: source, async: async)
}
}
}
} catch {
os_log("Error reading contents of preloads directory", log: bunnyLog, type: .error)
}
}
if bundle != nil {
os_log("Executing JS bundle", log: bunnyLog, type: .info)
orig.executeApplicationScript(bundle!, url: source, async: async)
} else {
os_log("Unable to fetch JS bundle", log: bunnyLog, type: .error)
}
os_log("Executing original script", log: bunnyLog, type: .info)
orig.executeApplicationScript(script, url: url, async: async)
}
}
struct BunnyTweak: Tweak {
func tweakDidActivate() {
if let themeData = try? Data(
contentsOf: pyoncordDirectory.appendingPathComponent("current-theme.json")) {
let theme = try? JSONDecoder().decode(Theme.self, from: themeData)
if let semanticColors = theme?.data.semanticColors { swizzleDCDThemeColor(semanticColors) }
if let rawColors = theme?.data.rawColors { swizzleUIColor(rawColors) }
}
if let fontData = try? Data(
contentsOf: pyoncordDirectory.appendingPathComponent("fonts.json")) {
let fonts = try? JSONDecoder().decode(FontDefinition.self, from: fontData)
if let main = fonts?.main { patchFonts(main, fontDefName: fonts!.name) }
}
}
}

View file

@ -1,16 +0,0 @@
import Foundation
func getPyoncordDirectory() -> URL {
let documentDirectoryURL = try! FileManager.default.url(
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
let pyoncordFolderURL = documentDirectoryURL.appendingPathComponent("pyoncord")
// Create the "pyoncord" folder if it doesn't exist
if !FileManager.default.fileExists(atPath: pyoncordFolderURL.path) {
try! FileManager.default.createDirectory(
at: pyoncordFolderURL, withIntermediateDirectories: true, attributes: nil)
}
return pyoncordFolderURL
}

View file

@ -1,11 +0,0 @@
#import <Orion/Orion.h>
const char * const _Nonnull get_install_prefix(void) {
return THEOS_PACKAGE_INSTALL_PREFIX;
}
__attribute__((constructor)) static void init() {
// Initialize Orion - do not remove this line.
orion_init();
// Custom initialization code goes here.
}

View file

@ -1,3 +0,0 @@
#import "RCTCxxBridge.h"
const char * const _Nonnull get_install_prefix(void);

View file

@ -1,4 +0,0 @@
module BunnyTweakC {
umbrella "."
export *
}

150
Sources/Fonts.x Normal file
View file

@ -0,0 +1,150 @@
#import "Fonts.h"
#import "Utils.h"
#import "Logger.h"
#import <CoreText/CoreText.h>
#import <CoreGraphics/CoreGraphics.h>
#import <CoreFoundation/CoreFoundation.h>
NSMutableDictionary<NSString *, NSString *> *fontMap;
%hook UIFont
+ (UIFont *)fontWithName:(NSString *)name size:(CGFloat)size {
NSString *replacementName = fontMap[name];
if (replacementName) {
UIFontDescriptor *replacementDescriptor = [UIFontDescriptor fontDescriptorWithName:replacementName size:size];
UIFontDescriptor *fallbackDescriptor = [replacementDescriptor fontDescriptorByAddingAttributes:@{UIFontDescriptorNameAttribute: @[name]}];
UIFontDescriptor *finalDescriptor = [replacementDescriptor fontDescriptorByAddingAttributes:@{UIFontDescriptorCascadeListAttribute: @[fallbackDescriptor]}];
return [UIFont fontWithDescriptor:finalDescriptor size:size];
}
return %orig;
}
+ (UIFont *)fontWithDescriptor:(UIFontDescriptor *)descriptor size:(CGFloat)size {
NSString *replacementName = fontMap[descriptor.postscriptName];
if (replacementName) {
UIFontDescriptor *replacementDescriptor = [UIFontDescriptor fontDescriptorWithName:replacementName size:size];
UIFontDescriptor *finalDescriptor = [replacementDescriptor fontDescriptorByAddingAttributes:@{UIFontDescriptorCascadeListAttribute: @[descriptor]}];
return [UIFont fontWithDescriptor:finalDescriptor size:size];
}
return %orig;
}
+ (UIFont *)systemFontOfSize:(CGFloat)size {
NSString *replacementName = fontMap[@"systemFont"];
if (replacementName) {
return [UIFont fontWithName:replacementName size:size];
}
return %orig;
}
+ (UIFont *)preferredFontForTextStyle:(UIFontTextStyle)style {
NSString *replacementName = fontMap[@"systemFont"];
if (replacementName) {
return [UIFont fontWithName:replacementName size:[UIFont systemFontSize]];
}
return %orig;
}
%end
void patchFonts(NSDictionary<NSString *, NSString *> *mainFonts, NSString *fontDefName) {
RevengeLog(@"patchFonts called with fonts: %@ and def name: %@", mainFonts, fontDefName);
if (!fontMap) {
RevengeLog(@"Creating new fontMap");
fontMap = [NSMutableDictionary dictionary];
}
NSString *fontJson = [NSString stringWithContentsOfURL:[getPyoncordDirectory() URLByAppendingPathComponent:@"fonts.json"]
encoding:NSUTF8StringEncoding
error:nil];
if (fontJson) {
RevengeLog(@"Found existing fonts.json: %@", fontJson);
}
for (NSString *fontName in mainFonts) {
NSString *url = mainFonts[fontName];
RevengeLog(@"Replacing font %@ with URL: %@", fontName, url);
NSURL *fontURL = [NSURL URLWithString:url];
NSString *fontExtension = fontURL.pathExtension;
NSURL *fontCachePath = [[[getPyoncordDirectory()
URLByAppendingPathComponent:@"downloads" isDirectory:YES]
URLByAppendingPathComponent:@"fonts" isDirectory:YES]
URLByAppendingPathComponent:fontDefName isDirectory:YES];
fontCachePath = [fontCachePath URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", fontName, fontExtension]];
NSURL *parentDir = [fontCachePath URLByDeletingLastPathComponent];
if (![[NSFileManager defaultManager] fileExistsAtPath:parentDir.path]) {
RevengeLog(@"Creating parent directory: %@", parentDir.path);
[[NSFileManager defaultManager] createDirectoryAtURL:parentDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
}
if (![[NSFileManager defaultManager] fileExistsAtPath:fontCachePath.path]) {
RevengeLog(@"Downloading font %@ from %@", fontName, url);
NSData *data = [NSData dataWithContentsOfURL:fontURL];
if (data) {
RevengeLog(@"Writing font data to: %@", fontCachePath.path);
[data writeToURL:fontCachePath atomically:YES];
}
}
NSData *fontData = [NSData dataWithContentsOfURL:fontCachePath];
if (fontData) {
RevengeLog(@"Registering font %@ with provider", fontName);
CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)fontData);
CGFontRef font = CGFontCreateWithDataProvider(provider);
if (font) {
CFStringRef postScriptName = CGFontCopyPostScriptName(font);
CTFontRef existingFont = CTFontCreateWithName(postScriptName, 0, NULL);
if (existingFont) {
CFErrorRef unregisterError = NULL;
if (!CTFontManagerUnregisterGraphicsFont(font, &unregisterError)) {
RevengeLog(@"Failed to deregister font %@: %@", (__bridge NSString *)postScriptName,
unregisterError ? (__bridge NSString *)CFErrorCopyDescription(unregisterError) : @"Unknown error");
if (unregisterError) CFRelease(unregisterError);
}
CFRelease(existingFont);
}
CFErrorRef error = NULL;
if (CTFontManagerRegisterGraphicsFont(font, &error)) {
fontMap[fontName] = (__bridge NSString *)postScriptName;
RevengeLog(@"Successfully registered font %@ to %@", fontName, (__bridge NSString *)postScriptName);
NSError *jsonError;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:fontMap options:0 error:&jsonError];
if (!jsonError) {
[jsonData writeToURL:[getPyoncordDirectory() URLByAppendingPathComponent:@"fontMap.json"] atomically:YES];
}
} else {
NSString *errorDesc = error ? (__bridge NSString *)CFErrorCopyDescription(error) : @"Unknown error";
RevengeLog(@"Failed to register font %@: %@", fontName, errorDesc);
if (error) CFRelease(error);
}
CFRelease(postScriptName);
CFRelease(font);
}
CGDataProviderRelease(provider);
}
}
}
%ctor {
@autoreleasepool {
fontMap = [NSMutableDictionary dictionary];
RevengeLog(@"Font hooks initialized");
%init;
}
}

49
Sources/JSI.mm Normal file
View file

@ -0,0 +1,49 @@
#import "JSI.h"
#import "Logger.h"
#import "Utils.h"
using namespace facebook;
namespace {
class NSDataBuffer : public jsi::Buffer {
public:
explicit NSDataBuffer(NSData *data) : data_(data) {}
size_t size() const override {
return data_.length;
}
const uint8_t *data() const override {
return static_cast<const uint8_t *>(data_.bytes);
}
private:
NSData *data_;
};
}
@implementation JSI
+ (void)evaluate:(NSData *)scriptData tag:(NSString *)tag runtime:(jsi::Runtime &)runtime {
if (scriptData.length == 0) return;
try {
if (isHermesBytecode(scriptData)) {
auto buffer = std::make_shared<NSDataBuffer>(scriptData);
auto prepared = runtime.prepareJavaScript(buffer, std::string(tag.UTF8String));
runtime.evaluatePreparedJavaScript(prepared);
} else {
std::string source((const char *)scriptData.bytes, scriptData.length);
auto buffer = std::make_shared<jsi::StringBuffer>(std::move(source));
runtime.evaluateJavaScript(buffer, std::string(tag.UTF8String));
}
} catch (const jsi::JSError &e) {
RevengeLog(@"[JSI] evaluate error: JSError for '%@': %s", tag, e.what());
} catch (const std::exception &e) {
RevengeLog(@"[JSI] evaluate error: exception for '%@': %s", tag, e.what());
}
}
@end

99
Sources/LoaderConfig.m Normal file
View file

@ -0,0 +1,99 @@
#import "LoaderConfig.h"
#import "Utils.h"
#import "Logger.h"
@implementation LoaderConfig
- (instancetype)init {
self = [super init];
if (self) {
self.customLoadUrlEnabled = NO;
self.customLoadUrl = [NSURL URLWithString:@"http://localhost:4040/revenge.js"];
}
return self;
}
- (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");
NSURL *loaderConfigUrl = [getPyoncordDirectory() URLByAppendingPathComponent:@"loader.json"];
if ([[NSFileManager defaultManager] fileExistsAtPath:loaderConfigUrl.path]) {
NSError *error = nil;
NSData *data = [NSData dataWithContentsOfURL:loaderConfigUrl];
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
if (json && !error) {
LoaderConfig *config = [[LoaderConfig alloc] init];
NSDictionary *customLoadUrl = json[@"customLoadUrl"];
if (customLoadUrl) {
config.customLoadUrlEnabled = [customLoadUrl[@"enabled"] boolValue];
NSString *urlString = customLoadUrl[@"url"];
if (urlString) {
config.customLoadUrl = [NSURL URLWithString:urlString];
}
}
return config;
}
}
RevengeLog(@"Couldn't get loader config");
return [LoaderConfig defaultConfig];
}
- (BOOL)saveConfig {
NSURL *loaderConfigUrl = [getPyoncordDirectory() URLByAppendingPathComponent:@"loader.json"];
NSDictionary *json = @{
@"customLoadUrl": @{
@"enabled": @(self.customLoadUrlEnabled),
@"url": self.customLoadUrl.absoluteString
}
};
NSData *data = [NSJSONSerialization dataWithJSONObject:json options:0 error:nil];
return [data writeToURL:loaderConfigUrl atomically:YES];
}
@end

1
Sources/Logger.m Normal file
View file

@ -0,0 +1 @@
#import "Logger.h"

88
Sources/Sideloading.x Normal file
View file

@ -0,0 +1,88 @@
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <dlfcn.h>
#import "Logger.h"
#define DISCORD_BUNDLE_ID @"com.hammerandchisel.discord"
#define DISCORD_NAME @"Discord"
static NSString *getAccessGroupID(void) {
NSDictionary *query = @{
(__bridge NSString *)kSecClass: (__bridge NSString *)kSecClassGenericPassword,
(__bridge NSString *)kSecAttrAccount: @"bundleSeedID",
(__bridge NSString *)kSecAttrService: @"",
(__bridge NSString *)kSecReturnAttributes: @YES
};
CFDictionaryRef result = NULL;
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&result);
if (status == errSecItemNotFound) {
status = SecItemAdd((__bridge CFDictionaryRef)query, (CFTypeRef *)&result);
}
if (status != errSecSuccess) return nil;
NSString *accessGroup = [(__bridge NSDictionary *)result objectForKey:(__bridge NSString *)kSecAttrAccessGroup];
if (result) CFRelease(result);
return accessGroup;
}
static BOOL isSelfCall(void) {
NSArray *address = [NSThread callStackReturnAddresses];
Dl_info info = {0};
if (dladdr((void *)[address[2] longLongValue], &info) == 0) return NO;
NSString *path = [NSString stringWithUTF8String:info.dli_fname];
return [path hasPrefix:NSBundle.mainBundle.bundlePath];
}
%group Sideloading
%hook NSBundle
- (NSString *)bundleIdentifier {
return isSelfCall() ? DISCORD_BUNDLE_ID : %orig;
}
- (NSDictionary *)infoDictionary {
if (!isSelfCall()) return %orig;
NSMutableDictionary *info = [%orig mutableCopy];
info[@"CFBundleIdentifier"] = DISCORD_BUNDLE_ID;
info[@"CFBundleDisplayName"] = DISCORD_NAME;
info[@"CFBundleName"] = DISCORD_NAME;
return info;
}
- (id)objectForInfoDictionaryKey:(NSString *)key {
if (!isSelfCall()) return %orig;
if ([key isEqualToString:@"CFBundleIdentifier"]) return DISCORD_BUNDLE_ID;
if ([key isEqualToString:@"CFBundleDisplayName"] ||
[key isEqualToString:@"CFBundleName"]) return DISCORD_NAME;
return %orig;
}
%end
%hook NSFileManager
- (NSURL *)containerURLForSecurityApplicationGroupIdentifier:(NSString *)groupIdentifier {
RevengeLog(@"containerURLForSecurityApplicationGroupIdentifier called! %@", groupIdentifier ?: @"nil");
NSArray *paths = [self URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask];
NSURL *lastPath = [paths lastObject];
return [lastPath URLByAppendingPathComponent:@"AppGroup"];
}
%end
%hook UIPasteboard
- (NSString *)_accessGroup {
return getAccessGroupID();
}
%end
%end
%ctor {
BOOL isAppStoreApp = [[NSFileManager defaultManager] fileExistsAtPath:[[NSBundle mainBundle] appStoreReceiptURL].path];
if (!isAppStoreApp) %init(Sideloading);
}

82
Sources/Theme.m Normal file
View file

@ -0,0 +1,82 @@
#import "Theme.h"
#import "Utils.h"
#import "Logger.h"
#import <objc/runtime.h>
void swizzleDCDThemeColor(NSDictionary<NSString *, NSArray<NSString *> *> *semanticColors) {
RevengeLog(@"Swizzling DCDThemeColor");
Class DCDTheme = NSClassFromString(@"DCDTheme");
Class dcdThemeTarget = object_getClass(DCDTheme);
SEL themeIndexSelector = NSSelectorFromString(@"themeIndex");
Method themeIndexMethod = class_getClassMethod(dcdThemeTarget, themeIndexSelector);
IMP themeIndexImpl = method_getImplementation(themeIndexMethod);
int (*themeIndex)(id, SEL) = (int (*)(id, SEL))themeIndexImpl;
Class DCDThemeColor = NSClassFromString(@"DCDThemeColor");
Class target = object_getClass(DCDThemeColor);
unsigned int methodCount;
Method *methods = class_copyMethodList(target, &methodCount);
for (unsigned int i = 0; i < methodCount; i++) {
Method method = methods[i];
SEL selector = method_getName(method);
NSString *methodName = NSStringFromSelector(selector);
NSArray<NSString *> *semanticColor = semanticColors[methodName];
if (semanticColor) {
RevengeLog(@"Swizzling %@", methodName);
IMP originalImpl = method_getImplementation(method);
UIColor *(*original)(id, SEL) = (UIColor *(*)(id, SEL))originalImpl;
id block = ^UIColor *(id self) {
int themeIndexVal = themeIndex(dcdThemeTarget, themeIndexSelector);
if (semanticColor.count - 1 >= themeIndexVal) {
UIColor *semanticUIColor = hexToUIColor(semanticColor[themeIndexVal]);
if (semanticUIColor) {
return semanticUIColor;
}
}
return original(target, selector);
};
IMP newImpl = imp_implementationWithBlock(block);
method_setImplementation(method, newImpl);
}
}
free(methods);
}
void swizzleUIColor(NSDictionary<NSString *, NSString *> *rawColors) {
RevengeLog(@"Swizzling UIColor");
Class UIColorClass = NSClassFromString(@"UIColor");
Class target = object_getClass(UIColorClass);
unsigned int methodCount;
Method *methods = class_copyMethodList(target, &methodCount);
for (unsigned int i = 0; i < methodCount; i++) {
Method method = methods[i];
SEL selector = method_getName(method);
NSString *methodName = NSStringFromSelector(selector);
NSString *rawColor = rawColors[methodName];
if (rawColor) {
RevengeLog(@"Swizzling %@", methodName);
id block = ^UIColor *(id self) {
return hexToUIColor(rawColor);
};
IMP newImpl = imp_implementationWithBlock(block);
method_setImplementation(method, newImpl);
}
}
free(methods);
}

227
Sources/Tweak.xm Normal file
View file

@ -0,0 +1,227 @@
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <jsi/jsi.h>
#import "Utils.h"
#import "Logger.h"
#import "Theme.h"
#import "Fonts.h"
#import "LoaderConfig.h"
#import "JSI.h"
#import "RCTHost.h"
using namespace facebook;
static NSURL *source;
static BOOL isJailbroken;
static NSString *revengePatchesBundlePath;
static NSURL *pyoncordDirectory;
static LoaderConfig *loaderConfig;
static BOOL didInject = NO;
/**
* Runs the full Revenge injection sequence (loader payload, downloaded bundle, stored theme,
* fonts, preloads) and hands each script's data off to `execute`.
*
* Historically this fed straight into `RCTCxxBridge -executeApplicationScript:url:async:`
* (React Native's legacy bridge), which is what `execute` still does below for old-arch hosts.
* On current Discord iOS builds (Bridgeless / New Architecture, confirmed via RainTweak -
* codeberg.org/raincord/RainTweak, an actively-shipping loader for a sibling Revenge/Bunny fork
* that explicitly requires "Discord v332 or above" for Bridgeless), that hook point never fires
* at all, so `execute` is instead wired to `JSI evaluate:` against the live runtime - see the
* `RCTHost` hook below, which is the verified entry point.
*/
static BOOL runRevengeInjection(BOOL async, void (^execute)(NSData *data, 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 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 NO;
}
NSData *patchData = [NSData dataWithContentsOfURL:patchPath];
RevengeLog(@"Injecting loader");
execute(patchData, YES);
__block NSData *bundle = [NSData dataWithContentsOfURL:[pyoncordDirectory URLByAppendingPathComponent:@"bundle.js"]];
dispatch_group_t group = dispatch_group_create();
dispatch_group_enter(group);
NSURL *bundleUrl;
if (loaderConfig.customLoadUrlEnabled && loaderConfig.customLoadUrl) {
bundleUrl = loaderConfig.customLoadUrl;
RevengeLog(@"Using custom load URL: %@", bundleUrl.absoluteString);
} else {
bundleUrl = [NSURL URLWithString:@"https://github.com/revenge-mod/revenge-bundle/releases/latest/download/revenge.min.js"];
RevengeLog(@"Using default bundle URL: %@", bundleUrl.absoluteString);
}
NSMutableURLRequest *bundleRequest = [NSMutableURLRequest requestWithURL:bundleUrl
cachePolicy:NSURLRequestReloadIgnoringLocalAndRemoteCacheData
timeoutInterval:3.0];
NSString *bundleEtag = [NSString stringWithContentsOfURL:[pyoncordDirectory URLByAppendingPathComponent:@"etag.txt"]
encoding:NSUTF8StringEncoding
error:nil];
if (bundleEtag && bundle) {
[bundleRequest setValue:bundleEtag forHTTPHeaderField:@"If-None-Match"];
}
NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]];
[[session dataTaskWithRequest:bundleRequest completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
if (httpResponse.statusCode == 200) {
bundle = data;
[bundle writeToURL:[pyoncordDirectory URLByAppendingPathComponent:@"bundle.js"] atomically:YES];
NSString *etag = [httpResponse.allHeaderFields objectForKey:@"Etag"];
if (etag) {
[etag writeToURL:[pyoncordDirectory URLByAppendingPathComponent:@"etag.txt"]
atomically:YES
encoding:NSUTF8StringEncoding
error:nil];
}
}
}
dispatch_group_leave(group);
}] resume];
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
NSString *themeString = [NSString stringWithContentsOfURL:[pyoncordDirectory URLByAppendingPathComponent:@"current-theme.json"]
encoding:NSUTF8StringEncoding
error:nil];
if (themeString) {
NSString *jsCode = [NSString stringWithFormat:@"globalThis.__PYON_LOADER__.storedTheme=%@", themeString];
execute([jsCode dataUsingEncoding:NSUTF8StringEncoding], async);
}
NSData *fontData = [NSData dataWithContentsOfURL:[pyoncordDirectory URLByAppendingPathComponent:@"fonts.json"]];
if (fontData) {
NSError *jsonError;
NSDictionary *fontDict = [NSJSONSerialization JSONObjectWithData:fontData options:0 error:&jsonError];
if (!jsonError && fontDict[@"main"]) {
RevengeLog(@"Found font configuration, applying...");
patchFonts(fontDict[@"main"], fontDict[@"name"]);
}
}
if (bundle) {
RevengeLog(@"Executing JS bundle");
execute(bundle, async);
}
NSURL *preloadsDirectory = [pyoncordDirectory URLByAppendingPathComponent:@"preloads"];
if ([[NSFileManager defaultManager] fileExistsAtPath:preloadsDirectory.path]) {
NSError *error = nil;
NSArray *contents = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:preloadsDirectory
includingPropertiesForKeys:nil
options:0
error:&error];
if (!error) {
for (NSURL *fileURL in contents) {
if ([[fileURL pathExtension] isEqualToString:@"js"]) {
RevengeLog(@"Executing preload JS file %@", fileURL.absoluteString);
NSData *data = [NSData dataWithContentsOfURL:fileURL];
if (data) {
execute(data, async);
}
}
}
} else {
RevengeLog(@"Error reading contents of preloads directory");
}
}
return YES;
}
%hook RCTHost
/**
* Bridgeless-mode entry point (React Native's New Architecture) - fires once the JS runtime is
* ready, letting Discord's own bundle load completely untouched via %orig first. Revenge's own
* scripts are then evaluated directly against the same runtime via JSI, rather than trying to
* masquerade as another RCTSource bundle load (which has no public constructor). This mirrors
* RainTweak's verified approach - see comment on `runRevengeInjection` above.
*/
- (void)instance:(id)instance didInitializeRuntime:(jsi::Runtime &)runtime {
%orig;
if (didInject) return;
didInject = YES;
runRevengeInjection(YES, ^(NSData *data, BOOL async) {
[JSI evaluate:data tag:@"revenge" runtime:runtime];
});
}
%end
%hook RCTCxxBridge
// Legacy-bridge entry point. Fires when the host app runs React Native's old (non-Bridgeless)
// bridge. Kept for hosts that haven't moved to Bridgeless; harmless if RCTCxxBridge is never
// instantiated (Logos skips hooking classes that aren't present at runtime).
- (void)executeApplicationScript:(NSData *)script url:(NSURL *)url async:(BOOL)async {
if (![url.absoluteString containsString:@"main.jsbundle"] || didInject) {
return %orig;
}
didInject = YES;
BOOL injected = runRevengeInjection(async, ^(NSData *data, BOOL scriptAsync) {
%orig(data, source, scriptAsync);
});
if (!injected) {
didInject = NO;
return %orig;
}
%orig(script, url, async);
}
%end
%ctor {
@autoreleasepool {
source = [NSURL URLWithString:@"revenge"];
NSString *install_prefix = @"/var/jb";
isJailbroken = [[NSFileManager defaultManager] fileExistsAtPath:install_prefix];
NSString *bundlePath = [NSString stringWithFormat:@"%@/Library/Application Support/RevengeResources.bundle", install_prefix];
RevengeLog(@"Is jailbroken: %d", isJailbroken);
RevengeLog(@"Bundle path for jailbroken: %@", bundlePath);
NSString *jailedPath = [[NSBundle mainBundle].bundleURL.path stringByAppendingPathComponent:@"RevengeResources.bundle"];
RevengeLog(@"Bundle path for jailed: %@", jailedPath);
revengePatchesBundlePath = isJailbroken ? bundlePath : jailedPath;
RevengeLog(@"Selected bundle path: %@", revengePatchesBundlePath);
BOOL bundleExists = [[NSFileManager defaultManager] fileExistsAtPath:revengePatchesBundlePath];
RevengeLog(@"Bundle exists at path: %d", bundleExists);
NSError *error = nil;
NSArray *bundleContents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:revengePatchesBundlePath error:&error];
if (error) {
RevengeLog(@"Error listing bundle contents: %@", error);
} else {
RevengeLog(@"Bundle contents: %@", bundleContents);
}
pyoncordDirectory = getPyoncordDirectory();
loaderConfig = [[LoaderConfig alloc] init];
[loaderConfig loadConfig];
%init;
}
}

76
Sources/Utils.m Normal file
View file

@ -0,0 +1,76 @@
#import "Utils.h"
BOOL isHermesBytecode(NSData *data) {
if (!data || data.length < 4) return NO;
const uint8_t *bytes = (const uint8_t *)data.bytes;
// Hermes bytecode magic: "Herm"
return bytes[0] == 0x48 && bytes[1] == 0x65 && bytes[2] == 0x72 && bytes[3] == 0x6d;
}
NSURL *getPyoncordDirectory(void) {
NSFileManager *fileManager = [NSFileManager defaultManager];
NSURL *documentDirectoryURL = [[fileManager URLsForDirectory:NSDocumentDirectory
inDomains:NSUserDomainMask] lastObject];
NSURL *pyoncordFolderURL = [documentDirectoryURL URLByAppendingPathComponent:@"pyoncord"];
if (![fileManager fileExistsAtPath:pyoncordFolderURL.path]) {
[fileManager createDirectoryAtURL:pyoncordFolderURL
withIntermediateDirectories:YES
attributes:nil
error:nil];
}
return pyoncordFolderURL;
}
UIColor *hexToUIColor(NSString *hex) {
if (![hex hasPrefix:@"#"]) {
return nil;
}
NSString *hexColor = [hex substringFromIndex:1];
if (hexColor.length == 6) {
hexColor = [hexColor stringByAppendingString:@"ff"];
}
if (hexColor.length == 8) {
unsigned int hexNumber;
NSScanner *scanner = [NSScanner scannerWithString:hexColor];
if ([scanner scanHexInt:&hexNumber]) {
CGFloat r = ((hexNumber & 0xFF000000) >> 24) / 255.0;
CGFloat g = ((hexNumber & 0x00FF0000) >> 16) / 255.0;
CGFloat b = ((hexNumber & 0x0000FF00) >> 8) / 255.0;
CGFloat a = (hexNumber & 0x000000FF) / 255.0;
return [UIColor colorWithRed:r green:g blue:b alpha:a];
}
}
return nil;
}
void showErrorAlert(NSString *title, NSString *message) {
dispatch_async(dispatch_get_main_queue(), ^{
UIAlertController *alert = [UIAlertController alertControllerWithTitle:title
message:message
preferredStyle:UIAlertControllerStyleAlert];
UIAlertAction *okAction = [UIAlertAction actionWithTitle:@"OK"
style:UIAlertActionStyleDefault
handler:nil];
[alert addAction:okAction];
UIWindow *window = nil;
NSArray *windows = [[UIApplication sharedApplication] windows];
for (UIWindow *w in windows) {
if (w.isKeyWindow) {
window = w;
break;
}
}
[window.rootViewController presentViewController:alert animated:YES completion:nil];
});
}

View file

@ -1,6 +1,6 @@
globalThis.__PYON_LOADER__ = { globalThis.__PYON_LOADER__ = {
loaderName: "BunnyTweak", loaderName: "@TWEAK_NAME@",
loaderVersion: "0.3.1", loaderVersion: "@PACKAGE_VERSION@",
hasThemeSupport: true, hasThemeSupport: true,
storedTheme: null, storedTheme: null,
fontPatch: 2 fontPatch: 2

View file

@ -1,52 +1,46 @@
{ {
"name": "Pyoncord App Repo", "name": "Revenge App Repo",
"identifier": "io.github.pyoncord.repo", "identifier": "io.github.revenge-mod.repo",
"iconURL": "https://pyoncord.github.io/assets/icon-altstore.png", "iconURL": "https://avatars.githubusercontent.com/u/159218871?s=200&v=4",
"apps": [ "apps": [
{ {
"name": "Bunny", "name": "Revenge",
"bundleIdentifier": "com.hammerandchisel.discord", "bundleIdentifier": "com.hammerandchisel.discord",
"developerName": "Pylix", "developerName": "Cristiandis",
"iconURL": "https://pyoncord.github.io/assets/icon-altstore.png", "iconURL": "https://avatars.githubusercontent.com/u/159218871?s=200&v=4",
"localizedDescription": "A client mod for Discord", "localizedDescription": "A client mod for Discord",
"subtitle": "A client mod for Discord", "subtitle": "A client mod for Discord",
"tintColor": "7289da", "tintColor": "7289da",
"versions": [ "versions": [
{ {
"version": "235.0", "version": "254.0",
"date": "2024-06-27", "date": "2024-11-15",
"size": 112173273, "size": 98005042,
"downloadURL": "https://github.com/pyoncord/BunnyTweak/releases/download/v235.0/Bunny.ipa" "downloadURL": "https://github.com/revenge-mod/revenge-tweak/releases/download/v254.0/Revenge.ipa"
}, },
{ {
"version": "234.0", "version": "250.0",
"date": "2024-06-24", "date": "2024-10-20",
"size": 111671815, "size": 101218873,
"downloadURL": "https://github.com/pyoncord/BunnyTweak/releases/download/v234.0/Bunny.ipa" "downloadURL": "https://github.com/revenge-mod/revenge-tweak/releases/download/v250.0/Revenge.ipa"
}, },
{ {
"version": "230.0", "version": "250.0",
"date": "2024-05-18", "date": "2024-10-17",
"size": 102457139, "size": 101218840,
"downloadURL": "https://github.com/pyoncord/BunnyTweak/releases/download/v230.0/Bunny.ipa" "downloadURL": "https://github.com/revenge-mod/revenge-tweak/releases/download/v250.0/Revenge.ipa"
}, },
{ {
"version": "229.0", "version": "246.0",
"date": "2024-05-16", "date": "2024-09-15",
"size": 103776118, "size": 102811465,
"downloadURL": "https://github.com/pyoncord/BunnyTweak/releases/download/v229.0/Bunny.ipa" "downloadURL": "https://github.com/revenge-mod/RevengeTweak/releases/download/v246.0/Revenge.ipa"
}, },
{ {
"version": "226.1", "version": "246.0",
"date": "2024-04-30", "date": "2024-09-14",
"size": 102705408, "size": 103194076,
"downloadURL": "https://github.com/pyoncord/BunnyTweak/releases/download/v226.1/Pyoncord.ipa" "downloadURL": "https://github.com/revenge-mod/RevengeTweak/releases/download/v246.0/Revenge.ipa"
},
{
"version": "223.0",
"date": "2024-04-08",
"size": 106082919,
"downloadURL": "https://github.com/pyoncord/BunnyTweak/releases/download/v223.0/Pyoncord.ipa"
} }
], ],
"appPermissions": {} "appPermissions": {}

View file

@ -0,0 +1,4 @@
<svg width="320" height="320" viewBox="0 0 320 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.26979 99H294.723L312.234 215.273C312.723 222.597 310.021 229.993 304.839 235.184L265.514 274.935C260.741 279.717 254.27 283.273 247.514 283.273H182.785C168.723 283.273 152.981 270.126 152.981 256.073V255.61C152.981 250.056 156.895 244.851 160.088 240.605L160.088 240.605L160.288 240.339L75.9944 236.224C73.9944 236.135 69.0077 236.055 62.9899 235.984C43.5233 235.762 27.1144 221.37 24.4033 202.09L8.74994 103.006C8.84706 101.648 9.02178 100.311 9.26979 99ZM84.5458 137.592L139.983 152.446C139.994 152.788 140 153.132 140 153.477C140 170.045 126.568 183.477 110 183.477C93.4314 183.477 79.9999 170.045 79.9999 153.477C79.9999 147.643 81.665 142.198 84.5458 137.592ZM180.019 152.4L235.429 137.553C238.325 142.168 240 147.627 240 153.477C240 170.045 226.568 183.477 210 183.477C193.431 183.477 180 170.045 180 153.477C180 153.116 180.006 152.757 180.019 152.4Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M288.328 82.0258C288.569 83.4188 289.198 84.7073 290.018 85.8593C292.71 89.6425 294.372 94.0776 294.688 98.766L295.899 106.809C296.628 111.647 292.881 116 287.988 116H17.6382C13.7018 116 10.3504 113.136 9.73617 109.248L8.89299 103.911C8.79798 103.31 8.76815 102.697 8.82742 102.091C10.4271 85.7347 23.3039 72.6711 39.7278 70.9082L87.1856 63.328C89.9772 62.8821 92.7967 63.9426 94.6025 66.1175L105.915 79.7431C107.719 81.9152 110.533 82.9759 113.322 82.5343L203.656 68.2267C205.851 67.8792 207.802 66.6347 209.042 64.7913L222.878 44.2296C224.116 42.3892 226.063 41.1457 228.253 40.7958L247.639 37.6994C265.683 35.7705 282.181 48.0371 285.523 65.8683L288.328 82.0258Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,4 @@
<svg width="320" height="320" viewBox="0 0 320 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.26979 99H294.723L312.234 215.273C312.723 222.597 310.021 229.993 304.839 235.184L265.514 274.935C260.741 279.717 254.27 283.273 247.514 283.273H182.785C168.723 283.273 152.981 270.126 152.981 256.073V255.61C152.981 250.056 156.895 244.851 160.088 240.605L160.088 240.605L160.288 240.339L75.9944 236.224C73.9944 236.135 69.0077 236.055 62.9899 235.984C43.5233 235.762 27.1144 221.37 24.4033 202.09L8.74994 103.006C8.84706 101.648 9.02178 100.311 9.26979 99ZM84.5458 137.592L139.983 152.446C139.994 152.788 140 153.132 140 153.477C140 170.045 126.568 183.477 110 183.477C93.4314 183.477 79.9999 170.045 79.9999 153.477C79.9999 147.643 81.665 142.198 84.5458 137.592ZM180.019 152.4L235.429 137.553C238.325 142.168 240 147.627 240 153.477C240 170.045 226.568 183.477 210 183.477C193.431 183.477 180 170.045 180 153.477C180 153.116 180.006 152.757 180.019 152.4Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M288.328 82.0258C288.569 83.4188 289.198 84.7073 290.018 85.8593C292.71 89.6425 294.372 94.0776 294.688 98.766L295.899 106.809C296.628 111.647 292.881 116 287.988 116H17.6382C13.7018 116 10.3504 113.136 9.73617 109.248L8.89299 103.911C8.79798 103.31 8.76815 102.697 8.82742 102.091C10.4271 85.7347 23.3039 72.6711 39.7278 70.9082L87.1856 63.328C89.9772 62.8821 92.7967 63.9426 94.6025 66.1175L105.915 79.7431C107.719 81.9152 110.533 82.9759 113.322 82.5343L203.656 68.2267C205.851 67.8792 207.802 66.6347 209.042 64.7913L222.878 44.2296C224.116 42.3892 226.063 41.1457 228.253 40.7958L247.639 37.6994C265.683 35.7705 282.181 48.0371 285.523 65.8683L288.328 82.0258Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

16
control
View file

@ -1,11 +1,11 @@
Package: io.github.pyoncord.app Package: io.github.revenge-mod.app
Name: Bunny Name: Revenge
Version: 0.3.1 Version: 2.0.1
Architecture: iphoneos-arm Architecture: iphoneos-arm
Description: A mod for Discord's mobile apps. Description: A mod for Discord's mobile apps.
Maintainer: Pylix Maintainer: Cristiandis
Author: Pylix, FieryFlames Author: Pylix, Adrian Castro, FieryFlames, Cristiandis
Icon: https://avatars.githubusercontent.com/u/132727175 Icon: https://avatars.githubusercontent.com/u/159218871
Section: Tweaks Section: Tweaks
Depends: ${ORION}, firmware (>= 13.0) Depends: mobilesubstrate (>= 0.9.5000)
Conflicts: app.enmity, mod.vendetta Conflicts: app.enmity, mod.vendetta, app.bunny