Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

How to access all the Objective‑C APIs using JSI

Jamie Birch
November 04, 2023

How to access all the Objective‑C APIs using JSI

My talk for React Native EU 2022.

Relates to:
https://github.com/shirakaba/rnobjc
(… which is a fork of):
https://github.com/shirakaba/react-native-native-runtime

Jamie Birch

November 04, 2023
Tweet

More Decks by Jamie Birch

Other Decks in Technology

Transcript

  1. Contents • About me • Background: Accessing native APIs is

    a pain • How to use JSI • Accessing a serialisable API from Obj-C • Accessing arbitrary non-serialisable APIs from Obj-C • Conclusion
  2. Who’s talking? Jamie Birch, all-platform dev
 🌐 Software Engineer at

    Birchill Maker of LinguaBrowse NativeScript TSC Enemy of native code
  3. Accessing native APIs is a pain • It’s boilerplate city

    • May involve installing (and possibly forking) an npm module • Requires rebuilding your app • Requires switching context
  4. We could call all native APIs directly from JS! (C#,

    but same concept) (by Phonegap) (by DiDi) DynamicCocoa (by bang590) JSPatch function callPhoneNumber(phoneNumber: string): void { const url: NSURL = NSURL.URLWithString(`tel://${phoneNumber}`); if(!url || !UIApplication.sharedApplication.canOpenURL(url)){ throw new Error('Phone number invalid, or permissions missing.'); } UIApplication.sharedApplication.openURLOptionsCompletionHandler( url, NSDictionary.alloc().init(), (success: boolean) => console.log(success), ); }
  5. App setup We’ll create a standard React Native project from

    the TypeScript template. 1. Create a fresh project: npx react-native init rnobjc --template
 react-native-template-typescript 2. Open the Xcode workspace: open rnobjc/ios/rnobjc.xcworkspace 3. Run the target scheme, rnobjc. … If this Hello World app fails to run, you’ll need to fi x your environment! Part 1: Hello World
  6. App setup Part 2: Native module fi le setup Alongside

    your AppDelegate fi les, let’s create a folder for our native module, starting with a couple of empty fi les.
  7. JSI module setup Part 1/7: ObjcRuntime.h - the interface #import

    <React/RCTBridgeModule.h> @interface ObjcRuntime : NSObject <RCTBridgeModule> @end
  8. JSI module setup Part 2/7: ObjcRuntime.mm - stub implementation #import

    “ObjcRuntime.h" #import <React/RCTBridge+Private.h> @implementation ObjcRuntime RCT_EXPORT_MODULE() + (BOOL)requiresMainQueueSetup { return YES; } // The installation lifecycle - (void)setBridge:(RCTBridge *)bridge {} // The cleanup lifecycle - (void)invalidate {} @end
  9. JSI module setup Part 3/7: ObjcRuntime.mm - grabbing the bridge

    #import "ObjcGlobal.h" #import <React/RCTBridge+Private.h> @implementation ObjcRuntime @synthesize bridge = m_bridge; // 1⃣ Create a setter named `m_bridge` for `self.bridge`. RCT_EXPORT_MODULE() + (BOOL)requiresMainQueueSetup { return YES; } // The installation lifecycle - (void)setBridge:(RCTBridge *)bridge { // 2⃣ Store the bridge so that we can access it later in the `invalidate` method. m_bridge = bridge; } // The cleanup lifecycle - (void)invalidate {} @end
  10. JSI module setup Part 4/7: ObjcRuntime.mm - grabbing the JSI

    runtime #import "ObjcRuntime.h" #import <React/RCTBridge+Private.h> #import <jsi/jsi.h> // 1⃣ Imports all the JSI APIs we need, e.g. `facebook::jsi`. using namespace facebook; // 2⃣ Shortens `facebook::jsi::Runtime` to `jsi::Runtime`. @implementation ObjcRuntime @synthesize bridge = m_bridge; RCT_EXPORT_MODULE() + (BOOL)requiresMainQueueSetup { return YES; } // The installation lifecycle - (void)setBridge:(RCTBridge *)bridge { m_bridge = bridge; // 3⃣ Grab the JSI runtime. RCTCxxBridge *cxxBridge = (RCTCxxBridge *)m_bridge; jsi::Runtime *runtime = (jsi::Runtime *)cxxBridge.runtime; if (!runtime) { return; } } // …
  11. JSI module setup Part 5/7: ObjcRuntime.mm - setting a property

    on the global object // … // The installation lifecycle - (void)setBridge:(RCTBridge *)bridge { m_bridge = bridge; RCTCxxBridge *cxxBridge = (RCTCxxBridge *)m_bridge; jsi::Runtime *runtime = (jsi::Runtime *)cxxBridge.runtime; if (!runtime) { return; } // 1⃣ Create a JSI string from a C string. jsi::String jsiString = jsi::String::createFromAscii(*runtime, "A C string!"); // 2⃣ Set global.objc = jsiString. runtime->global().setProperty(*runtime, "objc", jsiString); } // …
  12. JSI module setup Part 6/7: ObjcRuntime.mm - cleanup // …

    // The cleanup lifecycle - (void)invalidate { // Grab the JSI runtime. RCTCxxBridge *cxxBridge = (RCTCxxBridge *)m_bridge; jsi::Runtime *runtime = (jsi::Runtime *)cxxBridge.runtime; if (!runtime) { return; } // Set global.objc = undefined. runtime->global().setProperty(*runtime, "objc", jsi::Value::undefined()); } // …
  13. JSI module setup Part 7/7: App.tsx - call it! //

    1⃣ Declare this at the top level of your file declare const objc: any; // 2⃣ Use this effect in your App component. React.useEffect(() => console.log('objc:', objc), []);
  14. JSI in action Hopefully, at this point you see the

    text: objc: A C string! If not, you may have run into a startup race condition (resolvable by refreshing the JS bundle). For a workaround, see margelo/ react-native-quick-crypto - just search for: “RCT_EXPORT_BLOCKING_SYNCH RONOUS_METHOD”.
  15. JSI types cheatsheet Part 1/4 • String jsi::String::createFromAscii(*rt, "A C

    string!"); // createFromUtf8 also works. jsi::String::createFromUtf8(*rt, @"An NSString".UTF8String); • Number jsi::Value(123); // C number jsi::Value([NSNumber numberWithDouble:3.1415926].doubleValue); // NSNumber longhand jsi::Value((@3.1415926).doubleValue); // LLVM shorthand to instantiate an NSNumber • Boolean jsi::Value(TRUE); // Boolean (unsigned char) jsi::Value(YES); // Obj-C BOOL (signed char) // The following do not work: // true, false, @(YES), @(NO), kCFBooleanTrue, kCFBooleanFalse, 0, 1
  16. JSI types cheatsheet Part 2/4 • Null jsi::Value(nullptr); jsi::Value::null(); •

    Undefined jsi::Value::undefined(); • Object jsi::Object(*rt); • HostObject jsi::Object::createFromHostObject(*rt, std::make_shared<jsi::HostObject>()); // We’ll come back to HostObject later!
  17. JSI types cheatsheet Part 3/4 • BigInt (under development!) jsi::BigInt::createBigIntFromInt64(*rt,

    -9223372036854775808); jsi::BigInt::createBigIntFromUint64(*rt, 18446744073709551615); • Symbol // I think you can only clone them from the JS context - not create them afresh.
  18. JSI types cheatsheet Part 4/4 • Function // The host

    function auto sum = [] ( jsi::Runtime& rt, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count ) -> jsi::Value { return jsi::Value(arguments[0].asNumber() + arguments[1].asNumber()); }; // The JSI function jsi::Function::createFromHostFunction( *rt, jsi::PropNameID::forAscii(*rt, "sum"), // The name for the function in JS 2, // The number of arguments sum // The host function );
  19. // Forked from Marc Rousavy’s react-native-vision-camera; MIT licence. // (Copyright

    © 2021 mrousavy, all rights reserved). // // Originally extracted from RCTTurboModule.mm. // Copyright (c) Facebook, Inc. and its affiliates. See MIT licence in react-native. // … jsi::String convertNSStringToJSIString(jsi::Runtime &runtime, NSString *value) { return jsi::String::createFromUtf8(runtime, [value UTF8String] ?: ""); } NSString *convertJSIStringToNSString(jsi::Runtime &runtime, const jsi::String &value) { return [NSString stringWithUTF8String:value.utf8(runtime).c_str()]; } // … JSIUtils.mm - an excerpt Helpers to convert between JSI and Obj-C primitives
  20. Objectives • Create a HostObject via JSI, that can be

    accessed globally from JS as objc. • The HostObject will provide an experience similar to writing Objective-C: • Constant access: objc.NSStringTransformLatinToHiragana • Class access: objc.NSString • Method calls: objc.NSString.alloc() • Param marshalling: objc.NSString.alloc()['initWithString:']('Hello') • Getters (we won’t have time to cover setters):
 const blueColor = objc.UIColor.blueColor
  21. First off, let’s change our `objc` value to be a

    subclass of jsi::HostObject instead.
  22. Making `objc` into a subclass of jsi::HostObject • Add two

    empty fi les, HostObjectObjc.h and HostObjectObjc.mm. Part 1/5: File setup
  23. Part 2/5: HostObjectObjc.h - the interface #import <jsi/jsi.h> using namespace

    facebook; class JSI_EXPORT HostObjectObjc: public jsi::HostObject { public: jsi::Value get(jsi::Runtime& rt, const jsi::PropNameID& name) override; std::vector<jsi::PropNameID> getPropertyNames(jsi::Runtime& rt) override; }; Making `objc` into a subclass of jsi::HostObject
  24. Part 3/5: HostObjectObjc.mm - stub implementation #import "HostObjectObjc.h" // Returns

    the value for any given property accessed. jsi::Value HostObjectObjc::get(jsi::Runtime& rt, const jsi::PropNameID& propName) { // For now, we'll return undefined. return jsi::Value::undefined(); } // Returns the list of keys. std::vector<jsi::PropNameID> HostObjectObjc::getPropertyNames(jsi::Runtime& rt) { std::vector<jsi::PropNameID> result; // For now, we'll return an empty array. return result; } Making `objc` into a subclass of jsi::HostObject
  25. Part 4/5: ObjcRuntime.mm - exposing HostObjectObjc #import "HostObjectObjc.h" Go back

    to ObjcRuntime.mm and add this import: … and update our runtime->global().setProperty() call so that it sets an instance of our HostObjectObjc on `objc` instead of a string: Making `objc` into a subclass of jsi::HostObject - jsi::String jsiString = jsi::String::createFromAscii(*runtime, "A C string!"); runtime->global().setProperty( *runtime, "objc", - jsi::Object::createFromHostObject(*runtime, jsiString) + jsi::Object::createFromHostObject(*runtime, std::make_shared<HostObjectObjc>()) );
  26. Now that objc returns a HostObject, let’s have a look

    at it: console.log('objc:', objc); console.log('Object.keys(objc):', Object.keys(objc)); console.log('objc.toString():', objc.toString()); objc prints as an empty object, {}, with no keys: [] … and toString() just throws an error, as it hasn’t been implemented yet! Making `objc` into a subclass of jsi::HostObject Part 5/5: App.tsx - logging HostObjectObjc
  27. Exposing a serialisable Objective-C API • Here’s the API declaration:

    const NSStringTransform NSStringTransformLatinToHiragana; • NSStringTransform is just an NSString by another name. • It’s a global variable, so we want to expose it on the JS side via: objc.NSStringTransformLatinToHiragana • We want it to return the string value of that variable.
  28. Exposing a serialisable API #import "HostObjectObjc.h" #import <Foundation/Foundation.h> // 1⃣

    Import Foundation! jsi::Value HostObjectObjc::get(jsi::Runtime& rt, const jsi::PropNameID& propName) { auto name = propName.utf8(rt); // 2⃣ Add a case to handle NSStringTransformLatinToHiragana. if (name == "NSStringTransformLatinToHiragana"){ return jsi::String::createFromUtf8(rt, NSStringTransformLatinToHiragana.UTF8String); } return jsi::Value::undefined(); } Part 1/3: HostObjectObjc.mm - the getter
  29. Exposing a serialisable API std::vector<jsi::PropNameID> HostObjectObjc::getPropertyNames(jsi::Runtime& rt) { std::vector<jsi::PropNameID> result;

    // 1⃣ Add "NSStringTransformLatinToHiragana" as an enumerable key. result.push_back(jsi::PropNameID::forAscii(rt, "NSStringTransformLatinToHiragana")); return result; } Part 2/3: HostObjectObjc.mm - updating the enumerable keys
  30. Let’s have a look at the new behaviour: console.log('objc:', objc);

    console.log('objc.NSStringTransformLatinToHiragana:', objc.NSStringTransformLatinToHiragana); console.log('Object.keys(objc):', Object.keys(objc)); Exposing a serialisable API Part 3/3: App.tsx - the result … Congratulations, you just proxied a native API! 🎉
  31. Exposing non-serialisable APIs We’ll approach this by improving HostObjectObjc to

    handle any value in the Obj-C runtime. • The constructor will now take: 1. a pointer to a native object, allowing us to “wrap” a native data type; 2. an “isGlobal” param for the special case of the global object. • get() will now proxy through to the underlying native oject. • getPropertyNames() will now list all the available properties. • We’ll skip set() as there’s not much time!
  32. #import <jsi/jsi.h> using namespace facebook; // 1⃣ All the types

    of native ref that we’ll support. // We check it once at construction time to avoid re-checking. enum HostObjectObjcType { OTHER, CLASS, CLASS_INSTANCE, GLOBAL, }; class JSI_EXPORT HostObjectObjc: public jsi::HostObject { public: HostObjectObjc(void *nativeRef, bool isGlobal); void *m_nativeRef; HostObjectObjcType m_type; jsi::Value get(jsi::Runtime& rt, const jsi::PropNameID& name) override; std::vector<jsi::PropNameID> getPropertyNames(jsi::Runtime& rt) override; }; Exposing a non-serialisable API Part 1/11: HostObjectObjc.h - the interface
  33. Exposing a non-serialisable API #import "HostObjectObjc.h" HostObjectObjc::HostObjectObjc(void *nativeRef, bool isGlobal):

    m_nativeRef(nativeRef) { // 1⃣ Determine the type of nativeRef and initialise m_type. } jsi::Value HostObjectObjc::get(jsi::Runtime& rt, const jsi::PropNameID& propName) { auto name = propName.utf8(rt); if(m_type == GLOBAL){ // 2⃣ Look up classes/protocols/variables. } else if(m_type == CLASS || m_type == CLASS_INSTANCE){ // 3⃣ Look up methods/properties. } return jsi::Value::undefined(); } std::vector<jsi::PropNameID> getPropertyNames(jsi::Runtime& rt) { // 4⃣ getPropertyNames(): Return the name for every case that we handle in the get() method above. } Part 2/11: HostObjectObjc.mm - implementation outline
  34. // Set global.objc to be an instance of HostObjectObjc. runtime->global().setProperty(

    *runtime, "objc", - jsi::Object::createFromHostObject(*runtime, std::make_shared<HostObjectObjc>()) + jsi::Object::createFromHostObject(*runtime, std::make_shared<HostObjectObjc>((void*)NULL, true)) ); Go back to ObjcRuntime.mm and update our runtime->global().setProperty() call to pass in HostObjectObjc’s new construction params: Exposing a non-serialisable API Part 3/11: ObjcRuntime.mm - update the constructor call
  35. Exposing a non-serialisable API #import “HostObjectObjc.h” #import <objc/runtime.h> // <-

    Add this import. HostObjectObjc::HostObjectObjc(void *nativeRef, bool isGlobal): m_nativeRef(nativeRef) { // 1⃣ Determine the type of nativeRef and initialise m_type. if(isGlobal){ m_type = GLOBAL; return; } @try { if([(__bridge NSObject *)m_nativeRef isKindOfClass:[NSObject class]]){ m_type = class_isMetaClass(object_getClass((__bridge NSObject *)m_nativeRef)) ? CLASS : CLASS_INSTANCE; return; } } @catch (NSException *exception) { // Handles both ObjC and C++ exceptions as long as it's 64-bit. } m_type = OTHER; } Part 4/11: HostObjectObjc.mm - implementing the constructor
  36. Exposing a non-serialisable API // … jsi::Value HostObjectObjc::get(jsi::Runtime& rt, const

    jsi::PropNameID& propName) { // … NSString *nameNSString = [NSString stringWithUTF8String:name.c_str()]; if(m_type == GLOBAL){ // 1⃣ Look up classes/protocols. if (Class clazz = NSClassFromString(nameNSString)) { return jsi::Object::createFromHostObject( rt, std::make_shared<HostObjectObjc>((__bridge void*)clazz, false) ); } if (Protocol *protocol = NSProtocolFromString(nameNSString)) { return jsi::Object::createFromHostObject( rt, std::make_shared<HostObjectObjc>((__bridge void*)protocol, false) ); } // 2⃣ … We’ll continue this block and show global variable lookup on the next slide! } // … } Part 5/11: HostObjectObjc.mm - implementing class/protocol getters for GLOBAL
  37. Exposing a non-serialisable API #import <dlfcn.h> // <- Add this

    import. // … jsi::Value HostObjectObjc::get(jsi::Runtime& rt, const jsi::PropNameID& propName) { // … if(m_type == GLOBAL){ // 1⃣ … See previous slide for class/protocol lookup. // 2⃣ Look up global variables via the dynamic linker (thanks @develobile for telling me about it). void *value = dlsym(RTLD_MAIN_ONLY, nameNSString.UTF8String); if (!value) { value = dlsym(RTLD_SELF, nameNSString.UTF8String); } if (!value) { value = dlsym(RTLD_DEFAULT, nameNSString.UTF8String); } return value ? jsi::Object::createFromHostObject( rt, std::make_shared<HostObjectObjc>(*((void**)value), false) ); jsi::Value::undefined(); } // … } Part 6/11: HostObjectObjc.mm - implementing global variable getters for GLOBAL
  38. Exposing a non-serialisable API jsi::Value HostObjectObjc::get(jsi::Runtime& rt, const jsi::PropNameID& propName)

    { // … if(m_type == OTHER) return jsi::Value::undefined(); // 1⃣ Look up methods. SEL sel = NSSelectorFromString(nameNSString); if([(__bridge NSObject *)m_nativeRef respondsToSelector:sel]){ // 2⃣ I won’t be presenting how to implement invokeMethod(), as it’s very long - see the repo! return invokeMethod(rt, name, sel); } // 3⃣ … We’ll continue this block and show property lookup on the next slide! // … } Part 7/11: HostObjectObjc.mm - implementing getters for the other types
  39. Exposing a non-serialisable API jsi::Value HostObjectObjc::get(jsi::Runtime& rt, const jsi::PropNameID& propName)

    { // … // … See previous slide for method lookup! // 1⃣ Look up properties. NSObject *nativeRef = (__bridge NSObject *)m_nativeRef; Class clazz = m_type == CLASS ? (Class)nativeRef : [nativeRef class]; objc_property_t property = class_getProperty(clazz, nameNSString.UTF8String); if(property){ const char *propertyName = property_getName(property); if(propertyName){ NSObject *value = [nativeRef valueForKey:[NSString stringWithUTF8String:propertyName]]; return convertObjCObjectToJSIValue(rt, value); } } return jsi::Value::undefined(); } Part 8/11: HostObjectObjc.mm - implementing getters for the other types
  40. Exposing a non-serialisable API // 1⃣ Return the name for

    every case that we handle in the get() method. std::vector<jsi::PropNameID> HostObjectObjc::getPropertyNames(jsi::Runtime& rt) { std::vector<jsi::PropNameID> result; NSObject *nativeRef = (__bridge NSObject *)m_nativeRef; Class clazz = m_type == CLASS ? objc_getMetaClass(class_getName((Class)nativeRef)) : [nativeRef class]; // 2⃣ Copy properties. TODO: do the same for subclasses and categories, too. unsigned int pCount; objc_property_t *pList = class_copyPropertyList(clazz, &pCount); for(unsigned int i = 0; i < pCount; i++){ result.push_back(jsi::PropNameID::forUtf8(rt, property_getName(pList[i]))); } free(pList); // 3⃣ Copy methods. unsigned int mCount; Method *mList = class_copyMethodList(clazz, &mCount); for(unsigned int i = 0; i < mCount; i++){ result.push_back(jsi::PropNameID::forUtf8(rt, NSStringFromSelector(method_getName(mList[i])).UTF8String)); } free(mList); return result; } Part 9/11: HostObjectObjc.mm - implementing getPropertyNames()
  41. Exposing a non-serialisable API Part 10/11: HostObjectObjc.mm - other bits

    I won’t go into There won’t be time to cover these, but I’ll at least summarise them. 1. set() - Look up the relevant property setter and send an Obj-C message to it. 2. invokeMethod() - For the given method name, return a JSI::Function in which (in its host function) we prepare and invoke a corresponding NSInvocation. In both cases, we marshal any JS values/params into Obj-C before passing them to the API. This may involve unwrapping (pulling out the nativeRef property from) one of our HostObjectObjc instances.
 
 For full details, see the shirakaba/rnobjc repo!
  42. Part 11/11: App.tsx - call it! console.log( 'Transliterate '͠Β͔͹' into

    Latin via NSString:', objc.NSString.alloc() ['initWithString:']('͠Β͔͹') ['stringByApplyingTransform:reverse:']( objc.kCFStringTransformToLatin, false ) ); LOG Transliterate '͠Β͔͹' into Latin via NSString: shirakaba … Congratulations, you can now arbitrarily access the Obj-C runtime from JS! 🎉 Exposing a non-serialisable API
  43. Worries • Memory management: • Are we losing our native

    refs (previous slide)? • Are we leaking memory? • Is it secure? • Further work needed for: • Marshalling C values • TypeScript typings • Debugging is hard as everything’s dynamic • Will Apple reject apps using this library?
  44. Future ideas • Code review (help!) • Change from dynamic

    to static access • Use approach as a basis for smaller-scale libraries (e.g. just NSString) • An Android runtime • Delegate to NativeScript: • Use JSI to call NativeScript from React Native, or even better; • Swap React Native JSC/V8 for NativeScript JSC/V8, and call it directly
  45. Conclusion • JSI and Obj-C++ are not so scary for

    simple tasks • JSI is suitable for arbitrary native API access (though we need to get the memory management correct) • I hope you learned a bit more about: • C++ • Obj-C metaprogramming • Building JSI modules in general