Apple Targets plugin
[!WARNING]
This is highly experimental and not part of any official Expo workflow.
An experimental Expo Config Plugin that generates native Apple Targets like Widgets or App Clips, and links them outside the /ios
directory. You can open Xcode and develop the targets inside the virtual expo:targets
folder and the changes will be saved outside of the ios
directory. This pattern enables building things that fall outside of the scope of React Native while still obtaining all the benefits of Continuous Native Generation.
🚀 How to use
This plugin requires at least CocoaPods 1.16.2 (ruby 3.2.0), Xcode 16 (macOS 15 Sequoia), and Expo SDK +52.
- Run
npx create-target
in your Expo project to generate an Apple target. - Select a target to generate, I recommend starting with a
widget
(e.g. npx create-target widget
). This will generate the required widget files in the root /targets
directory, install @bacons/apple-targets
, and add the Expo Config Plugin to your project. - If you already know your Apple Team ID, then set it under the
ios.appleTeamId
property in your app.json
. You can find this in Xcode under the Signing & Capabilities tab and set it later if needed. - Run
npx expo prebuild -p ios --clean
to generate the Xcode project. - You can now open your project in Xcode (
xed ios
) and develop the widget inside the expo:targets/<target>
folder. - When you're ready to build, select the target in Xcode and build it.
How it works
The root /targets
folder is magic, each sub-directory should have a expo-target.config.js
file that defines the target settings. When you run npx expo prebuild --clean
, the plugin will generate the Xcode project and link the target files to the project. The plugin will also generate an Info.plist
file if one doesn't exist.
The Config Plugin will link the subfolder (e.g. targets/widget
) to Xcode, and all files inside of it will be part of the target. This means you can develop the target inside the expo:targets
folder and the changes will be saved outside of the generated ios
directory.
The root Info.plist
file in each target directory is not managed and can be freely modified.
Any files in a top-level target/{name}/assets
directory will be linked as resources of the target. This rule was added to support Safari Extensions.
Entitlements
If the expo-target.config
file defines an entitlements: {}
object, then a generated.entitlements
will be added. Avoid using this file directly, and instead update the expo-target.config.js
file. If the entitlements
object is not defined, you can manually add any top-level *.entitlements
file to the target directory—re-running npx expo prebuild
will link this file to the target as the entitlements file. Only one top-level *.entitlements
file is supported per target.
Some targets have special entitlements behavior:
- App Clips (
clip
) automatically set the required com.apple.developer.parent-application-identifiers
to $(AppIdentifierPrefix)${config.ios.bundleIdentifier}
- Targets that can utilize App Groups will automatically mirror the
ios.entitlements['com.apple.security.application-groups']
array from the app.json
if it's defined. This can be overwritten by specifying an entitlements['com.apple.security.application-groups']
array in the expo-target.config.js
file.
Development
Any changes you make outside of the expo:targets
directory in Xcode are subject to being overwritten by the next npx expo prebuild --clean
. Check to see if the settings you want to toggle are available in the Info.plist or the expo-target.config.js
file.
If you modify the expo-target.config.js
or your root app.json
, you will need to re-run npx expo prebuild --clean
to sync the changes.
You can use the custom Prebuild template --template node_modules/@bacons/apple-targets/prebuild-blank.tgz
to create a build without React Native, this can make development a bit faster since there's less to compile.
Target config
The target config can be a expo-target.config.js
, or expo-target.config.json
file.
This file can have the following properties:
module.exports = {
type: "widget",
name: "My Widget",
colors: {
$accent: { color: "red", darkColor: "blue" },
},
icon: "../assets/icon.png",
frameworks: [
"SwiftUI",
],
entitlements: {
},
images: {
thing: "../assets/thing.png",
},
deploymentTarget: "15.1",
bundleIdentifier: ".mywidget",
exportJs: false,
};
You can also return a function that accepts the Expo Config and returns a target function for syncing entitlements and other values:
module.exports = (config) => ({
type: "widget",
colors: {
$accent: "steelblue",
},
entitlements: {
"com.apple.security.application-groups":
config.ios.entitlements["com.apple.security.application-groups"],
"com.apple.security.application-groups": [
`group.${config.ios.bundleIdentifier}.widget`,
],
},
});
ESM and TypeScript are not supported in the target config. Stick to require
for sharing variables across targets.
Colors
There are certain values that are shared across targets. We use a predefined convention to map these values across targets.
Name | Build Setting | Purpose |
---|
$accent | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME | Sets the global accent color, in widgets this is used for the tint color of buttons when editing the widget. |
$widgetBackground | ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME | Sets the background color of the widget. |
CocoaPods
Adding a file pods.rb
in the root of the repo will enable you to modify the target settings for the project.
The ruby module evaluates with global access to the property podfile_properties
.
For example, the following is useful for enabling React Native in an App Clip target:
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
exclude = []
use_expo_modules!(exclude: exclude)
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
else
config_command = [
'node',
'--no-warnings',
'--eval',
'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
'react-native-config',
'--json',
'--platform',
'ios'
]
end
config = use_native_modules!(config_command)
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
:app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
)
This block executes at the end of the Podfile in a block like:
target "target_dir_name" do
target_file
end
The name of the target must match the name of the target directory.
exportJs
The exportJs
option should be used when the target uses React Native (App Clip, Share extension). It works by linking the main target's Bundle React Native code and images
build phase to the target. This will ensure that production builds (Release
) bundle the main JS entry file with Metro, and embed the bundle/assets for offline use.
To detect which target is being built, you can read the bundle identifier using expo-application
.
Examples
widget
I wrote a blog about this one and used it in production. Learn more: Expo x Apple Widgets.
module.exports = {
type: "widget",
icon: "../../icons/widget.png",
colors: {
$widgetBackground: "#DB739C",
$accent: "#F09458",
gradient1: {
light: "#E4975D",
dark: "#3E72A0",
},
},
images: {
valleys: "../../valleys.png",
},
entitlements: {
"com.apple.security.application-groups": ["group.bacon.data"],
},
};
action
These show up in the share sheet. The icon should be transparent as it will be masked by the system.
module.exports = {
type: "action",
name: "Inspect Element",
icon: "./assets/icon.png",
colors: {
TouchBarBezel: "#DB739C",
},
};
Add a JavaScript file to assets/index.js
:
class Action {
run({ extensionName, completionFunction }) {
completionFunction({
});
}
finalize() {
}
}
window.ExtensionPreprocessingJS = new Action();
Ensure NSExtensionJavaScriptPreprocessingFile: "index"
in the Info.plist.
spotlight
Populate the Spotlight search results with your app's content.
module.exports = {
type: "spotlight",
};
Supported types
Ideally, this would be generated automatically based on a fully qualified Xcode project, but for now it's a manual process. The currently supported types are based on static analysis of the most commonly used targets in the iOS App Store. I haven't tested all of these and they may not work.
Type | Description |
---|
action | Share Action |
app-intent | App Intent Extension |
widget | Widget / Live Activity |
watch | Watch App (with companion iOS App) |
clip | App Clip |
safari | Safari Extension |
share | Share Extension |
notification-content | Notification Content Extension |
notification-service | Notification Service Extension |
intent | Siri Intent Extension |
intent-ui | Siri Intent UI Extension |
spotlight | Spotlight Index Extension |
bg-download | Background Download Extension |
quicklook-thumbnail | Quick Look Thumbnail Extension |
location-push | Location Push Service Extension |
credentials-provider | Credentials Provider Extension |
account-auth | Account Authentication Extension |
device-activity-monitor | Device Activity Monitor Extension |
shield-configuration | Shield Configuration Extension |
shield-action | Shield Action Extension |
Code Signing
The codesigning is theoretically handled entirely by EAS Build. This plugin will add the requisite entitlements for target signing to work. I've only tested this end-to-end with my Pillar Valley Widget.
You can also manually sign all sub-targets if you want, I'll light a candle for you.
I haven't gotten App Clip codesigning to be fully automated yet. PRs welcome.
Building Widgets
I've written a blog post about building widgets with this plugin: Expo x Apple Widgets.
If you experience issues building widgets, it might be because React Native is shipped uncompiled which makes the build complexity much higher. This often leads to issues with the Swift compiler and SwiftUI previews.
Some workarounds:
- Prebuild without React Native:
npx expo prebuild --template node_modules/@bacons/apple-targets/prebuild-blank.tgz --clean
- If the widget doesn't show on the home screen when building the app, use iOS 18. You can long press the app icon and select the widget display options to transform the app icon into the widget.
Sharing data between targets
To share values between the app and the target, you must use App Groups and NSUserDefaults. I've added a native module to make the React Native API a bit easier.
Configuring App Groups
Start by defining an App Group, a good default is group.<bundle identifier>
. App Groups can be used across apps so you may want something more generic or less generic if you plan on having multiple extensions.
First, define your main App Group entitlement in your app.json
:
{
"expo": {
"ios": {
"entitlements": {
"com.apple.security.application-groups": ["group.bacon.data"]
}
},
"plugins": ["@bacons/apple-targets"]
}
}
Second, define the same App Group in your target's expo-target.config.js
:
module.exports = (config) => ({
type: "widget",
entitlements: {
"com.apple.security.application-groups":
config.ios.entitlements["com.apple.security.application-groups"],
},
});
Now you can prebuild to generate the entitlements. You may need to create an EAS Build or open Xcode to sync the entitlements.
Setting shared data
To define shared data, we'll use a native module (ExtensionStorage
) that interacts with NSUserDefaults
.
Somewhere in your Expo app, you can set a value:
import { ExtensionStorage } from "@bacons/apple-targets";
const storage = new ExtensionStorage(
"group.bacon.data"
);
storage.set("myKey", "myValue");
ExtensionStorage.reloadWidget();
ExtensionStorage
has the following API:
set(key: string, value: string | number | Record<string, string | number> | Array<Record<string, string | number>> | undefined): void
- Sets a value in the shared storage for a given key. Setting undefined
will remove the key.ExtensionStorage.reloadWidget(name?: string): void
- A static method for reloading the widget. Behind the scenes, this calls WidgetCenter.shared.reloadAllTimelines()
. If given a name, it will reload a specific widget using WidgetCenter.shared.reloadTimelines(ofKind: timeline)
.
Accessing shared data
Assuming this is done using Swift code, you'll access data using NSUserDefaults
directly. Here's an example of how you might access the data in a widget:
let defaults = UserDefaults(suiteName:
"group.bacon.data"
)
let index = defaults?.string(forKey: "myKey")
Xcode parsing
This plugin makes use of my proprietary Xcode parsing library, @bacons/xcode
. It's mostly typed, very untested, and possibly full of bugs––however, it's still 10x nicer than the alternative.