Skip to main content

What is the native bridge?

The native API resolves the app’s React Native native modules and exposes the ones Unbound uses most often. Use it for capabilities that live in native code rather than in JavaScript. The loader also installs two JSI globals:
  • NativePlugin is the versioned generic native plugin bridge.
  • NativePlatform contains platform-wide helpers that are not plugin-specific.
Neither global contains feature-specific native code. Plugins build their own behavior in JavaScript on top of the generic bridge.

Resolving native modules

Native module names change between Discord versions, so getNativeModule accepts several candidate names in priority order. It checks them against both NativeModules and the TurboModule registry and returns the first match.

Built-in handles

Unbound resolves BundleInfo, BundleManager, DeviceInfo, and the file manager for you. For file operations, use fs instead of the file manager directly, because fs handles paths and encodings.

Reloading

reload() saves pending settings, then asks the bundle manager to reload.

Platform helpers

NativePlatform exposes device information, app provenance, and Hermes bytecode evaluation. It is optional because it exists only inside a running Unbound loader environment.
NativePlatform.evaluateBytecode takes a Hermes ArrayBuffer and evaluates it in the current runtime. It throws if the argument is not valid Hermes bytecode or if evaluation fails.

Generic plugin bridge

NativePlugin is the public JavaScript interface to the native ABI. The loader publishes it as globalThis.NativePlugin. The client then scopes it to each plugin based on the capabilities in that plugin’s manifest.
The bridge exposes these version and capability fields:
string
The version of the JavaScript API that plugins use.
string
The version of the native ABI that the loader implements.
string[]
The native capabilities available in this loader build.

Objective-C operations

The objc namespace supports:
  • Class lookup, allocation, selector calls, and superclass calls
  • Ivar access and associated objects
  • Registered structs, arrays, and data conversion
  • Generic hooks
Hooks follow these rules:
  • Hook arguments are borrowed until the callback returns. Retain any value that needs to outlive the callback.
  • original() is synchronous only when the callback runs on the hook thread. Calls from another thread fail closed to avoid deadlocks.
  • Disposing a plugin context removes its hooks, associations, and bridge resources. Any later call through that context is rejected.
call runs on the current runtime thread. invoke takes an explicit thread policy, such as current or main. A synchronous call across threads that the bridge doesn’t support fails with a structured error. Results and errors:
  • Objective-C NSData results are returned as Uint8Array values.
  • Native bridge failures are thrown, or rejected for asynchronous calls, as JavaScript errors with a stable code property on both iOS and Android. A coded native failure keeps its own code; other native runtime failures use the code NATIVE_BRIDGE_ERROR.
  • Client-side negotiation errors have their own codes for unavailable bridges, missing capabilities, disposed scopes, and unsupported minimum API versions.

Raw FFI operations

The ffi namespace resolves symbols from images that are already loaded and calls them. Call signatures must be non-variadic and can use scalar, pointer, Objective-C, and registered struct types.
Both platforms run the same FFI implementation, so the same calls behave the same way on iOS and Android. Android has no Objective-C types, so its signatures use the scalar, cstring, pointer, and struct types. iOS adds object, class, and selector.
  • A signature takes at most 16 arguments, and no argument can be void.
  • null or undefined passed as a cstring becomes a null pointer.
  • Struct arguments can be { name, value } objects, plain field objects, or objc.struct values. Struct results have name and value properties, for example { name: 'timespec', value: { tv_sec: 1n, tv_nsec: 0n } }. On iOS, UIKit and Foundation structs come back as objc.struct values, so you can pass them straight to Objective-C calls.
  • The registered structs are CGPoint, CGSize, CGVector, CGRect, NSRange, UIEdgeInsets, CGAffineTransform, timespec, and timeval.
  • i64 and u64 results are BigInt values.
The v1 bridge does not expose:
  • Arbitrary memory access or dlopen
  • Variadic calls or plugin-supplied native closures
  • Unknown structs, vectors, unions, or bitfields

Fabric surfaces

The fabric namespace mounts a registered React Native component inside an existing UIKit view. Plugins use it to show Discord’s real native message component instead of a second, JavaScript-only approximation.
Fabric surfaces are created and destroyed on the UIKit main thread. The JavaScript API stays synchronous and fails closed on timeout. Android offers only the asynchronous variants (mountAsync, updateAsync, setSizeAsync, setFrameAsync, measureAsync, and unmountAsync), so Android plugins declare native.fabric.async without native.fabric.mount. Sizes and frames must be finite. Widths and heights must be between 0 and 32,768, and x and y between -100,000 and 100,000. Surface properties are limited to 8 levels of nesting, 512 entries, 16 KiB in total, 4 KiB per string, and property names of 1 to 64 bytes. Surface handles belong to the plugin scope. They are unmounted automatically when the scope is disposed.

Capabilities and lifecycle

In the plugin manifest, declare only the capabilities the plugin needs:
  • Objective-C: native.objc.classes, native.objc.invoke, native.objc.ivars, native.objc.associations, native.objc.hooks
  • FFI: native.ffi.symbols, native.ffi.call
  • Fabric: native.fabric.mount (iOS only), native.fabric.async
FFI calls skip the bridge’s type and object checks, so the user has to allow them. The first time an unsigned plugin that declares native.ffi.symbols or native.ffi.call is enabled, Unbound asks whether it may call native functions directly, and it starts only if the user allows it. It asks again when an update adds another FFI capability. Until then, an FFI plugin that was enabled before does not start. First-party plugins signed by the Unbound publish pipeline never ask. Before calling start(context), the addon manager validates the capability names and the optional minimum API version. context.native is scoped to the plugin. context.dispose() is called during stop, unload, reload, and cleanup after a failed start. Existing plugins whose start function takes no context still work. New plugins should use the scoped context so native resources can’t outlive the plugin’s lifecycle.