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.
This commit is contained in:
Ally Founder 2026-08-03 10:10:32 -04:00
parent dba11af542
commit 0044c2c12a
11 changed files with 2580 additions and 102 deletions

View file

@ -39,27 +39,44 @@ Architecture together, so if Android Discord is Bridgeless, iOS Discord almost
certainly is too — meaning `RCTCxxBridge` is likely never instantiated on current
Discord iOS builds, and the tweak's only injection point silently never fires.
**Fix applied (unverified):** added a second hook, `%hook RCTInstance` /
`-_loadScriptFromSource:`, the Bridgeless-mode equivalent, confirmed against React
Native's actual upstream source
(`ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm`). Both hooks
now share one injection pipeline (`runRevengeInjection`, factored out of the old
single hook body); Logos skips a `%hook` target gracefully if the class isn't
present at runtime, so shipping both is safe regardless of which architecture the
installed Discord build actually uses.
**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.
Since `RCTSource` (the type `_loadScriptFromSource:` takes) has no public
initializer, `makeRCTSource()` (`Sources/Utils.m`) builds one by poking its private
ivars directly — the same technique RN's own private `RCTSourceCreate()` uses
internally.
**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):
**This has not been run against a real device or the current Discord iOS binary.**
It's a best-effort port grounded in verified upstream RN source and the confirmed
Android hook target, not a tested fix. Needs a jailbreak + Xcode/Theos to validate;
if `_loadScriptFromSource:` turns out to have a different selector name in Discord's
actual build (obfuscation is unlikely for framework code, but app-specific
subclasses could differ), it'll need adjusting via class-dump/Frida against the
live binary.
- `%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)

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

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

View file

@ -5,9 +5,5 @@ NSURL *getPyoncordDirectory(void);
UIColor *hexToUIColor(NSString *hex);
void showErrorAlert(NSString *title, NSString *message);
/**
* Builds an `RCTSource` (react/runtime's bridgeless script container) by poking its private
* ivars directly, since React Native doesn't expose a public initializer for it.
* Returns nil if the `RCTSource` class isn't present in the running binary.
*/
id makeRCTSource(NSURL *url, NSData *data);
/** 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

@ -7,8 +7,9 @@ include $(THEOS)/makefiles/common.mk
TWEAK_NAME = Revenge
BUNDLE_NAME = RevengeResources
Revenge_FILES = $(wildcard Sources/*.x Sources/*.m Sources/**/*.x Sources/**/*.m)
Revenge_FILES = $(wildcard Sources/*.x Sources/*.xm Sources/*.m Sources/*.mm Sources/**/*.x Sources/**/*.xm Sources/**/*.m Sources/**/*.mm)
Revenge_CFLAGS = -fobjc-arc -DPACKAGE_VERSION='@"$(THEOS_PACKAGE_BASE_VERSION)"' -I$(THEOS_PROJECT_DIR)/Headers
Revenge_CCFLAGS = -std=c++17 -Wno-c++11-narrowing -I$(THEOS_PROJECT_DIR)/Headers
Revenge_FRAMEWORKS = Foundation UIKit CoreGraphics CoreText CoreFoundation
RevengeResources_INSTALL_PATH = "/Library/Application\ Support/"

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

View file

@ -1,27 +1,36 @@
#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 off to `execute`, then finally executes the host's
* own `script`/`url`/`async` once everything else has run.
* fonts, preloads) and hands each script's data off to `execute`.
*
* Factored out so it can be driven by either the legacy-bridge hook (RCTCxxBridge, used when
* the host app runs React Native's old bridge) or the bridgeless-mode hook (RCTInstance, used
* under React Native's New Architecture) below - both eventually funnel through here.
* 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(NSData *script, NSURL *url, BOOL async, void (^execute)(NSData *data, NSURL *url, BOOL async)) {
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);
@ -38,7 +47,7 @@ static BOOL runRevengeInjection(NSData *script, NSURL *url, BOOL async, void (^e
NSData *patchData = [NSData dataWithContentsOfURL:patchPath];
RevengeLog(@"Injecting loader");
execute(patchData, source, YES);
execute(patchData, YES);
__block NSData *bundle = [NSData dataWithContentsOfURL:[pyoncordDirectory URLByAppendingPathComponent:@"bundle.js"]];
@ -92,7 +101,7 @@ static BOOL runRevengeInjection(NSData *script, NSURL *url, BOOL async, void (^e
error:nil];
if (themeString) {
NSString *jsCode = [NSString stringWithFormat:@"globalThis.__PYON_LOADER__.storedTheme=%@", themeString];
execute([jsCode dataUsingEncoding:NSUTF8StringEncoding], source, async);
execute([jsCode dataUsingEncoding:NSUTF8StringEncoding], async);
}
NSData *fontData = [NSData dataWithContentsOfURL:[pyoncordDirectory URLByAppendingPathComponent:@"fonts.json"]];
@ -107,7 +116,7 @@ static BOOL runRevengeInjection(NSData *script, NSURL *url, BOOL async, void (^e
if (bundle) {
RevengeLog(@"Executing JS bundle");
execute(bundle, source, async);
execute(bundle, async);
}
NSURL *preloadsDirectory = [pyoncordDirectory URLByAppendingPathComponent:@"preloads"];
@ -123,7 +132,7 @@ static BOOL runRevengeInjection(NSData *script, NSURL *url, BOOL async, void (^e
RevengeLog(@"Executing preload JS file %@", fileURL.absoluteString);
NSData *data = [NSData dataWithContentsOfURL:fileURL];
if (data) {
execute(data, source, async);
execute(data, async);
}
}
}
@ -135,52 +144,48 @@ static BOOL runRevengeInjection(NSData *script, NSURL *url, BOOL async, void (^e
return YES;
}
%hook RCTCxxBridge
%hook RCTHost
// Legacy-bridge entry point. Fires when the host app runs React Native's old (non-Bridgeless) bridge.
- (void)executeApplicationScript:(NSData *)script url:(NSURL *)url async:(BOOL)async {
if (![url.absoluteString containsString:@"main.jsbundle"]) {
return %orig;
}
/**
* 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;
BOOL injected = runRevengeInjection(script, url, async, ^(NSData *data, NSURL *scriptUrl, BOOL scriptAsync) {
%orig(data, scriptUrl, scriptAsync);
if (didInject) return;
didInject = YES;
runRevengeInjection(YES, ^(NSData *data, BOOL async) {
[JSI evaluate:data tag:@"revenge" runtime:runtime];
});
if (!injected) return %orig;
%orig(script, url, async);
}
%end
%hook RCTInstance
%hook RCTCxxBridge
/**
* Bridgeless-mode (React Native's New Architecture) entry point. Discord's Android build moved
* to this architecture (see RevengeXposed's `com.facebook.react.runtime.ReactInstance$loadJSBundle$1`
* and `BridgelessDevSupportManager` hook targets), and the JS bundle now pins react-native 0.81,
* which defaults to it too - so this is likely the entry point that actually fires on current
* Discord iOS builds, not `RCTCxxBridge` above.
*
* `RCTInstance` has no public initializer for `RCTSource`, so `makeRCTSource` pokes its private
* ivars directly to build synthetic sources for the loader payload/bundle/theme/preload scripts.
* UNVERIFIED against a real device/current Discord binary - test before shipping.
*/
- (void)_loadScriptFromSource:(id)source {
NSURL *url = [source valueForKey:@"url"];
NSData *script = [source valueForKey:@"data"];
// 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;
if (![url.absoluteString containsString:@"main.jsbundle"]) {
BOOL injected = runRevengeInjection(async, ^(NSData *data, BOOL scriptAsync) {
%orig(data, source, scriptAsync);
});
if (!injected) {
didInject = NO;
return %orig;
}
BOOL injected = runRevengeInjection(script, url, YES, ^(NSData *data, NSURL *scriptUrl, BOOL scriptAsync) {
id syntheticSource = makeRCTSource(scriptUrl, data);
if (syntheticSource) %orig(syntheticSource);
});
if (!injected) return %orig;
%orig(source);
%orig(script, url, async);
}
%end
@ -213,10 +218,10 @@ static BOOL runRevengeInjection(NSData *script, NSURL *url, BOOL async, void (^e
RevengeLog(@"Bundle contents: %@", bundleContents);
}
pyoncordDirectory = getPyoncordDirectory();
pyoncordDirectory = getPyoncordDirectory();
loaderConfig = [[LoaderConfig alloc] init];
[loaderConfig loadConfig];
%init;
}
}

View file

@ -1,35 +1,10 @@
#import "Utils.h"
#import <objc/runtime.h>
id makeRCTSource(NSURL *url, NSData *data) {
Class RCTSourceClass = NSClassFromString(@"RCTSource");
if (!RCTSourceClass) return nil;
id source = [RCTSourceClass new];
// RCTSource has no public initializer (see RCTJavaScriptLoader.mm's private
// RCTSourceCreate()), so its private ivars are poked directly instead.
Ivar urlIvar = class_getInstanceVariable(RCTSourceClass, "_url");
Ivar dataIvar = class_getInstanceVariable(RCTSourceClass, "_data");
Ivar lengthIvar = class_getInstanceVariable(RCTSourceClass, "_length");
Ivar filesChangedCountIvar = class_getInstanceVariable(RCTSourceClass, "_filesChangedCount");
if (urlIvar) object_setIvar(source, urlIvar, url);
if (dataIvar) object_setIvar(source, dataIvar, data);
// _length and _filesChangedCount are scalars (NSUInteger / NSInteger), not objects,
// so they need direct memory writes at the ivar's offset instead of object_setIvar.
if (lengthIvar) {
ptrdiff_t offset = ivar_getOffset(lengthIvar);
*(NSUInteger *)((uint8_t *)(__bridge void *)source + offset) = data.length;
}
if (filesChangedCountIvar) {
ptrdiff_t offset = ivar_getOffset(filesChangedCountIvar);
// -2 == RCTSourceFilesChangedCountNotBuiltByBundler, matching sources not built by Metro.
*(NSInteger *)((uint8_t *)(__bridge void *)source + offset) = -2;
}
return source;
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) {