Example React Native app that implements a browser extension. This example was created with React Native version 0.48.1. The instructions may require updating for later versions. This project builds on the great instructions published by PromptWorks at https://www.promptworks.com/blog/building-ios-app-extensions-with-react-native , which did a bit less than we're aiming for here, and were for an earlier version of React Native so could be a bit out of date.
- Create a React Native app, e.g.
react-native init MyReactNativeExampleBrowserExtension
This should create a folder called MyReactNativeExampleBrowserExtension. - Open the iOS app in XCode, using the file
MyReactNativeExampleBrowserExtension/ios/MyReactNativeExampleBrowserExtension.xcodeproj
- Select your project in the left bar, click the
General
tab, and click the plus icon to add a new Target - Choose
Action Extension
to create the extension Make sure to choose Objective-C as the language, and leave the default selection forAction Type
to bePresents User Interface
. For this example we will be showing a UI built with React Native when the extension is activated. If you see a prompt toActivate "My Example Extension" scheme
, click theActivate
button. - At this point you have an Action Extension in your app. Note that a new folder named after the name of the extension you chose, in this example
My Example Extension
, has been created in theios
folder, and contains three files,ActionViewController.h
,ActionViewController.m
andInfo.plist
. We'll be rewriting the code inActionViewController.m
pretty soon.
The goal of this section is to configure the Action Extension you've just created to be the same as the main application. This example was created with React Native version 0.48.1, so if you're using a later version and your config is different, use the config in your app instead.
- In XCode, click your project name in the left bar. You may see a vertical list appear saying
Projects
andTargets
, but if now, click the icon circled in the screenshot below to expand that UI control. Click the first target, in this exampleReactNativeExampleBrowserExtension
and expand theLink Binary With Libraries
section. Note all the libraries in there, perhaps take a screenshot to avoid jumping back and forth. You'll need to replicate them elsewhere. - In the
Targets
list, select your extension, in this exampleMy Example Extension
. Click the+
icon in theLink Binary With Libraries
section and add all of the libraries you saw in the main app config. It should look like this when done - In order for the project to compile properly (compilation, remember that headache from the 90's? Yeah, well welcome back to it :-( ), we'll add some "linker flags". Select your extension's target in the project settings again, in this case
My Example Extension
. Click theBuild Settings
tab, and make sure to click theAll
button just below the tabs, otherwise XCode will helpfully hide the property you need to set.
Scroll down to find theOther Linker Flags
property, and double click on the row (do not expand it). This pops up a dialog, where you should click the+
icon. Add-ObjC
and-lc++
to this dialog. - To enable the app to load JavaScript we need to add an exception to the
App Transport Security Settings
to allow local files to be loaded. The easiest way to do this is to copy the required text from the main app. To do so, expand the main project section in the left bar, in this exampleReactNativeExampleBrowserExtension
, and choose theInfo.plist
file. Right click on theApp Transport Security Settings
property and copy it. Now expand the Extension section in the left bar, in this exampleMy Example Extension
and select theInfo.plist
file. Right click and paste. you should see the setting forApp Transport Security Settings
be replicated. - Now let's test out the app and make sure everything is working! In your terminal, make sure that you are in the root folder of your React Native app, in this example a folder called
ReactNativeExampleBrowserExtension
, and not in theios
subfolder. Run the commandreact-native run-ios
, which should successfully build and run your app in the simulator. - Once your app is visible, close it using the Home button (Cmd-Shift-H), and open Safari. Tap the share button on the bottom, and an action sheet should appear. Your shiny new extension is not visible here yet, so we have to activate it. (Sadly, end users will need to do the same when they install from the App Store. Talk to your nearest Apple executive when you run into them hanging around cafes in Cupertino about this...) Scroll the bottom row of icons all the way to the right and tap the
More
icon. This should pop up a list containing the name of your extension, in this exampleMy Example Extension
. Tap the toggle control to enable it and hitDone
. You should now see the empty icon for your extension in the action sheet. Tap it, and you'll see the empty UI pop up with aDone
button on the top. Congrats, your app works!
The previous steps created the file ActionViewController.m
, which defines what happens when the UI needs to load. Take a look at it's contents, you will see the viewDidLoad
function containing the default code generated by XCode to render the UI. We will replace this with a loadView
function that creates a RCTRootView with the JavaScript code.
- Add the following code to the top of
ActionViewController.m
#import <React/RCTBundleURLProvider.h> #import <React/RCTRootView.h>
- Add the following code below the line
@implementation ActionViewController
inActionViewController.m
- (void)loadView {
NSURL *jsCodeLocation;
jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index.ios" fallbackResource:nil];
RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
moduleName:@"ReactNativeExampleBrowserExtension"
initialProperties:nil
launchOptions:nil];
rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1];
self.view = rootView;
}
Note that this code was mostly copied directly from AppDelegate.m
, with nil
replacing launchOptions
since there are no launch options.
- Comment out the entire
viewDidLoad
function.
The previous code showed the same UI in your action extension as appears in the main app. This is unlikely to be what you want to have happen, so let's send a property to React Native to tell it to render a different UI.
- Update the
loadView
method inActionViewController.m
to pass in a dictionary with a property calledisActionExtension
set totrue
. We pass this to theinitialProperties
value.
NSDictionary *initialProps =
[NSDictionary dictionaryWithObject:[NSNumber numberWithBool: TRUE] forKey:@"isActionExtension"];
RCTRootView *rootView =
[[RCTRootView alloc] initWithBundleURL:jsCodeLocation
moduleName:@"ReactNativeExampleBrowserExtension"
initialProperties:initialProps
launchOptions:nil];
- Create a new React component called 'ActionExtensionScreen' in the file 'app/ActionExtensionScreen.js'
(create the
app
folder). Put the following code in there.
// @flow
import React from 'react';
import { Text, View } from 'react-native';
export default class ActionExtensionScreen extends React.Component {
render() {
return (
<View style={{ paddingTop: 100 }}>
<Text style={{ fontSize: 30, textAlign: 'center' }}>Hello from our Action Extension!</Text>
</View>
);
}
}
- Update the root component of the app, in the file
index.ios.js
, to add the prop check for isActionExtension.
import ActionExtensionScreen from './app/ActionExtensionScreen';
export default class ReactNativeExampleBrowserExtension extends Component {
static propTypes = {
isActionExtension: PropTypes.bool
};
static defaultProps = {
isActionExtension: false
};
render() {
if (this.props.isActionExtension) {
return <ActionExtensionScreen />;
} else {
return (
<View style={styles.container}>
<Text style={styles.welcome}>Welcome to ReactNativeExampleBrowserExtension!</Text>
<Text style={styles.instructions}>
This app shows how to create a browser extension using React Native
</Text>
<Text style={styles.instructions}>
You can mostly ignore this main app code, all the fun is in the extension and the
README, which contains the instructions you should follow
</Text>
</View>
);
}
}
}
Now when you open the action extension, you'll see the ActionExtensionScreen
component rendered.
To close the extension, the done
method on ActionViewController
must be called. Unfortunately
this is in Objective-C, so we can't call it directly from JavaScript. To enable this we
will create a Native Module.
This gets a bit hacky due to the fact that Action Extensions do not have access to the application context so we need to keep track of the ActionViewController ourselves.
- Update the
ActionViewController.h
file to expose a pointer to the view controller, and set that pointer when the view is loaded. The new code looks like:
#import <UIKit/UIKit.h>
@interface ActionViewController : UIViewController
- (void) done;
extern ActionViewController * actionViewController;
@end
- Update
ActionViewContoller.m
to also include a pointer toactionViewController
, and set the value of the pointer at the end of theloadView
function.
@interface ActionViewController ()
@property(strong,nonatomic) IBOutlet UIImageView *imageView;
@end
ActionViewController * actionViewController = nil;
@implementation ActionViewController
- (void)loadView {
// All other existing code here....
// New code
actionViewController = self;
}
- Time to create the NativeModule! Create a new file called
ActionExtension.h
in the same folder asAppDelegate.m
.
#import <React/RCTBridgeModule.h>
@interface ActionExtension : NSObject <RCTBridgeModule>
@end
Create a file called ActionExtension.m
in the same folder. You will be prompted to choose
the targets that the file should be compiled for. Make sure to select both the default one,
My Example Extension
and the main target, which should be the first in the list.
#import "ActionExtension.h"
#import "ActionViewController.h"
@implementation ActionExtension
RCT_EXPORT_MODULE();
RCT_EXPORT_METHOD(done) {
[actionViewController done];
}
@end
To avoid compilation/linker issues, add ActionViewController.m
to the Compile Sources
list of the
main project's Build Phases
tab.
- Update the
ActionExtensionScreen.js
file to contain aDone
component, which when pressed activates the newly exposeddone()
function on the native module.
// @flow
import React from 'react';
import { NativeModules, TouchableOpacity, Text, View } from 'react-native';
export default class ActionExtensionScreen extends React.Component {
render() {
return (
<View style={{ paddingTop: 100 }}>
<Text style={{ fontSize: 30, textAlign: 'center' }}>Hello from our Action Extension!</Text>
<View style={{ paddingTop: 100 }}>
<TouchableOpacity onPress={this._handleDone}>
<Text style={{ fontSize: 30, textAlign: 'center' }}>Done</Text>
</TouchableOpacity>
</View>
</View>
);
}
_handleDone = () => {
// Call the function that has been exposed on the native module to close the screen.
NativeModules.ActionExtension.done();
};
}
Now run your app again with react-native run-ios
and try opening the extension from a browser.
Clicking the Done
component should close the extension, taking you back to the browser!
Running in Release mode, rather than the Dev mode that we've been using up until now, does not work
out of the box when using an Action Extension. This is because, to run in Release mode,
React Native bundles up all the required JavaScript code and creates a file called main.jsbundle
,
and adds that file to the build folder of your application. Of course, the App Extension is
a separate application, so it doesn't have access to this file. The result is that when you try to
open the app extension from the browser, nothing happens.
There is likely a way to enable cross-application bundle access (a bundle is what Apple calls the set of assets in your app), and I tried out a few that didn't work, but the approach I found to work was to write a script that runs after React Native finishes building and copies the file into the extension app.
- Create a folder in the root of the project called
scripts
. In that folder, create a text file calledcopyJsBundle.sh
. In that file, add the following:
cp "$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/main.jsbundle" "$CONFIGURATION_BUILD_DIR/<YOUR_APP_EXTENSION_NAME>.appex"
cp "$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/main.jsbundle.meta" "$CONFIGURATION_BUILD_DIR/<YOUR_APP_EXTENSION_NAME>.appex"
In this, replace <YOUR_APP_EXTENSION_NAME> with the name of your app extension, in this example
it is My Example Extension
.
The other parameters are passed in by React Native, which I discovered looking at the build file
for iOS in node_modules/react-native/scripts/react-native-xcode.sh
.
You need to make this file executable, so in a terminal, from the root of your project, type
chmod +x scripts/copyJsBundle.sh
- Now you have to tell XCode to run this script after the main build has completed. This is done
by editing the file
ios/<YOUR_PROJECT>.xcodeproj/project.pbxproj
, so in this caseios/ReactNativeExampleBrowserExtension/project.pbxproj
. In this file, search for any instances ofreact-native-xcode.sh
, then edit that line to add&& ../scripts/copyJsBundle.sh
, so the line should look like
shellScript = "export NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh && ../scripts/copyJsBundle.sh";
-
Switch both the main project's scheme and the extension project's scheme to Release mode by going to the menu
Product/Scheme/Edit Scheme
, clicking onRun
in the left side of the dialog that pops up, and choosingRelease
from the dropdown. You can toggle between different project schemes in the top left of the dialog to modify both in one go. -
Run the app on the simulator, open the browser, go to some website, open the Share dialog and tap your extension's icon. It should open up correctly and render the React Native UI! This also means it should be possible to actually package up your app and send it to Apple for entry in the app store.
You'll notice that when you open your extension in the Simulator, you do not see logs show up in the XCode console. This is because XCode automatically attaches its debug session to the main app. The way to fix this is to attach the debug session to the app from which you'll open your extension, in this example it's Safari.
-
Open the Scheme for your extension. Click the menu
Product/Scheme/Edit Scheme
and choose your extension's project in the top left of the dialog that pops up. ChooseRun
on the left side of the dialog. Change theExecutable
dropdown toAsk on Launch
. Click theClose
button. -
At the top of XCode, just to the right of the Stop button, choose your extension project to be run. It was probably on your main app all along. Click the
Play
button to run it and you should be prompted for the app to run it in the context of. Choose Safari. Now you should see all your logs in the Console! If you do not see the Console at all, you can show it by going to the menuView/Debug Area/Activate Console