Use custom dev client in non-debug build configurations

I have been trying to set up a custom dev client for use by all members of my team when developing an app that has native dependencies. However, while I have successfully integrated the launcher and dev-menu by following the instructions here, the integration only seems to work when running a Debug configuration in XCode.

This is an issue because when a React Native app is built in debug mode, private APIs are included that simply do not pass the checks when submitting the app to TestFlight. In order to successfully publish the app to be accessed by our team, the app has to be built using a Release configuration.

Previous attempts

While trying to adapt the project configuration in a way that would let me use a Release config while still including the Expo launcher/menu, I made this change to the podfile, with devclient being a custom profile that is a copy of the default Release configuration.

- pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', :configurations => :debug
+ pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', :configurations => :devclient
- pod 'expo-dev-menu', path: '../node_modules/expo-dev-menu', :configurations => :debug
+ pod 'expo-dev-menu', path: '../node_modules/expo-dev-menu', :configurations => :devclient

This almost worked. Scanning a QR code from a terminal running expo start --dev-client opened the app correctly and Metro started building the bundle. But as soon as it was done, the app immidiately crashed. Curious as to what may have caused it, I attached the XCode debugger and found that this error was causing the crash:

[error][tid:com.facebook.react.JavaScript] Invariant Violation: TurboModuleRegistry.getEnforcing(...): 'DevSettings' could not be found. Verify that a module by this name is registered in the native binary.
[fatal][tid:com.facebook.react.ExceptionsManagerQueue] Unhandled JS Exception: Invariant Violation: TurboModuleRegistry.getEnforcing(...): 'DevSettings' could not be found. Verify that a module by this name is registered in the native binary.

Conclusion and question

The fact that everything works correctly in a Debug configuration, but not in a Release configuration, leads me to believe that the launcher cannot be included without running the app in debug mode. Is this true, and if it is, can anyone suggest a workaround? The last resort would of course be to compile .ipa files and distribute them to the team manually, but that would require us to collect the UUIDs of every team member’s devices which I’d like to avoid if it is possible.

The current functionality is to only create dev client builds for Debug builds, but you make a good point! For now, you’ll probably have to do this manually by going through the guide you probably followed to install (this one) and modify each of the if (debug) statements

cc @thetc

1 Like

Thank you for your quick response! I should have added that I tried such a solution to no avail as well. Do you see anything obviously wrong with the below content from AppDelegate.m? The changes are highlighted using a git diff, and the original content should be the same as in the instructions.

#import "AppDelegate.h"
 
 #if defined(EX_DEV_MENU_ENABLED)
 @import EXDevMenu;
 #endif
  
 #if defined(EX_DEV_LAUNCHER_ENABLED)
 #include <EXDevLauncher/EXDevLauncherController.h>
 #endif
 
 #import <React/RCTBridge.h>
 #import <React/RCTBundleURLProvider.h>
 #import <React/RCTRootView.h>
 #import <React/RCTLinkingManager.h>
 
 #ifdef FB_SONARKIT_ENABLED
 #import <FlipperKit/FlipperClient.h>
 #import <FlipperKitLayoutPlugin/FlipperKitLayoutPlugin.h>
 #import <FlipperKitUserDefaultsPlugin/FKUserDefaultsPlugin.h>
 #import <FlipperKitNetworkPlugin/FlipperKitNetworkPlugin.h>
 #import <SKIOSNetworkPlugin/SKIOSNetworkAdapter.h>
 #import <FlipperKitReactPlugin/FlipperKitReactPlugin.h>
 
 static void InitializeFlipper(UIApplication *application) {
   FlipperClient *client = [FlipperClient sharedClient];
   SKDescriptorMapper *layoutDescriptorMapper = [[SKDescriptorMapper alloc] initWithDefaults];
   [client addPlugin:[[FlipperKitLayoutPlugin alloc] initWithRootNode:application withDescriptorMapper:layoutDescriptorMapper]];
   [client addPlugin:[[FKUserDefaultsPlugin alloc] initWithSuiteName:nil]];
   [client addPlugin:[FlipperKitReactPlugin new]];
   [client addPlugin:[[FlipperKitNetworkPlugin alloc] initWithNetworkAdapter:[SKIOSNetworkAdapter new]]];
   [client start];
 }
 #endif
 
 @interface AppDelegate ()
  
 @property (nonatomic, strong) NSDictionary *launchOptions;
  
 @end
 
 @implementation AppDelegate
 
 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
 {
 #ifdef FB_SONARKIT_ENABLED
   InitializeFlipper(application);
 #endif
 
   self.launchOptions = launchOptions;
   self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
- 
-  #ifdef DEBUG
-    #if defined(EX_DEV_LAUNCHER_ENABLED)
-        EXDevLauncherController *controller = [EXDevLauncherController sharedInstance];
-        [controller startWithWindow:self.window delegate:(id<EXDevLauncherControllerDelegate>)self launchOptions:launchOptions];
-      #else
-        [self initializeReactNativeApp];
-      #endif
+
+  #if USE_EXPO && defined(EX_DEV_LAUNCHER_ENABLED)
+    EXDevLauncherController *controller = [EXDevLauncherController sharedInstance];
+    [controller startWithWindow:self.window delegate:(id<EXDevLauncherControllerDelegate>)self launchOptions:launchOptions];
   #else
-        [self initializeReactNativeApp];
+      [self initializeReactNativeApp];
   #endif
- 
+
   return YES;
 }
  
 - (RCTBridge *)initializeReactNativeApp
 {
   #if defined(EX_DEV_LAUNCHER_ENABLED)
     NSDictionary *launchOptions = [EXDevLauncherController.sharedInstance getLaunchOptions];
   #else
     NSDictionary *launchOptions = self.launchOptions;
   #endif
  
   RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions];
-  RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge moduleName:@"MyApp" initialProperties:nil];
+  RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge moduleName:@"ChartsRN" initialProperties:nil];
   if (@available(iOS 13.0, *)) {
       rootView.backgroundColor = [UIColor systemBackgroundColor];
   } else {
       rootView.backgroundColor = [UIColor whiteColor];
   }
 
   UIViewController *rootViewController = [UIViewController new];
   rootViewController.view = rootView;
   self.window.rootViewController = rootViewController;
   [self.window makeKeyAndVisible];
   return bridge;
 }
 
 - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
 {
-#if DEBUG
-  #if defined(EX_DEV_LAUNCHER_ENABLED)
-  return [[EXDevLauncherController sharedInstance] sourceUrl];
-  #else
-  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
+  #if USE_EXPO && defined(EX_DEV_LAUNCHER_ENABLED)
+    return [[EXDevLauncherController sharedInstance] sourceUrl];
+  #elif DEBUG
+    return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
   #endif
-#else
+  
   return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
-#endif
 }
 
 // Linking API
 - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options
 {
   #if defined(EX_DEV_LAUNCHER_ENABLED)
   if ([EXDevLauncherController.sharedInstance onDeepLink:url options:options]) {
     return true;
   }
   #endif
   return [RCTLinkingManager application:application openURL:url options:options];
 }
  
 // Universal Links
 - (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler
 {
   return [RCTLinkingManager application:application
                    continueUserActivity:userActivity
                      restorationHandler:restorationHandler];
 }
  
 @end
  
 #if defined(EX_DEV_LAUNCHER_ENABLED)
 @implementation AppDelegate (EXDevLauncherControllerDelegate)
  
 - (void)devLauncherController:(EXDevLauncherController *)developmentClientController
     didStartWithSuccess:(BOOL)success
 {
   developmentClientController.appBridge = [self initializeReactNativeApp];
 }
 
 @end
 #endif

Preprocessor macros

To include expo in a build, I specify the USE_EXPO preprocessor macro for the configuration that is in use. In the end, this is the resulting macros:

Error

Here is the error that I got immediately after the bundle was generated in Metro, i.e as soon as the application attempted to load the bundle from the development server:

(Not allowed to include more than one image, will add to separate post)

This issue on the React Native repository on GitHub leads me to believe that this indicates that the Expo launcher uses things that are not included in release (or non-debug) builds. Is this correct or can I prevent this error somehow?

Again, thanks for taking the time to look into this, I really appreciate it!

Here is a screenshot of the error message that I get. Due to the attachment limit for new users, I could not include it in my first post.

Before diving into your code changes and trying to configure it so that release builds also contain the dev client- can I ask why you don’t distribute your pre release version of the app (with the dev client) to your team via internal distribution, instead of going through Testflight?

Wouldn’t that require us to use the EAS platform? Perhaps I am misunderstanding what’s being described on that page, but that is the impression I get at least. We would like to maintain the whole pipeline ourselves, if possible.

Ad-hoc deployments, while made for this specific purpose, would again require me to manually register each device in the team. It is also a possibility, of course, but it would be great to be able to distribute the development client using TestFlight where the team is already a configured test group.


All that being said, I am starting to realize that TestFlight might not be the optimal way to do things. The goal would be to have a development client that anyone within the team could install to get started with developing the app as quickly as possible. Perhaps an ad-hoc distribution would be preferable after all, despite the inconveniences.

I guess what I’m really looking for is to have the resulting .ipa file downloadable (and installable) from a secured source, much like the internal distribution that you mentioned aims to accomplish, but in a pipeline that I am fully in control of.

Am I thinking incorrectly about this development client thing? I have used Expo Go in the past and LOVED it, which is why I want to try bringing in the same experience for the whole team.

Hi @maltehall,
The way you’re hoping to use it is very much in line with our goals for the project :slight_smile:
Unfortunately release builds are more easily said than done: parts of the code from react-native we rely on are currently excluded from projects in release mode.

Ad hoc builds are designed for this purpose, but certainly fiddly! Something we’ve tried to smooth over with internal distribution, although you would need to use our services to take advantage of it.

I do know other folks are successfully distributing custom client builds to their teams using test flight, so I’d love to understand better what private APIs are being added in your case.

Cheers,
TC

1 Like

I am happy to hear that my idea seems to align with your ambitions. You guys rock!

The message I got from apple when trying to publish a working (debug) version that included the Expo development client was this:

ITMS-90338: Non-public API usage - The app references non-public selectors in [AppName]: _isKeyDown, _modifiedInput, _modifierFlags, handleNotification:, onSuccess:, setCategoryID:. If method names in your source code match the private Apple APIs listed above, altering your method names will help prevent this app from being flagged in future submissions. In addition, note that one or more of the above APIs may be located in a static library that was included with your app. If so, they must be removed. For further information, visit the Technical Support Information at Requesting Technical Support - Support - Apple Developer

Other than Expo, we pretty much only have the Mapbox SDK implemented. The private APIs could very well be coming from there, although the above message reveals too little for me to personally pinpoint the source.

If you know a way around this then I’d be happy to give it a try! If not, I might look into the ad-hoc method as well but I think I’m approaching the maximum amount of effort I can put into this ambition for now and would most likely need to re-visit the scene at a later point, at least for the IOS side of things which is unfortunate since more than 2/3 of the team is using IOS devices.

As mentioned, any tips that could lead me in the right direction would be very welcome!

Kind Regards,
Malte