├── .eslintrc.js
├── .github
├── FUNDING.yml
└── workflows
│ └── npm-publish.yml
├── .gitignore
├── .npmignore
├── LICENSE
├── README.md
├── app.plugin.js
├── examples
├── basic
│ ├── .gitignore
│ ├── App.tsx
│ ├── README.md
│ ├── ShareExtension.tsx
│ ├── app.json
│ ├── assets
│ │ ├── adaptive-icon.png
│ │ ├── favicon.png
│ │ ├── fonts
│ │ │ └── Inter-Black.otf
│ │ ├── icon.png
│ │ └── splash.png
│ ├── babel.config.js
│ ├── eas.json
│ ├── index.js
│ ├── index.share.js
│ ├── metro.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── tsconfig.json
│ └── webpack.config.js
├── with-file
│ ├── .gitignore
│ ├── README.md
│ ├── ShareExtension.tsx
│ ├── app.json
│ ├── app
│ │ ├── create.tsx
│ │ └── index.tsx
│ ├── assets
│ │ ├── adaptive-icon.png
│ │ ├── favicon.png
│ │ ├── fonts
│ │ │ └── Inter-Black.otf
│ │ ├── icon.png
│ │ └── splash.png
│ ├── babel.config.js
│ ├── eas.json
│ ├── index.js
│ ├── index.share.js
│ ├── metro.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── tsconfig.json
│ └── webpack.config.js
├── with-firebase
│ ├── .gitignore
│ ├── App.tsx
│ ├── README.md
│ ├── ShareExtension.tsx
│ ├── app.json
│ ├── assets
│ │ ├── adaptive-icon.png
│ │ ├── favicon.png
│ │ ├── fonts
│ │ │ └── Inter-Black.otf
│ │ ├── icon.png
│ │ └── splash.png
│ ├── babel.config.js
│ ├── components
│ │ └── AppleAuthLogin.tsx
│ ├── eas.json
│ ├── index.js
│ ├── index.share.js
│ ├── metro.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── tsconfig.json
│ └── webpack.config.js
├── with-media
│ ├── .gitignore
│ ├── README.md
│ ├── ShareExtension.tsx
│ ├── app.json
│ ├── app
│ │ ├── create.tsx
│ │ └── index.tsx
│ ├── assets
│ │ ├── adaptive-icon.png
│ │ ├── favicon.png
│ │ ├── fonts
│ │ │ └── Inter-Black.otf
│ │ ├── icon.png
│ │ └── splash.png
│ ├── babel.config.js
│ ├── eas.json
│ ├── index.js
│ ├── index.share.js
│ ├── metro.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── tsconfig.json
│ └── webpack.config.js
├── with-mmkv
│ ├── .gitignore
│ ├── App.tsx
│ ├── README.md
│ ├── ShareExtension.tsx
│ ├── app.json
│ ├── assets
│ │ ├── adaptive-icon.png
│ │ ├── favicon.png
│ │ ├── fonts
│ │ │ └── Inter-Black.otf
│ │ ├── icon.png
│ │ └── splash.png
│ ├── babel.config.js
│ ├── eas.json
│ ├── index.js
│ ├── index.share.js
│ ├── metro.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── storage.ts
│ ├── tsconfig.json
│ └── webpack.config.js
└── with-preprocessing
│ ├── .gitignore
│ ├── App.tsx
│ ├── README.md
│ ├── ShareExtension.tsx
│ ├── app.json
│ ├── assets
│ ├── adaptive-icon.png
│ ├── favicon.png
│ ├── fonts
│ │ └── Inter-Black.otf
│ ├── icon.png
│ └── splash.png
│ ├── babel.config.js
│ ├── eas.json
│ ├── index.js
│ ├── index.share.js
│ ├── metro.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── preprocessing.js
│ ├── tsconfig.json
│ └── webpack.config.js
├── expo-module.config.json
├── ios
├── ExpoShareExtension.podspec
└── ExpoShareExtensionModule.swift
├── metro.js
├── package-lock.json
├── package.json
├── plugin
├── src
│ ├── index.ts
│ ├── withAppEntitlements.ts
│ ├── withAppInfoPlist.ts
│ ├── withExpoConfig.ts
│ ├── withPodfile.ts
│ ├── withShareExtensionEntitlements.ts
│ ├── withShareExtensionInfoPlist.ts
│ ├── withShareExtensionTarget.ts
│ └── xcode
│ │ ├── addBuildPhases.ts
│ │ ├── addPbxGroup.ts
│ │ ├── addProductFile.ts
│ │ ├── addTargetDependency.ts
│ │ ├── addToPbxNativeTargetSection.ts
│ │ ├── addToPbxProjectSection.ts
│ │ └── addToXCConfigurationList.ts
├── swift
│ └── ShareExtensionViewController.swift
└── tsconfig.json
├── src
├── ExpoShareExtensionModule.ts
└── index.ts
└── tsconfig.json
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: ['universe/native', 'universe/web'],
4 | ignorePatterns: ['build'],
5 | };
6 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [MaxAst]
2 |
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
3 |
4 | name: Node.js Package
5 |
6 | on:
7 | release:
8 | types: [created]
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v3
15 | - uses: actions/setup-node@v3
16 | with:
17 | node-version: 16
18 | - run: npm ci
19 |
20 | publish-npm:
21 | needs: build
22 | runs-on: ubuntu-latest
23 | steps:
24 | - uses: actions/checkout@v3
25 | - uses: actions/setup-node@v3
26 | with:
27 | node-version: 16
28 | registry-url: https://registry.npmjs.org/
29 | - run: npm ci
30 | - run: npm publish
31 | env:
32 | NODE_AUTH_TOKEN: ${{secrets.npm_token}}
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OSX
2 | #
3 | .DS_Store
4 |
5 | # VSCode
6 | .vscode/
7 | jsconfig.json
8 |
9 | # Xcode
10 | #
11 | build/
12 | *.pbxuser
13 | !default.pbxuser
14 | *.mode1v3
15 | !default.mode1v3
16 | *.mode2v3
17 | !default.mode2v3
18 | *.perspectivev3
19 | !default.perspectivev3
20 | xcuserdata
21 | *.xccheckout
22 | *.moved-aside
23 | DerivedData
24 | *.hmap
25 | *.ipa
26 | *.xcuserstate
27 | project.xcworkspace
28 |
29 | # Android/IJ
30 | #
31 | .classpath
32 | .cxx
33 | .gradle
34 | .idea
35 | .project
36 | .settings
37 | local.properties
38 | android.iml
39 | android/app/libs
40 | android/keystores/debug.keystore
41 |
42 | # Cocoapods
43 | #
44 | example/ios/Pods
45 |
46 | # Ruby
47 | example/vendor/
48 |
49 | # node.js
50 | #
51 | node_modules/
52 | npm-debug.log
53 | yarn-debug.log
54 | yarn-error.log
55 |
56 | # Expo
57 | .expo/*
58 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # Exclude all top-level hidden directories by convention
2 | /.*/
3 |
4 | __mocks__
5 | __tests__
6 |
7 | /babel.config.js
8 | /android/src/androidTest/
9 | /android/src/test/
10 | /android/build/
11 | /examples/
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Max
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Expo Share Extension
2 |
3 | 
4 | 
5 | 
6 | 
7 |
8 | > **Note**: Support for the New Architecture is under active development. For the time being, you need to disable it in your `app.json`/`app.config.(j|t)s`:
9 | >
10 | > ```json
11 | > {
12 | > "expo": {
13 | > "newArchEnabled": false
14 | > }
15 | > }
16 | > ```
17 |
18 | ## Overview
19 |
20 | Create an [iOS share extension](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Share.html) with a custom view (similar to e.g. Pinterest). Supports Apple Sign-In, [React Native Firebase](https://rnfirebase.io/) (including shared auth session via access groups), custom background, custom height, and custom fonts.
21 |
22 | https://github.com/MaxAst/expo-share-extension/assets/13224092/e5a6fb3d-6c85-4571-99c8-4efe0f862266
23 |
24 | ## Compatibility
25 |
26 | | Expo | `expo-share-extension` |
27 | | ---------- | ---------------------- |
28 | | **SDK 52** | 2.0.0+ |
29 | | **SDK 51** | 1.5.3+ |
30 | | **SDK 50** | 1.0.0+ |
31 |
32 | ## Quick Start
33 |
34 | ### 1. Installation
35 |
36 | ```sh
37 | npx expo install expo-share-extension
38 | ```
39 |
40 | ### 2. Basic Configuration
41 |
42 | 1. Update your `app.json` or `app.config.js`:
43 |
44 | ```json
45 | "expo": {
46 | ...
47 | "plugins": ["expo-share-extension"],
48 | ...
49 | }
50 | ```
51 |
52 | 2. Ensure your `package.json` has the correct `main` entry:
53 |
54 | ```json
55 | {
56 | ...
57 | "main": "index.js",
58 | ...
59 | }
60 | ```
61 |
62 | 3. Create the required entry points:
63 |
64 | `index.js` (main app):
65 |
66 | ```ts
67 | import { registerRootComponent } from "expo";
68 |
69 | import App from "./App";
70 |
71 | registerRootComponent(App);
72 |
73 | // or if you're using expo-router:
74 | // import "expo-router/entry";
75 | ```
76 |
77 | `index.share.js` (share extension):
78 |
79 | ```ts
80 | import { AppRegistry } from "react-native";
81 |
82 | // could be any component you want to use as the root component of your share extension's bundle
83 | import ShareExtension from "./ShareExtension";
84 |
85 | // IMPORTANT: the first argument to registerComponent, must be "shareExtension"
86 | AppRegistry.registerComponent("shareExtension", () => ShareExtension);
87 | ```
88 |
89 | 4. Wrap your metro config with `withShareExtension` in metro.config.js (if you don't have one, run: `npx expo customize metro.config.js` first):
90 |
91 | ```js
92 | // Learn more https://docs.expo.io/guides/customizing-metro
93 | const { getDefaultConfig } = require("expo/metro-config");
94 | const { withShareExtension } = require("expo-share-extension/metro");
95 |
96 | module.exports = withShareExtension(getDefaultConfig(__dirname), {
97 | // [Web-only]: Enables CSS support in Metro.
98 | isCSSEnabled: true,
99 | });
100 | ```
101 |
102 | ## Accessing Shared Data
103 |
104 | The shared data is passed to the share extension's root component as an initial prop based on this type:
105 |
106 | ```ts
107 | export type InitialProps = {
108 | files?: string[];
109 | images?: string[];
110 | videos?: string[];
111 | text?: string;
112 | url?: string;
113 | preprocessingResults?: unknown;
114 | };
115 | ```
116 |
117 | You can import `InitialProps` from `expo-share-extension` to use it as a type for your root component's props.
118 |
119 | ## Activation Rules
120 |
121 | The config plugin supports almost all [NSExtensionActivationRules](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/AppExtensionKeys.html#//apple_ref/doc/uid/TP40014212-SW10). It currently supports.
122 |
123 | - `NSExtensionActivationSupportsText`, which is triggered e.g. when sharing a WhatsApp message's contents or when selecting a text on a webpage and sharing it via the iOS tooltip menu. The result is passed as the `text` field in the initial props
124 | - `NSExtensionActivationSupportsWebURLWithMaxCount: 1`, which is triggered when using the share button in Safari. The result is passed as the `url` field in the initial props
125 | - `NSExtensionActivationSupportsWebPageWithMaxCount: 1`, which is triggered when using the share button in Safari. The result is passed as the `preprocessingResults` field in the initial props. When using this rule, you will no longer receive `url` as part of initial props, unless you extract it in your preprocessing JavaScript file. You can learn more about this in the [Preprocessing JavaScript](#preprocessing-javascript) section.
126 | - `NSExtensionActivationSupportsImageWithMaxCount: 1`, which is triggered when using the share button on an image. The result is passed as part of the `images` array in the initial props.
127 | - `NSExtensionActivationSupportsMovieWithMaxCount: 1`, which is triggered when using the share button on a video. The result is passed as part of the `videos` array in the initial props.
128 | - `NSExtensionActivationSupportsFileWithMaxCount: 1`, which is triggered when using the share button on a file. The result is passed as part of the `files` array in the initial props.
129 |
130 | You need to list the activation rules you want to use in your `app.json`/`app.config.(j|t)s` file like so:
131 |
132 | ```json
133 | [
134 | "expo-share-extension",
135 | {
136 | "activationRules": [
137 | {
138 | "type": "file",
139 | "max": 3
140 | },
141 | {
142 | "type": "image",
143 | "max": 2
144 | },
145 | {
146 | "type": "video",
147 | "max": 1
148 | },
149 | {
150 | "type": "text"
151 | },
152 | {
153 | "type": "url",
154 | "max": 1
155 | }
156 | ]
157 | }
158 | ]
159 | ```
160 |
161 | If no values for `max` are provided, the default value is `1`. The `type` field can be one of the following: `file`, `image`, `video`, `text`, `url`.
162 |
163 | If you want to use the `image` and `video` types, you need to make sure to add this to your `app.json`:
164 |
165 | ```jsonc
166 | {
167 | // ...
168 | "ios": {
169 | // ...
170 | "privacyManifests": {
171 | "NSPrivacyAccessedAPITypes": [
172 | {
173 | "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
174 | "NSPrivacyAccessedAPITypeReasons": ["C617.1"],
175 | },
176 | // ...
177 | ],
178 | },
179 | },
180 | }
181 | ```
182 |
183 | If you do not specify the `activationRules` option, `expo-share-extension` enables the `url` and `text` rules by default, for backwards compatibility.
184 |
185 | Contributions to support the remaining [NSExtensionActivationRules](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/AppExtensionKeys.html#//apple_ref/doc/uid/TP40014212-SW10) (`NSExtensionActivationSupportsAttachmentsWithMaxCount` and `NSExtensionActivationSupportsAttachmentsWithMinCount`) are welcome!
186 |
187 | ## Basic Usage
188 |
189 | Need a way to close the share extension? Use the `close` method from `expo-share-extension`:
190 |
191 | ```ts
192 | import { close } from "expo-share-extension"
193 | import { Button, Text, View } from "react-native";
194 |
195 | // if ShareExtension is your root component, url is available as an initial prop
196 | export default function ShareExtension({ url }: { url: string }) {
197 | return (
198 |
199 | {url}
200 |
201 |
202 | );
203 | }
204 | ```
205 |
206 | If you want to open the host app from the share extension, use the `openHostApp` method from `expo-share-extension` with a valid path:
207 |
208 | ```ts
209 | import { openHostApp } from "expo-share-extension"
210 | import { Button, Text, View } from "react-native";
211 |
212 | // if ShareExtension is your root component, url is available as an initial prop
213 | export default function ShareExtension({ url }: { url: string }) {
214 | const handleOpenHostApp = () => {
215 | openHostApp(`create?url=${url}`)
216 | }
217 |
218 | return (
219 |
220 | {url}
221 |
222 |
223 | );
224 | }
225 | ```
226 |
227 | When you share images and videos, `expo-share-extension` stores them in a `sharedData` directory in your app group's container.
228 | These files are not automatically cleaned up, so you should delete them when you're done with them. You can use the `clearAppGroupContainer` method from `expo-share-extension` to delete them:
229 |
230 | ```ts
231 | import { clearAppGroupContainer } from "expo-share-extension"
232 | import { Button, Text, View } from "react-native";
233 |
234 | // if ShareExtension is your root component, url is available as an initial prop
235 | export default function ShareExtension({ url }: { url: string }) {
236 | const handleCleanUp = async () => {
237 | await clearAppGroupContainer()
238 | }
239 |
240 | return (
241 |
242 | I have finished processing all shared images and videos
243 |
244 |
245 | );
246 | }
247 | ```
248 |
249 | ## Configuration Options
250 |
251 | ### Exlude Expo Modules
252 |
253 | Exclude unneeded expo modules to reduce the share extension's bundle size by adding the following to your `app.json`/`app.config.(j|t)s`:
254 |
255 | ```json
256 | [
257 | "expo-share-extension",
258 | {
259 | "excludedPackages": [
260 | "expo-dev-client",
261 | "expo-splash-screen",
262 | "expo-updates",
263 | "expo-font",
264 | ],
265 | },
266 | ],
267 | ```
268 |
269 | **Note**: The share extension does not support `expo-updates` as it causes the share extension to crash. Since version `1.5.0`, `expo-updates` is excluded from the share extension's bundle by default. If you're using an older version, you must exclude it by adding it to the `excludedPackages` option in your `app.json`/`app.config.(j|t)s`. See the [Exlude Expo Modules](#exlude-expo-modules) section for more information.
270 |
271 | ### React Native Firebase
272 |
273 | Using [React Native Firebase](https://rnfirebase.io/)? Given that share extensions are separate iOS targets, they have their own bundle IDs, so we need to create a _dedicated_ GoogleService-Info.plist in the Firebase console, just for the share extension target. The bundle ID of your share extension is your existing bundle ID with `.ShareExtension` as the suffix, e.g. `com.example.app.ShareExtension`.
274 |
275 | ```json
276 | [
277 | "expo-share-extension",
278 | {
279 | "googleServicesFile": "./path-to-your-separate/GoogleService-Info.plist",
280 | },
281 | ],
282 | ```
283 |
284 | You can share a firebase auth session between your main app and the share extension by using the [`useUserAccessGroup` hook](https://rnfirebase.io/reference/auth#useUserAccessGroup). The value for `userAccessGroup` is your main app's bundle ID with the `group.` prefix, e.g. `group.com.example.app`. For a full example, check [this](examples/with-firebase/README.md).
285 |
286 | ### Custom Background Color
287 |
288 | Want to customize the share extension's background color? Add the following to your `app.json`/`app.config.(j|t)s`:
289 |
290 | ```json
291 | [
292 | "expo-share-extension",
293 | {
294 | "backgroundColor": {
295 | "red": 255,
296 | "green": 255,
297 | "blue": 255,
298 | "alpha": 0.8 // if 0, the background will be transparent
299 | },
300 | },
301 | ],
302 | ```
303 |
304 | ### Custom Height
305 |
306 | Want to customize the share extension's height? Do this in your `app.json`/`app.config.(j|t)s`:
307 |
308 | ```json
309 | [
310 | "expo-share-extension",
311 | {
312 | "height": 500
313 | },
314 | ],
315 | ```
316 |
317 | ### Custom Fonts
318 |
319 | This plugin automatically adds custom fonts to the share extension target if they are [embedded in the native project](https://docs.expo.dev/develop/user-interface/fonts/#embed-font-in-a-native-project) via the `expo-font` config plugin.
320 |
321 | It currently does not support custom fonts that are [loaded at runtime](https://docs.expo.dev/develop/user-interface/fonts/#load-font-at-runtime), due to an `NSURLSesssion` [error](https://stackoverflow.com/questions/26172783/upload-nsurlsesssion-becomes-invalidated-in-sharing-extension-in-ios8-with-error). To fix this, Expo would need to support defining a [`sharedContainerIdentifier`](https://developer.apple.com/documentation/foundation/nsurlsessionconfiguration/1409450-sharedcontaineridentifier) for `NSURLSessionConfiguration` instances, where the value would be set to the main app's and share extension's app group identifier (e.g. `group.com.example.app`).
322 |
323 | ### Preprocessing JavaScript
324 |
325 | As explained in [Accessing a Webpage](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html#//apple_ref/doc/uid/TP40014214-CH21-SW12), we can use a JavaScript file to preprocess the webpage before the share extension is activated. This is useful if you want to extract the title and URL of the webpage, for example. To use this feature, add the following to your `app.json`/`app.config.(j|t)s`:
326 |
327 | ```json
328 | [
329 | "expo-share-extension",
330 | {
331 | "preprocessingFile": "./preprocessing.js"
332 | },
333 | ],
334 | ```
335 |
336 | The `preprocessingFile` option adds [`NSExtensionActivationSupportsWebPageWithMaxCount: 1`](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/nsextensionactivationrule/nsextensionactivationsupportswebpagewithmaxcount) as an `NSExtensionActivationRule`. Your preprocessing file must adhere to some rules:
337 |
338 | 1. You must create a class with a `run` method, which receives an object with a `completionFunction` method as its argument. This `completionFunction` method must be invoked at the end of your `run` method. The argument you pass to it, is what you will receive as the `preprocessingResults` object as part of initial props.
339 |
340 | ```javascript
341 | class ShareExtensionPreprocessor {
342 | run(args) {
343 | args.completionFunction({
344 | title: document.title,
345 | });
346 | }
347 | }
348 | ```
349 |
350 | 2. Your file must create an instance of a class using `var`, so that it is globally accessible.
351 |
352 | ```javascript
353 | var ExtensionPreprocessingJS = new ShareExtensionPreprocessor();
354 | ```
355 |
356 | For a full example, check [this](examples/with-preprocessing/README.md).
357 |
358 | **WARNING:** Using this option enables [`NSExtensionActivationSupportsWebPageWithMaxCount: 1`](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/nsextensionactivationrule/nsextensionactivationsupportswebpagewithmaxcount) and this is mutually exclusive with [`NSExtensionActivationSupportsWebURLWithMaxCount: 1`](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/nsextensionactivationrule/nsextensionactivationsupportsweburlwithmaxcount), which `expo-share-extension` enables by default. This means that once you set the `preprocessingFile` option, you will no longer receive `url` as part of initial props. However, you can still get the URL via `preprocessingResults` by using `window.location.href` in your preprocessing file:
359 |
360 | ```javascript
361 | class ShareExtensionPreprocessor {
362 | run(args) {
363 | args.completionFunction({
364 | url: window.location.href,
365 | title: document.title,
366 | });
367 | }
368 | }
369 | ```
370 |
371 | ## Development
372 |
373 | If you want to contribute to this project, you can use the example app to test your changes. Run the following commands to get started:
374 |
375 | 1. Start the expo module build in watch mode: `npm run build`
376 | 2. Start the config plugin build in watch mode: `npm run build plugin`
377 | 3. `cd /example` and generate the iOS project: `npm run prebuild`
378 | 4. Run the app from the /example folder: `npm run ios`
379 |
380 | ### Troubleshooting
381 |
382 | #### Command PhaseScriptExecution failed with a nonzero exit code
383 |
384 | If you encounter this error when building your app in XCode and you use yarn as a package manager, it is most likely caused by XCode using the wrong node binary. To fix this, navigate into your project's ios directory and replace the contents in the `.xcode.env.local` file with the contents of the `.xcode.env` file.
385 |
386 | #### Clear XCode Cache
387 |
388 | 1. navigate to `~/Library/Developer/Xcode/DerivedData/`
389 | 2. `rm -rf` folders that are prefixed with your project name
390 |
391 | #### Clear CocoaPods Cache
392 |
393 | 1. `pod cache clean --all`
394 | 2. `pod deintegrate`
395 |
396 | #### Attach Debugger to Share Extension Process:
397 |
398 | 1. In XCode in the top menu, navigate to Debug > Attach to Process.
399 | 2. In the submenu, you should see a list of running processes. Find your share extension's name in this list. If you don't see it, you can try typing its name into the search box at the bottom.
400 | 3. Once you've located your share extension's process, click on it to attach the debugger to that process.
401 | 4. With the debugger attached, you can also set breakpoints within your share extension's code. If these breakpoints are hit, Xcode will pause execution and allow you to inspect variables and step through your code, just like you would with your main app.
402 |
403 | #### Check Device Logs
404 |
405 | 1. Open the Console app from the Applications/Utilities folder
406 | 2. Select your device from the Devices list
407 | 3. Filter the log messages by process name matching your share extension target name
408 |
409 | #### Check Crash Logs
410 |
411 | 1. On your Mac, open Finder.
412 | 2. Select Go > Go to Folder from the menu bar or press Shift + Cmd + G.
413 | 3. Enter ~/Library/Logs/DiagnosticReports/ and click Go.
414 | 4. Look for any recent crash logs related to your share extension. These logs should have a .crash or .ips extension.
415 |
416 | ## Credits
417 |
418 | This project would not be possible without existing work in the react native ecosystem. I'd like to give credit to the following projects and their authors:
419 |
420 | - https://github.com/Expensify/react-native-share-menu
421 | - https://github.com/andrewsardone/react-native-ios-share-extension
422 | - https://github.com/alinz/react-native-share-extension
423 | - https://github.com/ajith-ab/react-native-receive-sharing-intent
424 | - https://github.com/timedtext/expo-config-plugin-ios-share-extension
425 | - https://github.com/achorein/expo-share-intent-demo
426 | - https://github.com/andrewsardone/react-native-ios-share-extension
427 | - https://github.com/EvanBacon/pillar-valley/tree/master/targets/widgets
428 | - https://github.com/andrew-levy/react-native-safari-extension
429 | - https://github.com/bndkt/react-native-app-clip
430 |
--------------------------------------------------------------------------------
/app.plugin.js:
--------------------------------------------------------------------------------
1 | module.exports = require("./plugin/build");
2 |
--------------------------------------------------------------------------------
/examples/basic/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 |
11 | # Native
12 | *.orig.*
13 | *.jks
14 | *.p8
15 | *.p12
16 | *.key
17 | *.mobileprovision
18 |
19 | # Metro
20 | .metro-health-check*
21 |
22 | # debug
23 | npm-debug.*
24 | yarn-debug.*
25 | yarn-error.*
26 |
27 | # macOS
28 | .DS_Store
29 | *.pem
30 |
31 | # local env files
32 | .env*.local
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
37 | android/
38 | ios/
--------------------------------------------------------------------------------
/examples/basic/App.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, Text, View } from "react-native";
2 |
3 | export default function App() {
4 | return (
5 |
6 |
9 | Basic Example
10 |
11 |
18 | Go to Safari and open the share menu to trigger this app's share
19 | extension.
20 |
21 |
22 | );
23 | }
24 |
25 | const styles = StyleSheet.create({
26 | container: {
27 | flex: 1,
28 | backgroundColor: "#FAF8F5",
29 | alignItems: "center",
30 | justifyContent: "center",
31 | padding: 30,
32 | },
33 | });
34 |
--------------------------------------------------------------------------------
/examples/basic/README.md:
--------------------------------------------------------------------------------
1 | # Basic Example
2 |
3 | This example demonstrates the `backgroundColor` and `height` options that you can provide in app.json to customize the look of the native view. It'd be configured in app.json like so:
4 |
5 | ```json
6 | [
7 | "expo-share-extension",
8 | {
9 | "backgroundColor": {
10 | "red": 255,
11 | "green": 255,
12 | "blue": 255,
13 | "alpha": 0 // make the background transparent
14 | },
15 | "height": 500
16 | }
17 | ]
18 | ```
19 |
20 | ## Usage
21 |
22 | 1. Run Prebuild
23 |
24 | ```bash
25 | npm run prebuild
26 | ```
27 |
28 | 2. Start the app via Expo CLI
29 |
30 | ```bash
31 | npm run ios
32 | ```
33 |
34 | 3. or only start the metro server and build via XCode
35 |
36 | ```bash
37 | npm run start
38 | ```
39 |
--------------------------------------------------------------------------------
/examples/basic/ShareExtension.tsx:
--------------------------------------------------------------------------------
1 | import { close, type InitialProps } from "expo-share-extension";
2 | import { useEffect } from "react";
3 | import { Button, StyleSheet, Text, View } from "react-native";
4 | import Animated, {
5 | useSharedValue,
6 | withSpring,
7 | useAnimatedStyle,
8 | } from "react-native-reanimated";
9 |
10 | export default function ShareExtension({ url, text }: InitialProps) {
11 | const opacity = useSharedValue(0);
12 | const scale = useSharedValue(0.8);
13 |
14 | useEffect(() => {
15 | // Animate in when component mounts
16 | opacity.value = withSpring(1, { damping: 20, stiffness: 90 });
17 | scale.value = withSpring(1, { damping: 20, stiffness: 90 });
18 | }, []);
19 |
20 | const animatedStyle = useAnimatedStyle(() => {
21 | return {
22 | opacity: opacity.value,
23 | transform: [{ scale: scale.value }],
24 | };
25 | });
26 |
27 | return (
28 |
29 |
30 |
33 | Basic Example
34 |
35 | {url && (
36 |
43 | URL: {url}
44 |
45 | )}
46 | {text && (
47 |
54 | Text: {text}
55 |
56 | )}
57 |
58 |
59 |
60 | );
61 | }
62 |
63 | const styles = StyleSheet.create({
64 | container: {
65 | flex: 1,
66 | borderRadius: 20,
67 | backgroundColor: "#FAF8F5",
68 | alignItems: "center",
69 | justifyContent: "center",
70 | padding: 30,
71 | },
72 | contentContainer: {
73 | width: "100%",
74 | alignItems: "center",
75 | justifyContent: "center",
76 | },
77 | });
78 |
--------------------------------------------------------------------------------
/examples/basic/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "basic",
4 | "slug": "basic",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "userInterfaceStyle": "light",
9 | "splash": {
10 | "image": "./assets/splash.png",
11 | "resizeMode": "contain",
12 | "backgroundColor": "#ffffff"
13 | },
14 | "assetBundlePatterns": ["**/*"],
15 | "ios": {
16 | "supportsTablet": true,
17 | "bundleIdentifier": "expo.modules.shareextension.basic"
18 | },
19 | "android": {
20 | "adaptiveIcon": {
21 | "foregroundImage": "./assets/adaptive-icon.png",
22 | "backgroundColor": "#ffffff"
23 | },
24 | "package": "expo.modules.shareextension.basic"
25 | },
26 | "web": {
27 | "favicon": "./assets/favicon.png"
28 | },
29 | "plugins": [
30 | [
31 | "expo-font",
32 | {
33 | "fonts": ["./assets/fonts/Inter-Black.otf"]
34 | }
35 | ],
36 | [
37 | "../../app.plugin.js",
38 | {
39 | "backgroundColor": {
40 | "red": 255,
41 | "green": 255,
42 | "blue": 255,
43 | "alpha": 0 // make the background transparent
44 | },
45 | "height": 500
46 | }
47 | ]
48 | ],
49 | "extra": {
50 | "eas": {
51 | "build": {
52 | "experimental": {
53 | "ios": {
54 | "appExtensions": [
55 | {
56 | "targetName": "basicShareExtension",
57 | "bundleIdentifier": "expo.modules.shareextension.basic.ShareExtension",
58 | "entitlements": {
59 | "com.apple.security.application-groups": [
60 | "group.expo.modules.shareextension.basic"
61 | ]
62 | }
63 | }
64 | ]
65 | }
66 | }
67 | },
68 | "projectId": "f729978a-6def-41c5-96b5-ae416ab2112f"
69 | }
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/examples/basic/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/basic/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/examples/basic/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/basic/assets/favicon.png
--------------------------------------------------------------------------------
/examples/basic/assets/fonts/Inter-Black.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/basic/assets/fonts/Inter-Black.otf
--------------------------------------------------------------------------------
/examples/basic/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/basic/assets/icon.png
--------------------------------------------------------------------------------
/examples/basic/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/basic/assets/splash.png
--------------------------------------------------------------------------------
/examples/basic/babel.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | module.exports = function (api) {
3 | api.cache(true);
4 | return {
5 | presets: ["babel-preset-expo"],
6 | plugins: [
7 | [
8 | "module-resolver",
9 | {
10 | extensions: [".tsx", ".ts", ".js", ".json"],
11 | alias: {
12 | // For development, we want to alias the library to the source
13 | "expo-share-extension": path.join(
14 | __dirname,
15 | "..",
16 | "..",
17 | "src",
18 | "index.ts"
19 | ),
20 | },
21 | },
22 | ],
23 | ],
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/examples/basic/eas.json:
--------------------------------------------------------------------------------
1 | {
2 | "cli": {
3 | "version": ">= 5.3.0",
4 | "promptToConfigurePushNotifications": false
5 | },
6 | "build": {
7 | "development": {
8 | "developmentClient": true,
9 | "distribution": "internal"
10 | },
11 | "preview": {
12 | "distribution": "internal"
13 | },
14 | "production": {}
15 | },
16 | "submit": {
17 | "production": {}
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/basic/index.js:
--------------------------------------------------------------------------------
1 | import { registerRootComponent } from "expo";
2 |
3 | import App from "./App";
4 |
5 | registerRootComponent(App);
6 |
--------------------------------------------------------------------------------
/examples/basic/index.share.js:
--------------------------------------------------------------------------------
1 | import { AppRegistry } from "react-native";
2 |
3 | import ShareExtension from "./ShareExtension";
4 |
5 | AppRegistry.registerComponent("shareExtension", () => ShareExtension);
6 |
--------------------------------------------------------------------------------
/examples/basic/metro.config.js:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.io/guides/customizing-metro
2 | const { getDefaultConfig } = require("expo/metro-config");
3 | const path = require("path");
4 |
5 | const { withShareExtension } = require("../../metro.js");
6 |
7 | const config = getDefaultConfig(__dirname);
8 |
9 | // npm v7+ will install ../node_modules/react-native because of peerDependencies.
10 | // To prevent the incompatible react-native bewtween ./node_modules/react-native and ../node_modules/react-native,
11 | // excludes the one from the parent folder when bundling.
12 | config.resolver.blockList = [
13 | ...Array.from(config.resolver.blockList ?? []),
14 | new RegExp(path.resolve("..", "..", "node_modules", "react-native")),
15 | ];
16 |
17 | config.resolver.nodeModulesPaths = [
18 | path.resolve(__dirname, "./node_modules"),
19 | path.resolve(__dirname, "../../node_modules"),
20 | ];
21 |
22 | config.watchFolders = [path.resolve(__dirname, "../..")];
23 |
24 | module.exports = withShareExtension(config);
25 |
--------------------------------------------------------------------------------
/examples/basic/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "basic",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo run:android",
8 | "ios": "expo run:ios",
9 | "web": "expo start --web",
10 | "eas-build-pre-install": "cd ../.. && npm install && npm run build && npm run build plugin",
11 | "postinstall": "npx patch-package",
12 | "prebuild": "expo prebuild -p ios --clean"
13 | },
14 | "dependencies": {
15 | "expo": "~52.0.18",
16 | "expo-dev-client": "~5.0.6",
17 | "expo-splash-screen": "~0.29.18",
18 | "expo-status-bar": "~2.0.0",
19 | "react": "18.3.1",
20 | "react-native": "0.76.5",
21 | "react-native-reanimated": "^3.17.0"
22 | },
23 | "devDependencies": {
24 | "@babel/core": "^7.26.0",
25 | "@types/react": "~18.3.12",
26 | "typescript": "~5.7.2"
27 | },
28 | "private": true,
29 | "expo": {
30 | "autolinking": {
31 | "nativeModulesDir": "../.."
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/examples/basic/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "expo-share-extension": ["../../src/index"],
7 | "expo-share-extension/*": ["../../src/*"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/examples/basic/webpack.config.js:
--------------------------------------------------------------------------------
1 | const createConfigAsync = require("@expo/webpack-config");
2 | const path = require("path");
3 |
4 | module.exports = async (env, argv) => {
5 | const config = await createConfigAsync(
6 | {
7 | ...env,
8 | babel: {
9 | dangerouslyAddModulePathsToTranspile: ["expo-share-extension"],
10 | },
11 | },
12 | argv
13 | );
14 | config.resolve.modules = [
15 | path.resolve(__dirname, "./node_modules"),
16 | path.resolve(__dirname, "../../node_modules"),
17 | ];
18 |
19 | return config;
20 | };
21 |
--------------------------------------------------------------------------------
/examples/with-file/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 |
11 | # Native
12 | *.orig.*
13 | *.jks
14 | *.p8
15 | *.p12
16 | *.key
17 | *.mobileprovision
18 |
19 | # Metro
20 | .metro-health-check*
21 |
22 | # debug
23 | npm-debug.*
24 | yarn-debug.*
25 | yarn-error.*
26 |
27 | # macOS
28 | .DS_Store
29 | *.pem
30 |
31 | # local env files
32 | .env*.local
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
37 | android/
38 | ios/
--------------------------------------------------------------------------------
/examples/with-file/README.md:
--------------------------------------------------------------------------------
1 | # File Example
2 |
3 | This example demonstrates how to share files. It'd be configured in app.json like so:
4 |
5 | ```json
6 | [
7 | "expo-share-extension",
8 | {
9 | "activationRules": [
10 | {
11 | "type": "file",
12 | "max": 2
13 | }
14 | ]
15 | }
16 | ]
17 | ```
18 |
19 | ## Usage
20 |
21 | 1. Run Prebuild
22 |
23 | ```bash
24 | npm run prebuild
25 | ```
26 |
27 | 2. Start the app via Expo CLI
28 |
29 | ```bash
30 | npm run ios
31 | ```
32 |
33 | 3. or only start the metro server and build via XCode
34 |
35 | ```bash
36 | npm run start
37 | ```
38 |
--------------------------------------------------------------------------------
/examples/with-file/ShareExtension.tsx:
--------------------------------------------------------------------------------
1 | import { type InitialProps, close, openHostApp } from "expo-share-extension";
2 | import { useCallback } from "react";
3 | import { Button, StyleSheet, Text, View } from "react-native";
4 |
5 | export default function ShareExtension({ files }: InitialProps) {
6 | const handleOpenHostApp = useCallback(() => {
7 | if (files?.length) {
8 | openHostApp(`/create?fileURL=${files[0]}`);
9 | }
10 | }, [files]);
11 |
12 | return (
13 |
14 |
17 | File Example
18 |
19 | {files?.length ? (
20 |
27 | Files: {JSON.stringify(files)}
28 |
29 | ) : null}
30 |
31 |
32 |
33 | );
34 | }
35 |
36 | const styles = StyleSheet.create({
37 | container: {
38 | flex: 1,
39 | borderRadius: 20,
40 | backgroundColor: "#FAF8F5",
41 | alignItems: "center",
42 | justifyContent: "center",
43 | padding: 30,
44 | },
45 | });
46 |
--------------------------------------------------------------------------------
/examples/with-file/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "with-file",
4 | "slug": "with-file",
5 | "scheme": "withfile",
6 | "version": "1.0.0",
7 | "orientation": "portrait",
8 | "icon": "./assets/icon.png",
9 | "userInterfaceStyle": "light",
10 | "splash": {
11 | "image": "./assets/splash.png",
12 | "resizeMode": "contain",
13 | "backgroundColor": "#ffffff"
14 | },
15 | "assetBundlePatterns": [
16 | "**/*"
17 | ],
18 | "ios": {
19 | "supportsTablet": true,
20 | "bundleIdentifier": "expo.modules.shareextension.withfile"
21 | },
22 | "android": {
23 | "adaptiveIcon": {
24 | "foregroundImage": "./assets/adaptive-icon.png",
25 | "backgroundColor": "#ffffff"
26 | },
27 | "package": "expo.modules.shareextension.withfile"
28 | },
29 | "web": {
30 | "favicon": "./assets/favicon.png"
31 | },
32 | "plugins": [
33 | [
34 | "expo-font",
35 | {
36 | "fonts": [
37 | "./assets/fonts/Inter-Black.otf"
38 | ]
39 | }
40 | ],
41 | [
42 | "../../app.plugin.js",
43 | {
44 | "activationRules": [
45 | {
46 | "type": "file",
47 | "max": 5
48 | }
49 | ]
50 | }
51 | ],
52 | "expo-router"
53 | ]
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/examples/with-file/app/create.tsx:
--------------------------------------------------------------------------------
1 | import { useLocalSearchParams } from "expo-router";
2 | import { StyleSheet, Text, View } from "react-native";
3 |
4 | export default function Create() {
5 | const { fileURL } = useLocalSearchParams();
6 |
7 | return (
8 |
9 |
12 | Create File
13 |
14 |
21 | {fileURL ? `File URL: ${fileURL}` : "No file url"}
22 |
23 |
24 | );
25 | }
26 |
27 | const styles = StyleSheet.create({
28 | container: {
29 | flex: 1,
30 | backgroundColor: "#FAF8F5",
31 | alignItems: "center",
32 | justifyContent: "center",
33 | padding: 30,
34 | },
35 | });
36 |
--------------------------------------------------------------------------------
/examples/with-file/app/index.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, Text, View } from "react-native";
2 |
3 | export default function Index() {
4 | return (
5 |
6 |
9 | File Example
10 |
11 |
18 | Open the files app and share a file to trigger this app's share
19 | extension.
20 |
21 |
22 | );
23 | }
24 |
25 | const styles = StyleSheet.create({
26 | container: {
27 | flex: 1,
28 | backgroundColor: "#FAF8F5",
29 | alignItems: "center",
30 | justifyContent: "center",
31 | padding: 30,
32 | },
33 | });
34 |
--------------------------------------------------------------------------------
/examples/with-file/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-file/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/examples/with-file/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-file/assets/favicon.png
--------------------------------------------------------------------------------
/examples/with-file/assets/fonts/Inter-Black.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-file/assets/fonts/Inter-Black.otf
--------------------------------------------------------------------------------
/examples/with-file/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-file/assets/icon.png
--------------------------------------------------------------------------------
/examples/with-file/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-file/assets/splash.png
--------------------------------------------------------------------------------
/examples/with-file/babel.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | module.exports = function (api) {
3 | api.cache(true);
4 | return {
5 | presets: ["babel-preset-expo"],
6 | plugins: [
7 | [
8 | "module-resolver",
9 | {
10 | extensions: [".tsx", ".ts", ".js", ".json"],
11 | alias: {
12 | // For development, we want to alias the library to the source
13 | "expo-share-extension": path.join(
14 | __dirname,
15 | "..",
16 | "..",
17 | "src",
18 | "index.ts"
19 | ),
20 | },
21 | },
22 | ],
23 | ],
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/examples/with-file/eas.json:
--------------------------------------------------------------------------------
1 | {
2 | "cli": {
3 | "version": ">= 5.3.0",
4 | "promptToConfigurePushNotifications": false
5 | },
6 | "build": {
7 | "development": {
8 | "developmentClient": true,
9 | "distribution": "internal"
10 | },
11 | "preview": {
12 | "distribution": "internal"
13 | },
14 | "production": {}
15 | },
16 | "submit": {
17 | "production": {}
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/with-file/index.js:
--------------------------------------------------------------------------------
1 | import "expo-router/entry";
2 |
--------------------------------------------------------------------------------
/examples/with-file/index.share.js:
--------------------------------------------------------------------------------
1 | import { AppRegistry } from "react-native";
2 |
3 | import ShareExtension from "./ShareExtension";
4 |
5 | AppRegistry.registerComponent("shareExtension", () => ShareExtension);
6 |
--------------------------------------------------------------------------------
/examples/with-file/metro.config.js:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.io/guides/customizing-metro
2 | const { getDefaultConfig } = require("expo/metro-config");
3 | const path = require("path");
4 |
5 | const config = getDefaultConfig(__dirname);
6 |
7 | // npm v7+ will install ../node_modules/react-native because of peerDependencies.
8 | // To prevent the incompatible react-native bewtween ./node_modules/react-native and ../node_modules/react-native,
9 | // excludes the one from the parent folder when bundling.
10 | config.resolver.blockList = [
11 | ...Array.from(config.resolver.blockList ?? []),
12 | new RegExp(path.resolve("..", "..", "node_modules", "react-native")),
13 | ];
14 |
15 | config.resolver.nodeModulesPaths = [
16 | path.resolve(__dirname, "./node_modules"),
17 | path.resolve(__dirname, "../../node_modules"),
18 | ];
19 |
20 | config.watchFolders = [path.resolve(__dirname, "../..")];
21 |
22 | config.transformer.getTransformOptions = () => ({
23 | resolver: {
24 | sourceExts: [...config.resolver.sourceExts, "share.js"], // Add 'share.js' as a recognized extension
25 | },
26 | });
27 |
28 | module.exports = config;
29 |
--------------------------------------------------------------------------------
/examples/with-file/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "with-file",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo run:android",
8 | "ios": "expo run:ios",
9 | "web": "expo start --web",
10 | "eas-build-pre-install": "cd ../.. && npm install && npm run build && npm run build plugin",
11 | "postinstall": "npx patch-package",
12 | "prebuild": "expo prebuild -p ios --clean"
13 | },
14 | "dependencies": {
15 | "expo": "~51.0.38",
16 | "expo-dev-client": "~4.0.28",
17 | "expo-splash-screen": "~0.27.6",
18 | "expo-status-bar": "~1.12.1",
19 | "expo-updates": "^0.25.27",
20 | "react": "18.2.0",
21 | "react-native": "0.74.5",
22 | "zod": "^3.23.8",
23 | "expo-router": "~3.5.23",
24 | "react-native-safe-area-context": "4.10.5",
25 | "react-native-screens": "3.31.1",
26 | "expo-linking": "~6.3.1",
27 | "expo-constants": "~16.0.2"
28 | },
29 | "devDependencies": {
30 | "@babel/core": "^7.25.9",
31 | "@types/react": "~18.2.79",
32 | "typescript": "~5.3.3"
33 | },
34 | "private": true,
35 | "expo": {
36 | "autolinking": {
37 | "nativeModulesDir": "../.."
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/examples/with-file/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "expo-share-extension": ["../../src/index"],
7 | "expo-share-extension/*": ["../../src/*"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/examples/with-file/webpack.config.js:
--------------------------------------------------------------------------------
1 | const createConfigAsync = require("@expo/webpack-config");
2 | const path = require("path");
3 |
4 | module.exports = async (env, argv) => {
5 | const config = await createConfigAsync(
6 | {
7 | ...env,
8 | babel: {
9 | dangerouslyAddModulePathsToTranspile: ["expo-share-extension"],
10 | },
11 | },
12 | argv
13 | );
14 | config.resolve.modules = [
15 | path.resolve(__dirname, "./node_modules"),
16 | path.resolve(__dirname, "../../node_modules"),
17 | ];
18 |
19 | return config;
20 | };
21 |
--------------------------------------------------------------------------------
/examples/with-firebase/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 |
11 | # Native
12 | *.orig.*
13 | *.jks
14 | *.p8
15 | *.p12
16 | *.key
17 | *.mobileprovision
18 |
19 | # Metro
20 | .metro-health-check*
21 |
22 | # debug
23 | npm-debug.*
24 | yarn-debug.*
25 | yarn-error.*
26 |
27 | # macOS
28 | .DS_Store
29 | *.pem
30 |
31 | # local env files
32 | .env*.local
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
37 | android/
38 | ios/
39 |
40 | firebase-credentials
--------------------------------------------------------------------------------
/examples/with-firebase/App.tsx:
--------------------------------------------------------------------------------
1 | import auth, { type FirebaseAuthTypes } from "@react-native-firebase/auth";
2 | import { useEffect, useState } from "react";
3 | import { Alert, Button, StyleSheet, Text, View } from "react-native";
4 |
5 | import { AppleAuthLoginButton } from "./components/AppleAuthLogin";
6 |
7 | export default function App() {
8 | const [session, setSession] = useState(null);
9 |
10 | useEffect(() => {
11 | auth()
12 | .useUserAccessGroup("group.expo.modules.shareextension.withfirebase")
13 | .catch((error) => {
14 | console.log(error);
15 | });
16 | }, []);
17 |
18 | useEffect(() => {
19 | const unsubscribe = auth().onAuthStateChanged(async (user) => {
20 | if (user) {
21 | try {
22 | setSession(user);
23 | } catch (error) {
24 | console.log(error);
25 | }
26 | } else {
27 | setSession(null);
28 | }
29 | });
30 | return () => {
31 | unsubscribe();
32 | };
33 | }, [setSession]);
34 |
35 | return (
36 |
37 |
40 | Basic Example
41 |
42 |
49 | After logging in, go to Safari and open the share menu to trigger this
50 | app's share extension. You should see that you are still logged in,
51 | because we are using the useUserAccessGroup hook with our app group
52 | name.
53 |
54 |
55 | {session ? (
56 |
57 |
58 | Firebase User ID: {session.uid}
59 |
60 |
71 | ) : (
72 |
73 | )}
74 |
75 |
76 | );
77 | }
78 |
79 | const styles = StyleSheet.create({
80 | container: {
81 | flex: 1,
82 | backgroundColor: "#FAF8F5",
83 | alignItems: "center",
84 | justifyContent: "center",
85 | padding: 30,
86 | },
87 | });
88 |
--------------------------------------------------------------------------------
/examples/with-firebase/README.md:
--------------------------------------------------------------------------------
1 | # Firebase Example
2 |
3 | This example demonstrates the use of [React Native Firebase](https://rnfirebase.io/). Given that share extensions are separate iOS targets, they have their own bundle IDs, so we need to create a _dedicated_ GoogleService-Info.plist in the Firebase console, just for the share extension target. The bundle ID of your share extension is your existing bundle ID with `.ShareExtension` as the suffix, e.g. `com.example.app.ShareExtension`.
4 |
5 | ```json
6 | [
7 | "expo-share-extension",
8 | {
9 | "googleServicesFile": "./path-to-your-separate/GoogleService-Info.plist",
10 | },
11 | ],
12 | ```
13 |
14 | ## Usage
15 |
16 | 1. Run Prebuild
17 |
18 | ```bash
19 | npm run prebuild
20 | ```
21 |
22 | 2. Start the app via Expo CLI
23 |
24 | ```bash
25 | npm run ios
26 | ```
27 |
28 | 3. or only start the metro server and build via XCode
29 |
30 | ```bash
31 | npm run start
32 | ```
33 |
--------------------------------------------------------------------------------
/examples/with-firebase/ShareExtension.tsx:
--------------------------------------------------------------------------------
1 | import auth, { type FirebaseAuthTypes } from "@react-native-firebase/auth";
2 | import { type InitialProps, close } from "expo-share-extension";
3 | import { useEffect, useState } from "react";
4 | import { Alert, Button, Text, View, StyleSheet } from "react-native";
5 |
6 | import { AppleAuthLoginButton } from "./components/AppleAuthLogin";
7 |
8 | export default function ShareExtension({ url, text }: InitialProps) {
9 | const [session, setSession] = useState(null);
10 |
11 | useEffect(() => {
12 | auth()
13 | .useUserAccessGroup("group.expo.modules.shareextension.withfirebase")
14 | .catch((error) => {
15 | console.log(error);
16 | });
17 | }, []);
18 |
19 | useEffect(() => {
20 | const unsubscribe = auth().onAuthStateChanged(async (user) => {
21 | if (user) {
22 | try {
23 | setSession(user);
24 | } catch (error) {
25 | console.log(error);
26 | }
27 | } else {
28 | setSession(null);
29 | }
30 | });
31 | return () => {
32 | unsubscribe();
33 | };
34 | }, [setSession]);
35 |
36 | return (
37 |
38 |
41 | Firebase Example
42 |
43 | {url && (
44 |
51 | URL: {url}
52 |
53 | )}
54 | {text && (
55 |
62 | Text: {text}
63 |
64 | )}
65 |
66 |
67 | {session ? (
68 |
69 |
76 | Firebase User ID: {session.uid}
77 |
78 |
89 | ) : (
90 |
91 | )}
92 |
93 |
94 | );
95 | }
96 |
97 | const styles = StyleSheet.create({
98 | container: {
99 | flex: 1,
100 | borderRadius: 20,
101 | backgroundColor: "#FAF8F5",
102 | alignItems: "center",
103 | justifyContent: "center",
104 | padding: 30,
105 | },
106 | });
107 |
--------------------------------------------------------------------------------
/examples/with-firebase/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "with-firebase",
4 | "slug": "with-firebase",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "userInterfaceStyle": "light",
9 | "splash": {
10 | "image": "./assets/splash.png",
11 | "resizeMode": "contain",
12 | "backgroundColor": "#ffffff"
13 | },
14 | "assetBundlePatterns": ["**/*"],
15 | "ios": {
16 | "supportsTablet": true,
17 | "bundleIdentifier": "expo.modules.shareextension.withfirebase",
18 | "googleServicesFile": "./firebase-credentials/GoogleService-Info.plist",
19 | "usesAppleSignIn": true
20 | },
21 | "android": {
22 | "adaptiveIcon": {
23 | "foregroundImage": "./assets/adaptive-icon.png",
24 | "backgroundColor": "#ffffff"
25 | },
26 | "package": "expo.modules.shareextension.withfirebase",
27 | "googleServicesFile": "./firebase-credentials/google-services.json"
28 | },
29 | "web": {
30 | "favicon": "./assets/favicon.png"
31 | },
32 | "plugins": [
33 | [
34 | "expo-font",
35 | {
36 | "fonts": ["./assets/fonts/Inter-Black.otf"]
37 | }
38 | ],
39 | [
40 | "../../app.plugin.js",
41 | {
42 | "excludedPackages": [
43 | "expo-dev-client",
44 | "expo-splash-screen",
45 | "expo-status-bar"
46 | ],
47 | "googleServicesFile": "./firebase-credentials/share-extension/GoogleService-Info.plist"
48 | }
49 | ],
50 | "expo-apple-authentication",
51 | "@react-native-firebase/app",
52 | "@react-native-firebase/auth",
53 | [
54 | "expo-build-properties",
55 | {
56 | "ios": {
57 | "useFrameworks": "static"
58 | }
59 | }
60 | ]
61 | ],
62 | "extra": {
63 | "eas": {
64 | "build": {
65 | "experimental": {
66 | "ios": {
67 | "appExtensions": [
68 | {
69 | "targetName": "withfirebaseShareExtension",
70 | "bundleIdentifier": "expo.modules.shareextension.withfirebase.ShareExtension",
71 | "entitlements": {
72 | "com.apple.security.application-groups": [
73 | "group.expo.modules.shareextension.withfirebase"
74 | ]
75 | }
76 | }
77 | ]
78 | }
79 | }
80 | },
81 | "projectId": "6f0eb684-6598-4172-a0f4-3b97856fb527"
82 | }
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/examples/with-firebase/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-firebase/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/examples/with-firebase/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-firebase/assets/favicon.png
--------------------------------------------------------------------------------
/examples/with-firebase/assets/fonts/Inter-Black.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-firebase/assets/fonts/Inter-Black.otf
--------------------------------------------------------------------------------
/examples/with-firebase/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-firebase/assets/icon.png
--------------------------------------------------------------------------------
/examples/with-firebase/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-firebase/assets/splash.png
--------------------------------------------------------------------------------
/examples/with-firebase/babel.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | module.exports = function (api) {
3 | api.cache(true);
4 | return {
5 | presets: ["babel-preset-expo"],
6 | plugins: [
7 | [
8 | "module-resolver",
9 | {
10 | extensions: [".tsx", ".ts", ".js", ".json"],
11 | alias: {
12 | // For development, we want to alias the library to the source
13 | "expo-share-extension": path.join(
14 | __dirname,
15 | "..",
16 | "..",
17 | "src",
18 | "index.ts"
19 | ),
20 | },
21 | },
22 | ],
23 | ],
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/examples/with-firebase/components/AppleAuthLogin.tsx:
--------------------------------------------------------------------------------
1 | import auth from "@react-native-firebase/auth";
2 | import * as AppleAuthentication from "expo-apple-authentication";
3 |
4 | const signInWithApple = async () => {
5 | try {
6 | const { state, identityToken } = await AppleAuthentication.signInAsync({
7 | requestedScopes: [
8 | AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
9 | AppleAuthentication.AppleAuthenticationScope.EMAIL,
10 | ],
11 | });
12 |
13 | const credential = auth.AppleAuthProvider.credential(
14 | identityToken,
15 | state || undefined
16 | );
17 |
18 | await auth().signInWithCredential(credential);
19 | } catch (error: any) {
20 | console.log(`${JSON.stringify(error, null, 2)}`);
21 | if (error.code === "ERR_CANCELED") {
22 | // handle that the user canceled the sign-in flow
23 | } else {
24 | // handle other errors
25 | }
26 | }
27 | };
28 |
29 | export const AppleAuthLoginButton = () => {
30 | return (
31 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/examples/with-firebase/eas.json:
--------------------------------------------------------------------------------
1 | {
2 | "cli": {
3 | "version": ">= 5.3.0",
4 | "promptToConfigurePushNotifications": false
5 | },
6 | "build": {
7 | "development": {
8 | "developmentClient": true,
9 | "distribution": "internal"
10 | },
11 | "preview": {
12 | "distribution": "internal"
13 | },
14 | "production": {}
15 | },
16 | "submit": {
17 | "production": {}
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/with-firebase/index.js:
--------------------------------------------------------------------------------
1 | import { registerRootComponent } from "expo";
2 |
3 | import App from "./App";
4 |
5 | registerRootComponent(App);
6 |
--------------------------------------------------------------------------------
/examples/with-firebase/index.share.js:
--------------------------------------------------------------------------------
1 | import { AppRegistry } from "react-native";
2 |
3 | import ShareExtension from "./ShareExtension";
4 |
5 | AppRegistry.registerComponent("shareExtension", () => ShareExtension);
6 |
--------------------------------------------------------------------------------
/examples/with-firebase/metro.config.js:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.io/guides/customizing-metro
2 | const { getDefaultConfig } = require("expo/metro-config");
3 | const path = require("path");
4 |
5 | const config = getDefaultConfig(__dirname);
6 |
7 | // npm v7+ will install ../node_modules/react-native because of peerDependencies.
8 | // To prevent the incompatible react-native bewtween ./node_modules/react-native and ../node_modules/react-native,
9 | // excludes the one from the parent folder when bundling.
10 | config.resolver.blockList = [
11 | ...Array.from(config.resolver.blockList ?? []),
12 | new RegExp(path.resolve("..", "..", "node_modules", "react-native")),
13 | ];
14 |
15 | config.resolver.nodeModulesPaths = [
16 | path.resolve(__dirname, "./node_modules"),
17 | path.resolve(__dirname, "../../node_modules"),
18 | ];
19 |
20 | config.watchFolders = [path.resolve(__dirname, "../..")];
21 |
22 | config.transformer.getTransformOptions = async () => ({
23 | transform: {
24 | experimentalImportSupport: false,
25 | inlineRequires: true,
26 | },
27 | resolver: {
28 | sourceExts: [...config.resolver.sourceExts, "share.js"], // Add 'share.js' as a recognized extension
29 | },
30 | });
31 |
32 | module.exports = config;
33 |
--------------------------------------------------------------------------------
/examples/with-firebase/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "with-firebase",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo run:android",
8 | "ios": "expo run:ios",
9 | "web": "expo start --web",
10 | "eas-build-pre-install": "cd ../.. && npm install && npm run build && npm run build plugin",
11 | "eas:dev": "eas build -e development -p ios",
12 | "postinstall": "npx patch-package",
13 | "prebuild": "expo prebuild -p ios --clean"
14 | },
15 | "dependencies": {
16 | "@react-native-firebase/app": "^21.2.0",
17 | "@react-native-firebase/auth": "^21.2.0",
18 | "@react-native-firebase/firestore": "^21.2.0",
19 | "expo": "~51.0.38",
20 | "expo-build-properties": "~0.12.5",
21 | "expo-dev-client": "~4.0.28",
22 | "expo-splash-screen": "~0.27.6",
23 | "expo-status-bar": "~1.12.1",
24 | "react": "18.2.0",
25 | "react-native": "0.74.5",
26 | "zod": "^3.23.8",
27 | "expo-apple-authentication": "~6.4.2",
28 | "expo-constants": "~16.0.2"
29 | },
30 | "devDependencies": {
31 | "@babel/core": "^7.25.9",
32 | "@types/react": "~18.2.79",
33 | "typescript": "~5.3.3"
34 | },
35 | "private": true,
36 | "expo": {
37 | "autolinking": {
38 | "nativeModulesDir": "../.."
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/examples/with-firebase/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "expo-share-extension": ["../../src/index"],
7 | "expo-share-extension/*": ["../../src/*"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/examples/with-firebase/webpack.config.js:
--------------------------------------------------------------------------------
1 | const createConfigAsync = require("@expo/webpack-config");
2 | const path = require("path");
3 |
4 | module.exports = async (env, argv) => {
5 | const config = await createConfigAsync(
6 | {
7 | ...env,
8 | babel: {
9 | dangerouslyAddModulePathsToTranspile: ["expo-share-extension"],
10 | },
11 | },
12 | argv
13 | );
14 | config.resolve.modules = [
15 | path.resolve(__dirname, "./node_modules"),
16 | path.resolve(__dirname, "../../node_modules"),
17 | ];
18 |
19 | return config;
20 | };
21 |
--------------------------------------------------------------------------------
/examples/with-media/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 |
11 | # Native
12 | *.orig.*
13 | *.jks
14 | *.p8
15 | *.p12
16 | *.key
17 | *.mobileprovision
18 |
19 | # Metro
20 | .metro-health-check*
21 |
22 | # debug
23 | npm-debug.*
24 | yarn-debug.*
25 | yarn-error.*
26 |
27 | # macOS
28 | .DS_Store
29 | *.pem
30 |
31 | # local env files
32 | .env*.local
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
37 | android/
38 | ios/
--------------------------------------------------------------------------------
/examples/with-media/README.md:
--------------------------------------------------------------------------------
1 | # Media Example
2 |
3 | This example demonstrates how to share media. It'd be configured in app.json like so:
4 |
5 | ```json
6 | [
7 | "expo-share-extension",
8 | {
9 | "activationRules": [
10 | {
11 | "type": "image",
12 | "max": 2
13 | },
14 | {
15 | "type": "video",
16 | "max": 1
17 | },
18 | {
19 | "type": "text"
20 | },
21 | {
22 | "type": "url",
23 | "max": 1
24 | }
25 | ]
26 | }
27 | ]
28 | ```
29 |
30 | Once you set this option, `expo-share-extension` will add [`NSExtensionActivationSupportsWebPageWithMaxCount: 1`](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/nsextensionactivationrule/nsextensionactivationsupportswebpagewithmaxcount) as an `NSExtensionActivationRule`. Your preprocessing file must adhere to some rules:
31 |
32 | 1. You must create a class with a `run` method, which receives an object with a `completionFunction` method as its argument. This `completionFunction` method must be invoked at the end of your `run` method. The argument you pass to it, is what you will receive as the `preprocessingResults` object as part of initial props.
33 |
34 | ```javascript
35 | class ShareExtensionPreprocessor {
36 | run(args) {
37 | args.completionFunction({
38 | title: document.title,
39 | });
40 | }
41 | }
42 | ```
43 |
44 | 2. Your file must create an instance of a class using `var`, so that it is globally accessible.
45 |
46 | ```javascript
47 | var ExtensionPreprocessingJS = new ShareExtensionPreprocessor();
48 | ```
49 |
50 | See the [preprocessing.js](./preprocessing.js) file for a complete example.
51 |
52 | **WARNING:** Using this option enbales [`NSExtensionActivationSupportsWebPageWithMaxCount: 1`](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/nsextensionactivationrule/nsextensionactivationsupportswebpagewithmaxcount) and this is mutually exclusive with [`NSExtensionActivationSupportsWebURLWithMaxCount: 1`](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/nsextensionactivationrule/nsextensionactivationsupportsweburlwithmaxcount), which `expo-share-extension` enables by default. This means that once you set the `preprocessingFile` option, you will no longer receive `url` as part of initial props. However, you can still get the URL by using `window.location.href` in your preprocessing file:
53 |
54 | ```javascript
55 | class ShareExtensionPreprocessor {
56 | run(args) {
57 | args.completionFunction({
58 | url: window.location.href,
59 | title: document.title,
60 | });
61 | }
62 | }
63 | ```
64 |
65 | ## Usage
66 |
67 | 1. Run Prebuild
68 |
69 | ```bash
70 | npm run prebuild
71 | ```
72 |
73 | 2. Start the app via Expo CLI
74 |
75 | ```bash
76 | npm run ios
77 | ```
78 |
79 | 3. or only start the metro server and build via XCode
80 |
81 | ```bash
82 | npm run start
83 | ```
84 |
--------------------------------------------------------------------------------
/examples/with-media/ShareExtension.tsx:
--------------------------------------------------------------------------------
1 | import { type InitialProps, close, openHostApp } from "expo-share-extension";
2 | import { useCallback } from "react";
3 | import { Button, StyleSheet, Text, View } from "react-native";
4 |
5 | export default function ShareExtension({ images, videos }: InitialProps) {
6 | const handleOpenHostApp = useCallback(() => {
7 | if (videos?.length) {
8 | openHostApp(`/create?videoUrl=${videos[0]}`);
9 | }
10 | }, [videos]);
11 |
12 | return (
13 |
14 |
17 | Media Example
18 |
19 | {images?.length ? (
20 |
27 | Images: {JSON.stringify(images)}
28 |
29 | ) : null}
30 | {videos?.length ? (
31 |
38 | Videos:{JSON.stringify(videos)}
39 |
40 | ) : null}
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | const styles = StyleSheet.create({
48 | container: {
49 | flex: 1,
50 | borderRadius: 20,
51 | backgroundColor: "#FAF8F5",
52 | alignItems: "center",
53 | justifyContent: "center",
54 | padding: 30,
55 | },
56 | });
57 |
--------------------------------------------------------------------------------
/examples/with-media/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "with-media",
4 | "slug": "with-media",
5 | "scheme": "withmedia",
6 | "version": "1.0.0",
7 | "orientation": "portrait",
8 | "icon": "./assets/icon.png",
9 | "userInterfaceStyle": "light",
10 | "splash": {
11 | "image": "./assets/splash.png",
12 | "resizeMode": "contain",
13 | "backgroundColor": "#ffffff"
14 | },
15 | "assetBundlePatterns": ["**/*"],
16 | "ios": {
17 | "supportsTablet": true,
18 | "bundleIdentifier": "expo.modules.shareextension.withmedia"
19 | },
20 | "android": {
21 | "adaptiveIcon": {
22 | "foregroundImage": "./assets/adaptive-icon.png",
23 | "backgroundColor": "#ffffff"
24 | },
25 | "package": "expo.modules.shareextension.withmedia"
26 | },
27 | "web": {
28 | "favicon": "./assets/favicon.png"
29 | },
30 | "plugins": [
31 | [
32 | "expo-font",
33 | {
34 | "fonts": ["./assets/fonts/Inter-Black.otf"]
35 | }
36 | ],
37 | [
38 | "../../app.plugin.js",
39 | {
40 | "activationRules": [
41 | {
42 | "type": "image"
43 | },
44 | {
45 | "type": "video"
46 | }
47 | ]
48 | }
49 | ],
50 | "expo-router"
51 | ]
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/examples/with-media/app/create.tsx:
--------------------------------------------------------------------------------
1 | import { useLocalSearchParams } from "expo-router";
2 | import { clearAppGroupContainer } from "expo-share-extension";
3 | import { useEffect } from "react";
4 | import { Button, StyleSheet, Text, View } from "react-native";
5 |
6 | export default function Create() {
7 | const { videoUrl } = useLocalSearchParams();
8 |
9 | useEffect(() => {
10 | // use sth. like expo-video-metadata to process video file
11 | console.log(videoUrl)
12 | }, [videoUrl]);
13 |
14 | const handleClearAppGroupContainer = async () => {
15 | try {
16 | await clearAppGroupContainer();
17 | } catch (error) {
18 | console.error(error);
19 | }
20 | };
21 |
22 | return (
23 |
24 |
27 | Create Media
28 |
29 |
36 | {videoUrl ? `Video URL: ${videoUrl}` : "No video url"}
37 |
38 |
42 |
43 | );
44 | }
45 |
46 | const styles = StyleSheet.create({
47 | container: {
48 | flex: 1,
49 | backgroundColor: "#FAF8F5",
50 | alignItems: "center",
51 | justifyContent: "center",
52 | padding: 30,
53 | },
54 | });
55 |
--------------------------------------------------------------------------------
/examples/with-media/app/index.tsx:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 | import { clearAppGroupContainer } from "expo-share-extension";
3 | import { StyleSheet, Text, View } from "react-native";
4 |
5 | const cleanUpBefore = dayjs().subtract(1, "day").toDate();
6 |
7 | clearAppGroupContainer({ cleanUpBefore }).catch((error) => {
8 | console.error(error);
9 | });
10 |
11 | export default function Index() {
12 | return (
13 |
14 |
17 | Media Example
18 |
19 |
26 | Go to Photo Library and open the share menu on a photo to trigger this
27 | app's share extension.
28 |
29 |
30 | );
31 | }
32 |
33 | const styles = StyleSheet.create({
34 | container: {
35 | flex: 1,
36 | backgroundColor: "#FAF8F5",
37 | alignItems: "center",
38 | justifyContent: "center",
39 | padding: 30,
40 | },
41 | });
42 |
--------------------------------------------------------------------------------
/examples/with-media/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-media/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/examples/with-media/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-media/assets/favicon.png
--------------------------------------------------------------------------------
/examples/with-media/assets/fonts/Inter-Black.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-media/assets/fonts/Inter-Black.otf
--------------------------------------------------------------------------------
/examples/with-media/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-media/assets/icon.png
--------------------------------------------------------------------------------
/examples/with-media/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-media/assets/splash.png
--------------------------------------------------------------------------------
/examples/with-media/babel.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | module.exports = function (api) {
3 | api.cache(true);
4 | return {
5 | presets: ["babel-preset-expo"],
6 | plugins: [
7 | [
8 | "module-resolver",
9 | {
10 | extensions: [".tsx", ".ts", ".js", ".json"],
11 | alias: {
12 | // For development, we want to alias the library to the source
13 | "expo-share-extension": path.join(
14 | __dirname,
15 | "..",
16 | "..",
17 | "src",
18 | "index.ts"
19 | ),
20 | },
21 | },
22 | ],
23 | ],
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/examples/with-media/eas.json:
--------------------------------------------------------------------------------
1 | {
2 | "cli": {
3 | "version": ">= 5.3.0",
4 | "promptToConfigurePushNotifications": false
5 | },
6 | "build": {
7 | "development": {
8 | "developmentClient": true,
9 | "distribution": "internal"
10 | },
11 | "preview": {
12 | "distribution": "internal"
13 | },
14 | "production": {}
15 | },
16 | "submit": {
17 | "production": {}
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/with-media/index.js:
--------------------------------------------------------------------------------
1 | import "expo-router/entry";
2 |
--------------------------------------------------------------------------------
/examples/with-media/index.share.js:
--------------------------------------------------------------------------------
1 | import { AppRegistry } from "react-native";
2 |
3 | import ShareExtension from "./ShareExtension";
4 |
5 | AppRegistry.registerComponent("shareExtension", () => ShareExtension);
6 |
--------------------------------------------------------------------------------
/examples/with-media/metro.config.js:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.io/guides/customizing-metro
2 | const { getDefaultConfig } = require("expo/metro-config");
3 | const path = require("path");
4 |
5 | const config = getDefaultConfig(__dirname);
6 |
7 | // npm v7+ will install ../node_modules/react-native because of peerDependencies.
8 | // To prevent the incompatible react-native bewtween ./node_modules/react-native and ../node_modules/react-native,
9 | // excludes the one from the parent folder when bundling.
10 | config.resolver.blockList = [
11 | ...Array.from(config.resolver.blockList ?? []),
12 | new RegExp(path.resolve("..", "..", "node_modules", "react-native")),
13 | ];
14 |
15 | config.resolver.nodeModulesPaths = [
16 | path.resolve(__dirname, "./node_modules"),
17 | path.resolve(__dirname, "../../node_modules"),
18 | ];
19 |
20 | config.watchFolders = [path.resolve(__dirname, "../..")];
21 |
22 | config.transformer.getTransformOptions = () => ({
23 | resolver: {
24 | sourceExts: [...config.resolver.sourceExts, "share.js"], // Add 'share.js' as a recognized extension
25 | },
26 | });
27 |
28 | module.exports = config;
29 |
--------------------------------------------------------------------------------
/examples/with-media/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "with-media",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo run:android",
8 | "ios": "expo run:ios",
9 | "web": "expo start --web",
10 | "eas-build-pre-install": "cd ../.. && npm install && npm run build && npm run build plugin",
11 | "postinstall": "npx patch-package",
12 | "prebuild": "expo prebuild -p ios --clean"
13 | },
14 | "dependencies": {
15 | "dayjs": "^1.11.13",
16 | "expo": "~51.0.38",
17 | "expo-constants": "~16.0.2",
18 | "expo-dev-client": "~4.0.28",
19 | "expo-file-system": "~17.0.1",
20 | "expo-linking": "~6.3.1",
21 | "expo-router": "~3.5.23",
22 | "expo-splash-screen": "~0.27.6",
23 | "expo-status-bar": "~1.12.1",
24 | "expo-updates": "^0.25.27",
25 | "react": "18.2.0",
26 | "react-native": "0.74.5",
27 | "react-native-safe-area-context": "4.10.5",
28 | "react-native-screens": "3.31.1",
29 | "zod": "^3.23.8"
30 | },
31 | "devDependencies": {
32 | "@babel/core": "^7.25.9",
33 | "@types/react": "~18.2.79",
34 | "typescript": "~5.3.3"
35 | },
36 | "private": true,
37 | "expo": {
38 | "autolinking": {
39 | "nativeModulesDir": "../.."
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/examples/with-media/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "expo-share-extension": ["../../src/index"],
7 | "expo-share-extension/*": ["../../src/*"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/examples/with-media/webpack.config.js:
--------------------------------------------------------------------------------
1 | const createConfigAsync = require("@expo/webpack-config");
2 | const path = require("path");
3 |
4 | module.exports = async (env, argv) => {
5 | const config = await createConfigAsync(
6 | {
7 | ...env,
8 | babel: {
9 | dangerouslyAddModulePathsToTranspile: ["expo-share-extension"],
10 | },
11 | },
12 | argv
13 | );
14 | config.resolve.modules = [
15 | path.resolve(__dirname, "./node_modules"),
16 | path.resolve(__dirname, "../../node_modules"),
17 | ];
18 |
19 | return config;
20 | };
21 |
--------------------------------------------------------------------------------
/examples/with-mmkv/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 |
11 | # Native
12 | *.orig.*
13 | *.jks
14 | *.p8
15 | *.p12
16 | *.key
17 | *.mobileprovision
18 |
19 | # Metro
20 | .metro-health-check*
21 |
22 | # debug
23 | npm-debug.*
24 | yarn-debug.*
25 | yarn-error.*
26 |
27 | # macOS
28 | .DS_Store
29 | *.pem
30 |
31 | # local env files
32 | .env*.local
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
37 | android/
38 | ios/
--------------------------------------------------------------------------------
/examples/with-mmkv/App.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { Alert, Button, StyleSheet, Text, View } from "react-native";
3 | import { useMMKVString } from "react-native-mmkv";
4 |
5 | import { storage } from "./storage";
6 |
7 | export default function App() {
8 | const [shared] = useMMKVString("shared");
9 |
10 | const enterText = useCallback(() => {
11 | Alert.prompt(
12 | "Enter persisted value",
13 | "This value will be stored in MMKV",
14 | (text) => {
15 | storage.set("shared", text);
16 | },
17 | );
18 | }, []);
19 |
20 | return (
21 |
22 |
25 | With MMKV Example
26 |
27 |
34 | Persisted value: {shared}
35 |
36 |
37 |
44 | Go to Safari and open the share menu to trigger this app's share
45 | extension.
46 |
47 |
48 | );
49 | }
50 |
51 | const styles = StyleSheet.create({
52 | container: {
53 | flex: 1,
54 | backgroundColor: "#FAF8F5",
55 | alignItems: "center",
56 | justifyContent: "center",
57 | padding: 30,
58 | },
59 | });
60 |
--------------------------------------------------------------------------------
/examples/with-mmkv/README.md:
--------------------------------------------------------------------------------
1 | # MMKV Example
2 |
3 | This example demonstrates that [`react-native-mmkv`](https://github.com/mrousavy/react-native-mmkv) can be used in `expo-share-extension` for persisting data that is shared between the main app and the share extension.
4 |
5 | ## Usage
6 |
7 | 1. Run Prebuild
8 |
9 | ```bash
10 | npm run prebuild
11 | ```
12 |
13 | 2. Start the app via Expo CLI
14 |
15 | ```bash
16 | npm run ios
17 | ```
18 |
19 | 3. or only start the metro server and build via XCode
20 |
21 | ```bash
22 | npm run start
23 | ```
24 |
--------------------------------------------------------------------------------
/examples/with-mmkv/ShareExtension.tsx:
--------------------------------------------------------------------------------
1 | import { close, type InitialProps } from "expo-share-extension";
2 | import { Button, StyleSheet, Text, View } from "react-native";
3 | import { useMMKVString } from "react-native-mmkv";
4 |
5 | import { storage } from "./storage";
6 |
7 | export default function ShareExtension({ url, text }: InitialProps) {
8 | const [shared] = useMMKVString("shared");
9 |
10 | return (
11 |
12 |
15 | With MMKV Example
16 |
17 |
24 | Persisted value: {shared}
25 |
26 | {url && (
27 |
34 | URL: {url}
35 |
36 | )}
37 | {text && (
38 |
45 | Text: {text}
46 |
47 | )}
48 |
56 | );
57 | }
58 |
59 | const styles = StyleSheet.create({
60 | container: {
61 | flex: 1,
62 | borderRadius: 20,
63 | backgroundColor: "#FAF8F5",
64 | alignItems: "center",
65 | justifyContent: "center",
66 | padding: 30,
67 | },
68 | });
69 |
--------------------------------------------------------------------------------
/examples/with-mmkv/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "with-mmkv",
4 | "slug": "with-mmkv",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "userInterfaceStyle": "light",
9 | "splash": {
10 | "image": "./assets/splash.png",
11 | "resizeMode": "contain",
12 | "backgroundColor": "#ffffff"
13 | },
14 | "assetBundlePatterns": ["**/*"],
15 | "ios": {
16 | "supportsTablet": true,
17 | "bundleIdentifier": "expo.modules.shareextension.withmmkv"
18 | },
19 | "android": {
20 | "adaptiveIcon": {
21 | "foregroundImage": "./assets/adaptive-icon.png",
22 | "backgroundColor": "#ffffff"
23 | },
24 | "package": "expo.modules.shareextension.withmmkv"
25 | },
26 | "web": {
27 | "favicon": "./assets/favicon.png"
28 | },
29 | "plugins": [
30 | [
31 | "expo-font",
32 | {
33 | "fonts": ["./assets/fonts/Inter-Black.otf"]
34 | }
35 | ],
36 | [
37 | "../../app.plugin.js",
38 | {
39 | "backgroundColor": {
40 | "red": 255,
41 | "green": 255,
42 | "blue": 255,
43 | "alpha": 0 // make the background transparent
44 | },
45 | "height": 500
46 | }
47 | ]
48 | ]
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/examples/with-mmkv/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-mmkv/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/examples/with-mmkv/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-mmkv/assets/favicon.png
--------------------------------------------------------------------------------
/examples/with-mmkv/assets/fonts/Inter-Black.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-mmkv/assets/fonts/Inter-Black.otf
--------------------------------------------------------------------------------
/examples/with-mmkv/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-mmkv/assets/icon.png
--------------------------------------------------------------------------------
/examples/with-mmkv/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-mmkv/assets/splash.png
--------------------------------------------------------------------------------
/examples/with-mmkv/babel.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | module.exports = function (api) {
3 | api.cache(true);
4 | return {
5 | presets: ["babel-preset-expo"],
6 | plugins: [
7 | [
8 | "module-resolver",
9 | {
10 | extensions: [".tsx", ".ts", ".js", ".json"],
11 | alias: {
12 | // For development, we want to alias the library to the source
13 | "expo-share-extension": path.join(
14 | __dirname,
15 | "..",
16 | "..",
17 | "src",
18 | "index.ts"
19 | ),
20 | },
21 | },
22 | ],
23 | ],
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/examples/with-mmkv/eas.json:
--------------------------------------------------------------------------------
1 | {
2 | "cli": {
3 | "version": ">= 5.3.0",
4 | "promptToConfigurePushNotifications": false
5 | },
6 | "build": {
7 | "development": {
8 | "developmentClient": true,
9 | "distribution": "internal"
10 | },
11 | "preview": {
12 | "distribution": "internal"
13 | },
14 | "production": {}
15 | },
16 | "submit": {
17 | "production": {}
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/with-mmkv/index.js:
--------------------------------------------------------------------------------
1 | import { registerRootComponent } from "expo";
2 |
3 | import App from "./App";
4 |
5 | registerRootComponent(App);
6 |
--------------------------------------------------------------------------------
/examples/with-mmkv/index.share.js:
--------------------------------------------------------------------------------
1 | import { AppRegistry } from "react-native";
2 |
3 | import ShareExtension from "./ShareExtension";
4 |
5 | AppRegistry.registerComponent("shareExtension", () => ShareExtension);
6 |
--------------------------------------------------------------------------------
/examples/with-mmkv/metro.config.js:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.io/guides/customizing-metro
2 | const { getDefaultConfig } = require("expo/metro-config");
3 | const path = require("path");
4 |
5 | const config = getDefaultConfig(__dirname);
6 |
7 | // npm v7+ will install ../node_modules/react-native because of peerDependencies.
8 | // To prevent the incompatible react-native bewtween ./node_modules/react-native and ../node_modules/react-native,
9 | // excludes the one from the parent folder when bundling.
10 | config.resolver.blockList = [
11 | ...Array.from(config.resolver.blockList ?? []),
12 | new RegExp(path.resolve("..", "..", "node_modules", "react-native")),
13 | ];
14 |
15 | config.resolver.nodeModulesPaths = [
16 | path.resolve(__dirname, "./node_modules"),
17 | path.resolve(__dirname, "../../node_modules"),
18 | ];
19 |
20 | config.watchFolders = [path.resolve(__dirname, "../..")];
21 |
22 | config.transformer.getTransformOptions = () => ({
23 | resolver: {
24 | sourceExts: [...config.resolver.sourceExts, "share.js"], // Add 'share.js' as a recognized extension
25 | },
26 | });
27 |
28 | module.exports = config;
29 |
--------------------------------------------------------------------------------
/examples/with-mmkv/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "with-mmkv",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo run:android",
8 | "ios": "expo run:ios",
9 | "web": "expo start --web",
10 | "eas-build-pre-install": "cd ../.. && npm install && npm run build && npm run build plugin",
11 | "postinstall": "npx patch-package",
12 | "prebuild": "expo prebuild -p ios --clean"
13 | },
14 | "dependencies": {
15 | "expo": "~51.0.38",
16 | "expo-dev-client": "~4.0.28",
17 | "expo-splash-screen": "~0.27.6",
18 | "expo-status-bar": "~1.12.1",
19 | "react": "18.2.0",
20 | "react-native": "0.74.5",
21 | "react-native-mmkv": "^3.1.0"
22 | },
23 | "devDependencies": {
24 | "@babel/core": "^7.25.9",
25 | "@types/react": "~18.2.79",
26 | "typescript": "~5.3.3"
27 | },
28 | "private": true,
29 | "expo": {
30 | "autolinking": {
31 | "nativeModulesDir": "../.."
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/examples/with-mmkv/storage.ts:
--------------------------------------------------------------------------------
1 | import { MMKV } from "react-native-mmkv";
2 |
3 | export const storage = new MMKV();
4 |
--------------------------------------------------------------------------------
/examples/with-mmkv/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "expo-share-extension": ["../../src/index"],
7 | "expo-share-extension/*": ["../../src/*"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/examples/with-mmkv/webpack.config.js:
--------------------------------------------------------------------------------
1 | const createConfigAsync = require("@expo/webpack-config");
2 | const path = require("path");
3 |
4 | module.exports = async (env, argv) => {
5 | const config = await createConfigAsync(
6 | {
7 | ...env,
8 | babel: {
9 | dangerouslyAddModulePathsToTranspile: ["expo-share-extension"],
10 | },
11 | },
12 | argv
13 | );
14 | config.resolve.modules = [
15 | path.resolve(__dirname, "./node_modules"),
16 | path.resolve(__dirname, "../../node_modules"),
17 | ];
18 |
19 | return config;
20 | };
21 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 |
11 | # Native
12 | *.orig.*
13 | *.jks
14 | *.p8
15 | *.p12
16 | *.key
17 | *.mobileprovision
18 |
19 | # Metro
20 | .metro-health-check*
21 |
22 | # debug
23 | npm-debug.*
24 | yarn-debug.*
25 | yarn-error.*
26 |
27 | # macOS
28 | .DS_Store
29 | *.pem
30 |
31 | # local env files
32 | .env*.local
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
37 | android/
38 | ios/
--------------------------------------------------------------------------------
/examples/with-preprocessing/App.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, Text, View } from "react-native";
2 |
3 | export default function App() {
4 | return (
5 |
6 |
9 | Preprocessing Example
10 |
11 |
18 | Go to Safari and open the share menu to trigger this app's share
19 | extension.
20 |
21 |
22 | );
23 | }
24 |
25 | const styles = StyleSheet.create({
26 | container: {
27 | flex: 1,
28 | backgroundColor: "#FAF8F5",
29 | alignItems: "center",
30 | justifyContent: "center",
31 | padding: 30,
32 | },
33 | });
34 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/README.md:
--------------------------------------------------------------------------------
1 | # Preprocessing Example
2 |
3 | This example demonstrates the `preprocessingFile` option. It'd be configured in app.json like so:
4 |
5 | ```json
6 | [
7 | "expo-share-extension",
8 | {
9 | "preprocessingFile": "./preprocessing.js"
10 | }
11 | ]
12 | ```
13 |
14 | Once you set this option, `expo-share-extension` will add [`NSExtensionActivationSupportsWebPageWithMaxCount: 1`](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/nsextensionactivationrule/nsextensionactivationsupportswebpagewithmaxcount) as an `NSExtensionActivationRule`. Your preprocessing file must adhere to some rules:
15 |
16 | 1. You must create a class with a `run` method, which receives an object with a `completionFunction` method as its argument. This `completionFunction` method must be invoked at the end of your `run` method. The argument you pass to it, is what you will receive as the `preprocessingResults` object as part of initial props.
17 |
18 | ```javascript
19 | class ShareExtensionPreprocessor {
20 | run(args) {
21 | args.completionFunction({
22 | title: document.title,
23 | });
24 | }
25 | }
26 | ```
27 |
28 | 2. Your file must create an instance of a class using `var`, so that it is globally accessible.
29 |
30 | ```javascript
31 | var ExtensionPreprocessingJS = new ShareExtensionPreprocessor();
32 | ```
33 |
34 | See the [preprocessing.js](./preprocessing.js) file for a complete example.
35 |
36 | **WARNING:** Using this option enbales [`NSExtensionActivationSupportsWebPageWithMaxCount: 1`](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/nsextensionactivationrule/nsextensionactivationsupportswebpagewithmaxcount) and this is mutually exclusive with [`NSExtensionActivationSupportsWebURLWithMaxCount: 1`](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionattributes/nsextensionactivationrule/nsextensionactivationsupportsweburlwithmaxcount), which `expo-share-extension` enables by default. This means that once you set the `preprocessingFile` option, you will no longer receive `url` as part of initial props. However, you can still get the URL by using `window.location.href` in your preprocessing file:
37 |
38 | ```javascript
39 | class ShareExtensionPreprocessor {
40 | run(args) {
41 | args.completionFunction({
42 | url: window.location.href,
43 | title: document.title,
44 | });
45 | }
46 | }
47 | ```
48 |
49 | ## Usage
50 |
51 | 1. Run Prebuild
52 |
53 | ```bash
54 | npm run prebuild
55 | ```
56 |
57 | 2. Start the app via Expo CLI
58 |
59 | ```bash
60 | npm run ios
61 | ```
62 |
63 | 3. or only start the metro server and build via XCode
64 |
65 | ```bash
66 | npm run start
67 | ```
68 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/ShareExtension.tsx:
--------------------------------------------------------------------------------
1 | import { type InitialProps, close } from "expo-share-extension";
2 | import { useEffect, useState } from "react";
3 | import { Button, StyleSheet, Text, View } from "react-native";
4 | import { z } from "zod";
5 |
6 | const preprocessingResultsSchema = z.object({
7 | title: z.string(),
8 | });
9 |
10 | export default function ShareExtension({
11 | preprocessingResults,
12 | text,
13 | }: InitialProps) {
14 | const [title, setTitle] = useState();
15 |
16 | useEffect(() => {
17 | const result = preprocessingResultsSchema.safeParse(preprocessingResults);
18 | if (result.success) {
19 | setTitle(result.data.title);
20 | }
21 | }, [preprocessingResults]);
22 |
23 | return (
24 |
25 |
28 | Preprocessing Example
29 |
30 | {title && (
31 |
38 | Document title: {title}
39 |
40 | )}
41 | {text && (
42 |
49 | Text: {text}
50 |
51 | )}
52 |
53 |
54 | );
55 | }
56 |
57 | const styles = StyleSheet.create({
58 | container: {
59 | flex: 1,
60 | borderRadius: 20,
61 | backgroundColor: "#FAF8F5",
62 | alignItems: "center",
63 | justifyContent: "center",
64 | padding: 30,
65 | },
66 | });
67 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "with-preprocessing",
4 | "slug": "with-preprocessing",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "userInterfaceStyle": "light",
9 | "splash": {
10 | "image": "./assets/splash.png",
11 | "resizeMode": "contain",
12 | "backgroundColor": "#ffffff"
13 | },
14 | "assetBundlePatterns": ["**/*"],
15 | "ios": {
16 | "supportsTablet": true,
17 | "bundleIdentifier": "expo.modules.shareextension.withpreprocessing"
18 | },
19 | "android": {
20 | "adaptiveIcon": {
21 | "foregroundImage": "./assets/adaptive-icon.png",
22 | "backgroundColor": "#ffffff"
23 | },
24 | "package": "expo.modules.shareextension.withpreprocessing"
25 | },
26 | "web": {
27 | "favicon": "./assets/favicon.png"
28 | },
29 | "plugins": [
30 | [
31 | "expo-font",
32 | {
33 | "fonts": ["./assets/fonts/Inter-Black.otf"]
34 | }
35 | ],
36 | [
37 | "../../app.plugin.js",
38 | {
39 | "preprocessingFile": "./preprocessing.js"
40 | }
41 | ]
42 | ]
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-preprocessing/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/examples/with-preprocessing/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-preprocessing/assets/favicon.png
--------------------------------------------------------------------------------
/examples/with-preprocessing/assets/fonts/Inter-Black.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-preprocessing/assets/fonts/Inter-Black.otf
--------------------------------------------------------------------------------
/examples/with-preprocessing/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-preprocessing/assets/icon.png
--------------------------------------------------------------------------------
/examples/with-preprocessing/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxAst/expo-share-extension/2c93a2ee0751f43c37101697181470cc5049d3b6/examples/with-preprocessing/assets/splash.png
--------------------------------------------------------------------------------
/examples/with-preprocessing/babel.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | module.exports = function (api) {
3 | api.cache(true);
4 | return {
5 | presets: ["babel-preset-expo"],
6 | plugins: [
7 | [
8 | "module-resolver",
9 | {
10 | extensions: [".tsx", ".ts", ".js", ".json"],
11 | alias: {
12 | // For development, we want to alias the library to the source
13 | "expo-share-extension": path.join(
14 | __dirname,
15 | "..",
16 | "..",
17 | "src",
18 | "index.ts"
19 | ),
20 | },
21 | },
22 | ],
23 | ],
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/eas.json:
--------------------------------------------------------------------------------
1 | {
2 | "cli": {
3 | "version": ">= 5.3.0",
4 | "promptToConfigurePushNotifications": false
5 | },
6 | "build": {
7 | "development": {
8 | "developmentClient": true,
9 | "distribution": "internal"
10 | },
11 | "preview": {
12 | "distribution": "internal"
13 | },
14 | "production": {}
15 | },
16 | "submit": {
17 | "production": {}
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/index.js:
--------------------------------------------------------------------------------
1 | import { registerRootComponent } from "expo";
2 |
3 | import App from "./App";
4 |
5 | registerRootComponent(App);
6 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/index.share.js:
--------------------------------------------------------------------------------
1 | import { AppRegistry } from "react-native";
2 |
3 | import ShareExtension from "./ShareExtension";
4 |
5 | AppRegistry.registerComponent("shareExtension", () => ShareExtension);
6 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/metro.config.js:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.io/guides/customizing-metro
2 | const { getDefaultConfig } = require("expo/metro-config");
3 | const path = require("path");
4 |
5 | const config = getDefaultConfig(__dirname);
6 |
7 | // npm v7+ will install ../node_modules/react-native because of peerDependencies.
8 | // To prevent the incompatible react-native bewtween ./node_modules/react-native and ../node_modules/react-native,
9 | // excludes the one from the parent folder when bundling.
10 | config.resolver.blockList = [
11 | ...Array.from(config.resolver.blockList ?? []),
12 | new RegExp(path.resolve("..", "..", "node_modules", "react-native")),
13 | ];
14 |
15 | config.resolver.nodeModulesPaths = [
16 | path.resolve(__dirname, "./node_modules"),
17 | path.resolve(__dirname, "../../node_modules"),
18 | ];
19 |
20 | config.watchFolders = [path.resolve(__dirname, "../..")];
21 |
22 | config.transformer.getTransformOptions = () => ({
23 | resolver: {
24 | sourceExts: [...config.resolver.sourceExts, "share.js"], // Add 'share.js' as a recognized extension
25 | },
26 | });
27 |
28 | module.exports = config;
29 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "with-preprocessing",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo run:android",
8 | "ios": "expo run:ios",
9 | "web": "expo start --web",
10 | "eas-build-pre-install": "cd ../.. && npm install && npm run build && npm run build plugin",
11 | "postinstall": "npx patch-package",
12 | "prebuild": "expo prebuild -p ios --clean"
13 | },
14 | "dependencies": {
15 | "expo": "~51.0.38",
16 | "expo-dev-client": "~4.0.28",
17 | "expo-splash-screen": "~0.27.6",
18 | "expo-status-bar": "~1.12.1",
19 | "expo-updates": "^0.25.27",
20 | "react": "18.2.0",
21 | "react-native": "0.74.5",
22 | "zod": "^3.23.8"
23 | },
24 | "devDependencies": {
25 | "@babel/core": "^7.25.9",
26 | "@types/react": "~18.2.79",
27 | "typescript": "~5.3.3"
28 | },
29 | "private": true,
30 | "expo": {
31 | "autolinking": {
32 | "nativeModulesDir": "../.."
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/preprocessing.js:
--------------------------------------------------------------------------------
1 | class ShareExtensionPreprocessor {
2 | run(args) {
3 | args.completionFunction({
4 | title: document.title,
5 | });
6 | }
7 | }
8 |
9 | var ExtensionPreprocessingJS = new ShareExtensionPreprocessor();
10 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "expo-share-extension": ["../../src/index"],
7 | "expo-share-extension/*": ["../../src/*"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/examples/with-preprocessing/webpack.config.js:
--------------------------------------------------------------------------------
1 | const createConfigAsync = require("@expo/webpack-config");
2 | const path = require("path");
3 |
4 | module.exports = async (env, argv) => {
5 | const config = await createConfigAsync(
6 | {
7 | ...env,
8 | babel: {
9 | dangerouslyAddModulePathsToTranspile: ["expo-share-extension"],
10 | },
11 | },
12 | argv
13 | );
14 | config.resolve.modules = [
15 | path.resolve(__dirname, "./node_modules"),
16 | path.resolve(__dirname, "../../node_modules"),
17 | ];
18 |
19 | return config;
20 | };
21 |
--------------------------------------------------------------------------------
/expo-module.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "platforms": ["ios"],
3 | "ios": {
4 | "modules": ["ExpoShareExtensionModule"]
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/ios/ExpoShareExtension.podspec:
--------------------------------------------------------------------------------
1 | require 'json'
2 |
3 | package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4 |
5 | Pod::Spec.new do |s|
6 | s.name = 'ExpoShareExtension'
7 | s.version = package['version']
8 | s.summary = package['description']
9 | s.description = package['description']
10 | s.license = package['license']
11 | s.author = package['author']
12 | s.homepage = package['homepage']
13 | s.platform = :ios, '13.0'
14 | s.swift_version = '5.4'
15 | s.source = { git: 'https://github.com/MaxAst/expo-share-extension' }
16 | s.static_framework = true
17 |
18 | s.dependency 'ExpoModulesCore'
19 |
20 | # Swift/Objective-C compatibility
21 | s.pod_target_xcconfig = {
22 | 'DEFINES_MODULE' => 'YES',
23 | 'SWIFT_COMPILATION_MODE' => 'wholemodule'
24 | }
25 |
26 | s.source_files = "**/*.{h,m,swift}"
27 | end
28 |
--------------------------------------------------------------------------------
/ios/ExpoShareExtensionModule.swift:
--------------------------------------------------------------------------------
1 | import ExpoModulesCore
2 |
3 | public class ExpoShareExtensionModule: Module {
4 | public func definition() -> ModuleDefinition {
5 | Name("ExpoShareExtension")
6 |
7 | Function("close") { () in
8 | NotificationCenter.default.post(name: NSNotification.Name("close"), object: nil)
9 | }
10 |
11 | Function("openHostApp") { (path: String) in
12 | let userInfo: [String: String] = ["path": path]
13 | NotificationCenter.default.post(name: NSNotification.Name("openHostApp"), object: nil, userInfo: userInfo)
14 | }
15 |
16 | AsyncFunction("clearAppGroupContainer") { (date: String?, promise: Promise) in
17 | DispatchQueue.global(qos: .background).async {
18 | guard let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as? String else {
19 | DispatchQueue.main.async {
20 | promise.reject("ERR_APP_GROUP", "Could not find AppGroup in info.plist")
21 | }
22 | return
23 | }
24 |
25 | guard let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
26 | DispatchQueue.main.async {
27 | promise.reject("ERR_CONTAINER_URL", "Could not set up file manager container URL for app group")
28 | }
29 | return
30 | }
31 |
32 | var comparisonDate: Date? = nil
33 | if let isoDateString = date {
34 | let isoFormatter = ISO8601DateFormatter()
35 | // new Date().toISOString() returns fractional seconds, which we need to account for:
36 | isoFormatter.formatOptions.insert(.withFractionalSeconds)
37 | if let date = isoFormatter.date(from: isoDateString) {
38 | comparisonDate = date
39 | } else {
40 | DispatchQueue.main.async {
41 | promise.reject("ERR_INVALID_DATE", "The provided date string is not in a valid ISO 8601 format")
42 | }
43 | return
44 | }
45 | }
46 |
47 | let fileManager = FileManager.default
48 | let sharedDataUrl = containerUrl.appendingPathComponent("sharedData")
49 |
50 | if fileManager.fileExists(atPath: sharedDataUrl.path) {
51 | do {
52 | let contents = try fileManager.contentsOfDirectory(atPath: sharedDataUrl.path)
53 | for item in contents {
54 | let itemPath = sharedDataUrl.appendingPathComponent(item).path
55 | if let creationDate = self.getCreationDate(of: itemPath) {
56 | if let comparisonDate = comparisonDate {
57 | if creationDate < comparisonDate {
58 | try fileManager.removeItem(atPath: itemPath)
59 | }
60 | } else {
61 | try fileManager.removeItem(atPath: itemPath)
62 | }
63 | } else {
64 | DispatchQueue.main.async {
65 | promise.reject("ERR_REMOVE_CONTENTS", "Unable to retrieve creation date")
66 | }
67 | return
68 | }
69 | }
70 | DispatchQueue.main.async {
71 | print("sharedData directory contents removed successfully.")
72 | promise.resolve()
73 | }
74 | } catch {
75 | DispatchQueue.main.async {
76 | promise.reject("ERR_REMOVE_CONTENTS", "Error removing sharedData directory contents: \(error)")
77 | }
78 | }
79 | } else {
80 | DispatchQueue.main.async {
81 | print("sharedData directory does not exist.")
82 | promise.resolve()
83 | }
84 | }
85 | }
86 | }
87 | }
88 |
89 | internal func getCreationDate(of filePath: String) -> Date? {
90 | let fileManager = FileManager.default
91 | do {
92 | let attributes = try fileManager.attributesOfItem(atPath: filePath)
93 | if let creationDate = attributes[.creationDate] as? Date {
94 | return creationDate
95 | }
96 | } catch {
97 | print("Error getting file attributes: \(error.localizedDescription)")
98 | }
99 | return nil
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/metro.js:
--------------------------------------------------------------------------------
1 | const withShareExtension = (config) => {
2 | if (!config.resolver) {
3 | throw new Error("config.resolver is not defined");
4 | }
5 |
6 | config.resolver.sourceExts = [
7 | ...(config.resolver?.sourceExts ?? []),
8 | "share.js",
9 | ];
10 |
11 | if (!config.server) {
12 | throw new Error("config.server is not defined");
13 | }
14 |
15 | const originalRewriteRequestUrl =
16 | config.server?.rewriteRequestUrl || ((url) => url);
17 |
18 | config.server.rewriteRequestUrl = (url) => {
19 | const isShareExtension = url.includes("shareExtension=true");
20 | const rewrittenUrl = originalRewriteRequestUrl(url);
21 |
22 | if (isShareExtension) {
23 | return rewrittenUrl.replace("index.bundle", "index.share.bundle");
24 | }
25 |
26 | return rewrittenUrl;
27 | };
28 |
29 | return config;
30 | };
31 |
32 | module.exports = {
33 | withShareExtension,
34 | };
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "expo-share-extension",
3 | "version": "3.0.0",
4 | "description": "Expo config plugin to create an iOS share extension.",
5 | "main": "build/index.js",
6 | "types": "build/index.d.ts",
7 | "scripts": {
8 | "build": "expo-module build",
9 | "clean": "expo-module clean",
10 | "lint": "expo-module lint",
11 | "test": "expo-module test",
12 | "prepare": "expo-module prepare",
13 | "prepublishOnly": "expo-module prepublishOnly",
14 | "expo-module": "expo-module",
15 | "open:ios": "open -a \"Xcode\" example/ios",
16 | "open:android": "open -a \"Android Studio\" example/android",
17 | "release": "release-it",
18 | "ncu": "npx npm-check-updates --deep -u"
19 | },
20 | "keywords": [
21 | "react-native",
22 | "expo",
23 | "expo-share-extension",
24 | "ExpoShareExtension"
25 | ],
26 | "repository": {
27 | "type": "git",
28 | "url": "git+https://github.com/MaxAst/expo-share-extension.git"
29 | },
30 | "bugs": {
31 | "url": "https://github.com/MaxAst/expo-share-extension/issues"
32 | },
33 | "author": "Max Ast (https://github.com/MaxAst)",
34 | "license": "MIT",
35 | "homepage": "https://github.com/MaxAst/expo-share-extension#readme",
36 | "dependencies": {
37 | "semver": "^7.6.3",
38 | "zod": "^3.23.8"
39 | },
40 | "devDependencies": {
41 | "@types/react": "18.3.12",
42 | "@types/semver": "^7.5.8",
43 | "expo-module-scripts": "^4.0.2",
44 | "expo-modules-core": "^2.0.6",
45 | "release-it": "^17.10.0"
46 | },
47 | "peerDependencies": {
48 | "expo": "*",
49 | "react": "*",
50 | "react-native": "*"
51 | },
52 | "release-it": {
53 | "github": {
54 | "release": true
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/plugin/src/index.ts:
--------------------------------------------------------------------------------
1 | import { type ExpoConfig } from "@expo/config-types";
2 | import { ConfigPlugin, IOSConfig, withPlugins } from "expo/config-plugins";
3 | import { z } from "zod";
4 |
5 | import { withAppEntitlements } from "./withAppEntitlements";
6 | import { withAppInfoPlist } from "./withAppInfoPlist";
7 | import { withExpoConfig } from "./withExpoConfig";
8 | import { withPodfile } from "./withPodfile";
9 | import { withShareExtensionEntitlements } from "./withShareExtensionEntitlements";
10 | import { withShareExtensionInfoPlist } from "./withShareExtensionInfoPlist";
11 | import { withShareExtensionTarget } from "./withShareExtensionTarget";
12 |
13 | export const getAppGroup = (identifier: string) => `group.${identifier}`;
14 |
15 | export const getAppBundleIdentifier = (config: ExpoConfig) => {
16 | if (!config.ios?.bundleIdentifier) {
17 | throw new Error("No bundle identifier");
18 | }
19 | return config.ios?.bundleIdentifier;
20 | };
21 |
22 | export const getShareExtensionBundleIdentifier = (config: ExpoConfig) => {
23 | return `${getAppBundleIdentifier(config)}.ShareExtension`;
24 | };
25 |
26 | export const getShareExtensionName = (config: ExpoConfig) => {
27 | return `${IOSConfig.XcodeUtils.sanitizedName(config.name)}ShareExtension`;
28 | };
29 |
30 | export const getShareExtensionEntitlementsFileName = (config: ExpoConfig) => {
31 | const name = getShareExtensionName(config);
32 | return `${name}.entitlements`;
33 | };
34 |
35 | const rgbaSchema = z.object({
36 | red: z.number().min(0).max(255),
37 | green: z.number().min(0).max(255),
38 | blue: z.number().min(0).max(255),
39 | alpha: z.number().min(0).max(1),
40 | });
41 |
42 | export type BackgroundColor = z.infer;
43 |
44 | const heightSchema = z.number().int().min(50).max(1000);
45 |
46 | export type Height = z.infer;
47 |
48 | type ActivationType = "image" | "video" | "text" | "url" | "file";
49 |
50 | export type ActivationRule = {
51 | type: ActivationType;
52 | max?: number;
53 | };
54 |
55 | const withShareExtension: ConfigPlugin<{
56 | activationRules?: ActivationRule[];
57 | backgroundColor?: BackgroundColor;
58 | height?: Height;
59 | excludedPackages?: string[];
60 | googleServicesFile?: string;
61 | preprocessingFile?: string;
62 | }> = (config, props) => {
63 | if (props?.backgroundColor) {
64 | rgbaSchema.parse(props.backgroundColor);
65 | }
66 |
67 | const expoFontPlugin = config.plugins?.find(
68 | (p) => Array.isArray(p) && p.length && p.at(0) === "expo-font",
69 | );
70 |
71 | const fonts = expoFontPlugin?.at(1).fonts ?? [];
72 |
73 | return withPlugins(config, [
74 | withExpoConfig,
75 | withAppEntitlements,
76 | withAppInfoPlist,
77 | [withPodfile, { excludedPackages: props?.excludedPackages ?? [] }],
78 | [
79 | withShareExtensionInfoPlist,
80 | {
81 | fonts,
82 | activationRules: props?.activationRules,
83 | backgroundColor: props?.backgroundColor,
84 | height: props?.height,
85 | preprocessingFile: props?.preprocessingFile,
86 | googleServicesFile: props?.googleServicesFile,
87 | },
88 | ],
89 | withShareExtensionEntitlements,
90 | [
91 | withShareExtensionTarget,
92 | {
93 | fonts,
94 | googleServicesFile: props?.googleServicesFile,
95 | preprocessingFile: props?.preprocessingFile,
96 | },
97 | ],
98 | ]);
99 | };
100 |
101 | export default withShareExtension;
102 |
--------------------------------------------------------------------------------
/plugin/src/withAppEntitlements.ts:
--------------------------------------------------------------------------------
1 | import { ConfigPlugin, withEntitlementsPlist } from "@expo/config-plugins";
2 |
3 | import { getAppBundleIdentifier, getAppGroup } from "./index";
4 |
5 | export const withAppEntitlements: ConfigPlugin = (config) => {
6 | return withEntitlementsPlist(config, (config) => {
7 | const bundleIdentifier = getAppBundleIdentifier(config);
8 |
9 | if (config.ios?.entitlements?.["com.apple.security.application-groups"]) {
10 | return config;
11 | }
12 |
13 | config.modResults["com.apple.security.application-groups"] = [
14 | getAppGroup(bundleIdentifier),
15 | ];
16 |
17 | return config;
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/plugin/src/withAppInfoPlist.ts:
--------------------------------------------------------------------------------
1 | import { ConfigPlugin, withInfoPlist } from "@expo/config-plugins";
2 |
3 | import { getAppBundleIdentifier, getAppGroup } from "./index";
4 |
5 | export const withAppInfoPlist: ConfigPlugin = (config) => {
6 | return withInfoPlist(config, (config) => {
7 | const bundleIdentifier = getAppBundleIdentifier(config);
8 |
9 | config.modResults["AppGroup"] = getAppGroup(bundleIdentifier);
10 |
11 | return config;
12 | });
13 | };
14 |
--------------------------------------------------------------------------------
/plugin/src/withExpoConfig.ts:
--------------------------------------------------------------------------------
1 | import { ConfigPlugin } from "@expo/config-plugins";
2 |
3 | import {
4 | getAppBundleIdentifier,
5 | getAppGroup,
6 | getShareExtensionBundleIdentifier,
7 | getShareExtensionName,
8 | } from "./index";
9 |
10 | type iOSExtensionConfig = {
11 | targetName: string;
12 | bundleIdentifier: string;
13 | entitlements: Record;
14 | };
15 |
16 | // extend expo app config with app extension config for our share extension
17 | export const withExpoConfig: ConfigPlugin = (config) => {
18 | if (!config.ios?.bundleIdentifier) {
19 | throw new Error("You need to specify ios.bundleIdentifier in app.json.");
20 | }
21 |
22 | const extensionName = getShareExtensionName(config);
23 | const extensionBundleIdentifier = getShareExtensionBundleIdentifier(config);
24 | const appBundleIdentifier = getAppBundleIdentifier(config);
25 |
26 | const iosExtensions: iOSExtensionConfig[] =
27 | config.extra?.eas?.build?.experimental?.ios?.appExtensions;
28 |
29 | const shareExtensionConfig = iosExtensions?.find(
30 | (extension) => extension.targetName === extensionName
31 | );
32 |
33 | return {
34 | ...config,
35 | extra: {
36 | ...(config.extra ?? {}),
37 | eas: {
38 | ...(config.extra?.eas ?? {}),
39 | build: {
40 | ...(config.extra?.eas?.build ?? {}),
41 | experimental: {
42 | ...(config.extra?.eas?.build?.experimental ?? {}),
43 | ios: {
44 | ...(config.extra?.eas?.build?.experimental?.ios ?? {}),
45 | appExtensions: [
46 | {
47 | ...(shareExtensionConfig ?? {
48 | targetName: extensionName,
49 | bundleIdentifier: extensionBundleIdentifier,
50 | }),
51 | entitlements: {
52 | ...shareExtensionConfig?.entitlements,
53 | "com.apple.security.application-groups": [
54 | getAppGroup(config.ios?.bundleIdentifier),
55 | ],
56 | ...(config.ios.usesAppleSignIn && {
57 | "com.apple.developer.applesignin": ["Default"],
58 | }),
59 | },
60 | },
61 | ...(iosExtensions?.filter(
62 | (extension) => extension.targetName !== extensionName
63 | ) ?? []),
64 | ],
65 | },
66 | },
67 | },
68 | },
69 | appleApplicationGroup: getAppGroup(appBundleIdentifier),
70 | },
71 | };
72 | };
73 |
--------------------------------------------------------------------------------
/plugin/src/withPodfile.ts:
--------------------------------------------------------------------------------
1 | import { mergeContents } from "@expo/config-plugins/build/utils/generateCode";
2 | import { ConfigPlugin, withDangerousMod } from "expo/config-plugins";
3 | import fs from "fs";
4 | import path from "path";
5 | import semver from "semver";
6 |
7 | import { getShareExtensionName } from "./index";
8 |
9 | export const withPodfile: ConfigPlugin<{
10 | excludedPackages?: string[];
11 | }> = (config, { excludedPackages }) => {
12 | const targetName = getShareExtensionName(config);
13 | return withDangerousMod(config, [
14 | "ios",
15 | (config) => {
16 | const podFilePath = path.join(
17 | config.modRequest.platformProjectRoot,
18 | "Podfile",
19 | );
20 | let podfileContent = fs.readFileSync(podFilePath).toString();
21 |
22 | const postInstallBuildSettings = ` installer.pods_project.targets.each do |target|
23 | unless target.name == 'Sentry'
24 | target.build_configurations.each do |config|
25 | config.build_settings['APPLICATION_EXTENSION_API_ONLY'] = 'No'
26 | end
27 | end
28 | end`;
29 |
30 | podfileContent = mergeContents({
31 | tag: "post-install-build-settings",
32 | src: podfileContent,
33 | newSrc: postInstallBuildSettings,
34 | anchor: `react_native_post_install`,
35 | offset: 7,
36 | comment: "#",
37 | }).contents;
38 |
39 | // we always want to exclude expo-updates because it throws this error when the share extension is triggered
40 | // EXUpdates/AppController.swift:151: Assertion failed: AppController.sharedInstace was called before the module was initialized
41 | const exclude = excludedPackages?.length
42 | ? Array.from(new Set(["expo-updates", ...excludedPackages]))
43 | : ["expo-updates"];
44 |
45 | const useExpoModules = `exclude = ["${exclude.join(`", "`)}"]
46 | use_expo_modules!(exclude: exclude)`;
47 |
48 | const expoVersion = semver.parse(config.sdkVersion);
49 | const majorVersion = expoVersion?.major ?? 0;
50 |
51 | const shareExtensionTarget = `
52 |
53 | target '${targetName}' do
54 | ${useExpoModules}
55 |
56 | if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
57 | config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
58 | else
59 | config_command = [
60 | 'node',
61 | '--no-warnings',
62 | '--eval',
63 | 'require(require.resolve(\\'expo-modules-autolinking\\', { paths: [require.resolve(\\'expo/package.json\\')] }))(process.argv.slice(1))',
64 | 'react-native-config',
65 | '--json',
66 | '--platform',
67 | 'ios'
68 | ]
69 | end
70 |
71 | config = use_native_modules!(config_command)
72 |
73 | use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
74 | use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
75 |
76 | use_react_native!(
77 | :path => config[:reactNativePath],
78 | :hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
79 | # An absolute path to your application root.
80 | :app_path => "#{Pod::Config.instance.installation_root}/..",${majorVersion >= 51
81 | ? `
82 | # Temporarily disable privacy file aggregation by default, until React
83 | # Native 0.74.2 is released with fixes.
84 | :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] == 'true',`
85 | : ""
86 | }
87 | )
88 | end`;
89 |
90 | // Find the very last 'end' in the file
91 | const lastEndIndex = podfileContent.lastIndexOf("end");
92 | if (lastEndIndex === -1) {
93 | throw new Error("Could not find the last 'end' in Podfile");
94 | }
95 |
96 | // Insert the share extension target after the last 'end'
97 | podfileContent =
98 | podfileContent.slice(0, lastEndIndex + 3) + // +3 to include "end"
99 | shareExtensionTarget +
100 | podfileContent.slice(lastEndIndex + 3);
101 |
102 | fs.writeFileSync(podFilePath, podfileContent);
103 |
104 | return config;
105 | },
106 | ]);
107 | };
108 |
--------------------------------------------------------------------------------
/plugin/src/withShareExtensionEntitlements.ts:
--------------------------------------------------------------------------------
1 | import plist from "@expo/plist";
2 | import { ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
3 | import fs from "fs";
4 | import path from "path";
5 |
6 | import {
7 | getAppBundleIdentifier,
8 | getAppGroup,
9 | getShareExtensionName,
10 | } from "./index";
11 |
12 | export const withShareExtensionEntitlements: ConfigPlugin = (config) => {
13 | return withEntitlementsPlist(config, (config) => {
14 | const targetName = getShareExtensionName(config);
15 |
16 | const targetPath = path.join(
17 | config.modRequest.platformProjectRoot,
18 | targetName
19 | );
20 | const filePath = path.join(targetPath, `${targetName}.entitlements`);
21 |
22 | const bundleIdentifier = getAppBundleIdentifier(config);
23 |
24 | const existingAppGroup =
25 | config.ios?.entitlements?.["com.apple.security.application-groups"];
26 |
27 | let shareExtensionEntitlements: Record = {
28 | "com.apple.security.application-groups": existingAppGroup ?? [getAppGroup(bundleIdentifier)],
29 | };
30 |
31 | if (config.ios?.usesAppleSignIn) {
32 | shareExtensionEntitlements = {
33 | ...shareExtensionEntitlements,
34 | "com.apple.developer.applesignin": ["Default"],
35 | };
36 | }
37 |
38 | fs.mkdirSync(path.dirname(filePath), { recursive: true });
39 | fs.writeFileSync(filePath, plist.build(shareExtensionEntitlements));
40 |
41 | return config;
42 | });
43 | };
44 |
--------------------------------------------------------------------------------
/plugin/src/withShareExtensionInfoPlist.ts:
--------------------------------------------------------------------------------
1 | import plist from "@expo/plist";
2 | import {
3 | type ConfigPlugin,
4 | type InfoPlist,
5 | withInfoPlist,
6 | } from "expo/config-plugins";
7 | import fs from "fs";
8 | import path from "path";
9 |
10 | import {
11 | type ActivationRule,
12 | type BackgroundColor,
13 | type Height,
14 | getAppBundleIdentifier,
15 | getAppGroup,
16 | getShareExtensionName,
17 | } from "./index";
18 |
19 | export const withShareExtensionInfoPlist: ConfigPlugin<{
20 | fonts: string[];
21 | activationRules?: ActivationRule[];
22 | backgroundColor?: BackgroundColor;
23 | height?: Height;
24 | preprocessingFile?: string;
25 | googleServicesFile?: string;
26 | }> = (
27 | config,
28 | {
29 | fonts = [],
30 | activationRules = [{ type: "text" }, { type: "url" }],
31 | backgroundColor,
32 | height,
33 | preprocessingFile,
34 | googleServicesFile,
35 | }
36 | ) => {
37 | return withInfoPlist(config, (config) => {
38 | const targetName = getShareExtensionName(config);
39 |
40 | const targetPath = path.join(
41 | config.modRequest.platformProjectRoot,
42 | targetName
43 | );
44 |
45 | const filePath = path.join(targetPath, "Info.plist");
46 |
47 | const bundleIdentifier = getAppBundleIdentifier(config);
48 | const appGroup = getAppGroup(bundleIdentifier);
49 |
50 | let infoPlist: InfoPlist = {
51 | CFBundleDevelopmentRegion: "$(DEVELOPMENT_LANGUAGE)",
52 | CFBundleDisplayName: "$(PRODUCT_NAME) Share Extension",
53 | CFBundleExecutable: "$(EXECUTABLE_NAME)",
54 | CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)",
55 | CFBundleInfoDictionaryVersion: "6.0",
56 | CFBundleName: "$(PRODUCT_NAME)",
57 | CFBundlePackageType: "$(PRODUCT_BUNDLE_PACKAGE_TYPE)",
58 | CFBundleShortVersionString: "$(MARKETING_VERSION)",
59 | CFBundleVersion: "$(CURRENT_PROJECT_VERSION)",
60 | LSRequiresIPhoneOS: true,
61 | NSAppTransportSecurity: {
62 | NSExceptionDomains: {
63 | localhost: {
64 | NSExceptionAllowsInsecureHTTPLoads: true,
65 | },
66 | },
67 | },
68 | UIRequiredDeviceCapabilities: ["armv7"],
69 | UIStatusBarStyle: "UIStatusBarStyleDefault",
70 | UISupportedInterfaceOrientations: [
71 | "UIInterfaceOrientationPortrait",
72 | "UIInterfaceOrientationPortraitUpsideDown",
73 | ],
74 | UIUserInterfaceStyle: "Automatic",
75 | UIViewControllerBasedStatusBarAppearance: false,
76 | UIApplicationSceneManifest: {
77 | UIApplicationSupportsMultipleScenes: true,
78 | UISceneConfigurations: {},
79 | },
80 | UIAppFonts: fonts.map((font) => path.basename(font)) ?? [],
81 | // we need to add an AppGroup key for compatibility with react-native-mmkv https://github.com/mrousavy/react-native-mmkv
82 | AppGroup: appGroup,
83 | NSExtension: {
84 | NSExtensionAttributes: {
85 | NSExtensionActivationRule: activationRules.reduce((acc, current) => {
86 | switch (current.type) {
87 | case "image":
88 | return {
89 | ...acc,
90 | NSExtensionActivationSupportsImageWithMaxCount:
91 | current.max ?? 1,
92 | };
93 | case "video":
94 | return {
95 | ...acc,
96 | NSExtensionActivationSupportsMovieWithMaxCount:
97 | current.max ?? 1,
98 | };
99 | case "text":
100 | return {
101 | ...acc,
102 | NSExtensionActivationSupportsText: true,
103 | };
104 | case "url":
105 | return preprocessingFile
106 | ? {
107 | ...acc,
108 | NSExtensionActivationSupportsWebPageWithMaxCount:
109 | current.max ?? 1,
110 | NSExtensionActivationSupportsWebURLWithMaxCount:
111 | current.max ?? 1,
112 | }
113 | : {
114 | ...acc,
115 | NSExtensionActivationSupportsWebURLWithMaxCount:
116 | current.max ?? 1,
117 | };
118 | case "file":
119 | return {
120 | ...acc,
121 | NSExtensionActivationSupportsFileWithMaxCount:
122 | current.max ?? 1,
123 | };
124 | default:
125 | return acc;
126 | }
127 | }, {}),
128 | ...(preprocessingFile && {
129 | NSExtensionJavaScriptPreprocessingFile: path.basename(
130 | preprocessingFile,
131 | path.extname(preprocessingFile)
132 | ),
133 | }),
134 | },
135 | NSExtensionPrincipalClass:
136 | "$(PRODUCT_MODULE_NAME).ShareExtensionViewController",
137 | NSExtensionPointIdentifier: "com.apple.share-services",
138 | },
139 | ShareExtensionBackgroundColor: backgroundColor,
140 | ShareExtensionHeight: height,
141 | HostAppScheme: config.scheme,
142 | WithFirebase: !!googleServicesFile,
143 | };
144 |
145 | // see https://github.com/expo/expo/blob/main/packages/expo-apple-authentication/plugin/src/withAppleAuthIOS.ts#L3-L17
146 | if (config.ios?.usesAppleSignIn) {
147 | infoPlist = {
148 | ...infoPlist,
149 | CFBundleAllowedMixedLocalizations:
150 | config.modResults.CFBundleAllowMixedLocalizations ?? true,
151 | };
152 | }
153 |
154 | fs.mkdirSync(path.dirname(filePath), {
155 | recursive: true,
156 | });
157 | fs.writeFileSync(filePath, plist.build(infoPlist));
158 |
159 | return config;
160 | });
161 | };
162 |
--------------------------------------------------------------------------------
/plugin/src/withShareExtensionTarget.ts:
--------------------------------------------------------------------------------
1 | import { ConfigPlugin } from "@expo/config-plugins";
2 | import { withXcodeProject } from "expo/config-plugins";
3 | import path from "path";
4 |
5 | import {
6 | getShareExtensionBundleIdentifier,
7 | getShareExtensionName,
8 | } from "./index";
9 | import { addBuildPhases } from "./xcode/addBuildPhases";
10 | import { addPbxGroup } from "./xcode/addPbxGroup";
11 | import { addProductFile } from "./xcode/addProductFile";
12 | import { addTargetDependency } from "./xcode/addTargetDependency";
13 | import { addToPbxNativeTargetSection } from "./xcode/addToPbxNativeTargetSection";
14 | import { addToPbxProjectSection } from "./xcode/addToPbxProjectSection";
15 | import { addXCConfigurationList } from "./xcode/addToXCConfigurationList";
16 |
17 | export const withShareExtensionTarget: ConfigPlugin<{
18 | fonts: string[];
19 | googleServicesFile?: string;
20 | preprocessingFile?: string;
21 | }> = (config, { fonts = [], googleServicesFile, preprocessingFile }) => {
22 | return withXcodeProject(config, async (config) => {
23 | const xcodeProject = config.modResults;
24 |
25 | const targetName = getShareExtensionName(config);
26 | const bundleIdentifier = getShareExtensionBundleIdentifier(config);
27 | const marketingVersion = config.version;
28 |
29 | const targetUuid = xcodeProject.generateUuid();
30 | const groupName = "Embed Foundation Extensions";
31 | const { platformProjectRoot, projectRoot } = config.modRequest;
32 |
33 | if (config.ios?.googleServicesFile && !googleServicesFile) {
34 | console.warn(
35 | "Warning: No Google Services file specified for Share Extension"
36 | );
37 | }
38 |
39 | const resources = fonts.map((font: string) => path.basename(font));
40 |
41 | const googleServicesFilePath = googleServicesFile
42 | ? path.resolve(projectRoot, googleServicesFile)
43 | : undefined;
44 |
45 | if (googleServicesFile) {
46 | resources.push(path.basename(googleServicesFile));
47 | }
48 |
49 | const preprocessingFilePath = preprocessingFile
50 | ? path.resolve(projectRoot, preprocessingFile)
51 | : undefined;
52 |
53 | if (preprocessingFile) {
54 | resources.push(path.basename(preprocessingFile));
55 | }
56 |
57 | const xCConfigurationList = addXCConfigurationList(xcodeProject, {
58 | targetName,
59 | currentProjectVersion: config.ios?.buildNumber || "1",
60 | bundleIdentifier,
61 | marketingVersion,
62 | });
63 |
64 | const productFile = addProductFile(xcodeProject, {
65 | targetName,
66 | groupName,
67 | });
68 |
69 | const target = addToPbxNativeTargetSection(xcodeProject, {
70 | targetName,
71 | targetUuid,
72 | productFile,
73 | xCConfigurationList,
74 | });
75 |
76 | addToPbxProjectSection(xcodeProject, target);
77 |
78 | addTargetDependency(xcodeProject, target);
79 |
80 | addPbxGroup(xcodeProject, {
81 | targetName,
82 | platformProjectRoot,
83 | fonts,
84 | googleServicesFilePath,
85 | preprocessingFilePath,
86 | });
87 |
88 | addBuildPhases(xcodeProject, {
89 | targetUuid,
90 | groupName,
91 | productFile,
92 | resources,
93 | });
94 |
95 | return config;
96 | });
97 | };
98 |
--------------------------------------------------------------------------------
/plugin/src/xcode/addBuildPhases.ts:
--------------------------------------------------------------------------------
1 | import { XcodeProject } from "expo/config-plugins";
2 |
3 | export function addBuildPhases(
4 | xcodeProject: XcodeProject,
5 | {
6 | targetUuid,
7 | groupName,
8 | productFile,
9 | resources,
10 | }: {
11 | targetUuid: string;
12 | groupName: string;
13 | productFile: {
14 | uuid: string;
15 | target: string;
16 | basename: string;
17 | group: string;
18 | };
19 | resources: string[];
20 | },
21 | ) {
22 | const buildPath = `"$(CONTENTS_FOLDER_PATH)/ShareExtensions"`;
23 | const targetType = "app_extension";
24 |
25 | // Add shell script build phase "Start Packager"
26 | xcodeProject.addBuildPhase(
27 | [],
28 | "PBXShellScriptBuildPhase",
29 | "Start Packager",
30 | targetUuid,
31 | {
32 | shellPath: "/bin/sh",
33 | shellScript:
34 | 'export RCT_METRO_PORT="${RCT_METRO_PORT:=8081}"\necho "export RCT_METRO_PORT=${RCT_METRO_PORT}" > "${SRCROOT}/../node_modules/react-native/scripts/.packager.env"\nif [ -z "${RCT_NO_LAUNCH_PACKAGER+xxx}" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s "http://localhost:${RCT_METRO_PORT}/status" | grep -q "packager-status:running" ; then\n echo "Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly"\n exit 2\n fi\n else\n open "$SRCROOT/../node_modules/react-native/scripts/launchPackager.command" || echo "Can\'t start packager automatically"\n fi\nfi\n',
35 | },
36 | buildPath,
37 | );
38 |
39 | // Sources build phase
40 | xcodeProject.addBuildPhase(
41 | ["ShareExtensionViewController.swift"],
42 | "PBXSourcesBuildPhase",
43 | groupName,
44 | targetUuid,
45 | targetType,
46 | buildPath,
47 | );
48 |
49 | // Copy files build phase
50 | xcodeProject.addBuildPhase(
51 | [],
52 | "PBXCopyFilesBuildPhase",
53 | groupName,
54 | xcodeProject.getFirstTarget().uuid,
55 | targetType,
56 | );
57 |
58 | xcodeProject.addBuildPhase(
59 | [],
60 | "PBXCopyFilesBuildPhase",
61 | "Copy Files",
62 | xcodeProject.getFirstTarget().uuid,
63 | targetType,
64 | );
65 | xcodeProject.addToPbxCopyfilesBuildPhase(productFile);
66 |
67 | // Frameworks build phase
68 | xcodeProject.addBuildPhase(
69 | [],
70 | "PBXFrameworksBuildPhase",
71 | groupName,
72 | targetUuid,
73 | targetType,
74 | buildPath,
75 | );
76 |
77 | // Resources build phase
78 | xcodeProject.addBuildPhase(
79 | resources,
80 | "PBXResourcesBuildPhase",
81 | groupName,
82 | targetUuid,
83 | targetType,
84 | buildPath,
85 | );
86 |
87 | xcodeProject.addBuildPhase(
88 | [],
89 | "PBXShellScriptBuildPhase",
90 | "Bundle React Native code and images",
91 | targetUuid,
92 | {
93 | shellPath: "/bin/sh",
94 | shellScript: `set -e
95 | NODE_BINARY=\${NODE_BINARY:-node}
96 |
97 | # Source environment files
98 | if [[ -f "$PODS_ROOT/../.xcode.env" ]]; then
99 | source "$PODS_ROOT/../.xcode.env"
100 | fi
101 | if [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then
102 | source "$PODS_ROOT/../.xcode.env.local"
103 | fi
104 |
105 | # Set project root
106 | export PROJECT_ROOT="$PROJECT_DIR"/..
107 |
108 | # Set entry file
109 | export ENTRY_FILE="$PROJECT_ROOT/index.share.js"
110 |
111 | # Set up Expo CLI
112 | if [[ -z "$CLI_PATH" ]]; then
113 | export CLI_PATH="$("$NODE_BINARY" --print "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })")"
114 | fi
115 |
116 | if [[ -z "$BUNDLE_COMMAND" ]]; then
117 | export BUNDLE_COMMAND="export:embed"
118 | fi
119 |
120 | REACT_NATIVE_SCRIPTS_PATH=$("$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json')) + '/scripts'")
121 | WITH_ENVIRONMENT="$REACT_NATIVE_SCRIPTS_PATH/xcode/with-environment.sh"
122 | REACT_NATIVE_XCODE="$REACT_NATIVE_SCRIPTS_PATH/react-native-xcode.sh"
123 |
124 | /bin/sh -c "$WITH_ENVIRONMENT $REACT_NATIVE_XCODE"`,
125 | },
126 | buildPath,
127 | );
128 | }
129 |
--------------------------------------------------------------------------------
/plugin/src/xcode/addPbxGroup.ts:
--------------------------------------------------------------------------------
1 | import { XcodeProject } from "expo/config-plugins";
2 | import fs from "fs";
3 | import path from "path";
4 |
5 | export function addPbxGroup(
6 | xcodeProject: XcodeProject,
7 | {
8 | targetName,
9 | platformProjectRoot,
10 | fonts = [],
11 | googleServicesFilePath,
12 | preprocessingFilePath,
13 | }: {
14 | targetName: string;
15 | platformProjectRoot: string;
16 | fonts: string[];
17 | googleServicesFilePath?: string;
18 | preprocessingFilePath?: string;
19 | }
20 | ) {
21 | const targetPath = path.join(platformProjectRoot, targetName);
22 |
23 | if (!fs.existsSync(targetPath)) {
24 | fs.mkdirSync(targetPath, { recursive: true });
25 | }
26 |
27 | copyFileSync(
28 | path.join(__dirname, "../../swift/ShareExtensionViewController.swift"),
29 | targetPath,
30 | "ShareExtensionViewController.swift"
31 | );
32 |
33 | for (const font of fonts) {
34 | copyFileSync(font, targetPath);
35 | }
36 |
37 | const files = [
38 | "ShareExtensionViewController.swift",
39 | "Info.plist",
40 | `${targetName}.entitlements`,
41 | ...fonts.map((font: string) => path.basename(font)),
42 | ];
43 |
44 | if (googleServicesFilePath?.length) {
45 | copyFileSync(googleServicesFilePath, targetPath);
46 | files.push(path.basename(googleServicesFilePath));
47 | }
48 |
49 | if (preprocessingFilePath?.length) {
50 | copyFileSync(preprocessingFilePath, targetPath);
51 | files.push(path.basename(preprocessingFilePath));
52 | }
53 |
54 | // Add PBX group
55 | const { uuid: pbxGroupUuid } = xcodeProject.addPbxGroup(
56 | files,
57 | targetName,
58 | targetName
59 | );
60 |
61 | // Add PBXGroup to top level group
62 | const groups = xcodeProject.hash.project.objects["PBXGroup"];
63 | if (pbxGroupUuid) {
64 | Object.keys(groups).forEach(function (key) {
65 | if (groups[key].name === undefined && groups[key].path === undefined) {
66 | xcodeProject.addToPbxGroup(pbxGroupUuid, key);
67 | }
68 | });
69 | }
70 | }
71 |
72 | function copyFileSync(source: string, target: string, basename?: string) {
73 | let targetFile = target;
74 |
75 | if (fs.existsSync(target) && fs.lstatSync(target).isDirectory()) {
76 | targetFile = path.join(target, basename ?? path.basename(source));
77 | }
78 |
79 | fs.writeFileSync(targetFile, fs.readFileSync(source));
80 | }
81 |
--------------------------------------------------------------------------------
/plugin/src/xcode/addProductFile.ts:
--------------------------------------------------------------------------------
1 | import { XcodeProject } from "expo/config-plugins";
2 |
3 | export function addProductFile(
4 | xcodeProject: XcodeProject,
5 | { targetName }: { targetName: string; groupName: string }
6 | ) {
7 | const productFile = xcodeProject.addProductFile(targetName, {
8 | group: "Copy Files",
9 | explicitFileType: "wrapper.app-extension",
10 | });
11 |
12 | xcodeProject.addToPbxBuildFileSection(productFile);
13 |
14 | return productFile;
15 | }
16 |
--------------------------------------------------------------------------------
/plugin/src/xcode/addTargetDependency.ts:
--------------------------------------------------------------------------------
1 | import { XcodeProject } from "expo/config-plugins";
2 |
3 | export function addTargetDependency(
4 | xcodeProject: XcodeProject,
5 | target: { uuid: string }
6 | ) {
7 | if (!xcodeProject.hash.project.objects["PBXTargetDependency"]) {
8 | xcodeProject.hash.project.objects["PBXTargetDependency"] = {};
9 | }
10 | if (!xcodeProject.hash.project.objects["PBXContainerItemProxy"]) {
11 | xcodeProject.hash.project.objects["PBXContainerItemProxy"] = {};
12 | }
13 |
14 | xcodeProject.addTargetDependency(xcodeProject.getFirstTarget().uuid, [
15 | target.uuid,
16 | ]);
17 | }
18 |
--------------------------------------------------------------------------------
/plugin/src/xcode/addToPbxNativeTargetSection.ts:
--------------------------------------------------------------------------------
1 | import { XcodeProject } from "expo/config-plugins";
2 |
3 | export function addToPbxNativeTargetSection(
4 | xcodeProject: XcodeProject,
5 | {
6 | targetName,
7 | targetUuid,
8 | productFile,
9 | xCConfigurationList,
10 | }: {
11 | targetName: string;
12 | targetUuid: string;
13 | productFile: { fileRef: string };
14 | xCConfigurationList: { uuid: string };
15 | }
16 | ) {
17 | const target = {
18 | uuid: targetUuid,
19 | pbxNativeTarget: {
20 | isa: "PBXNativeTarget",
21 | name: targetName,
22 | productName: targetName,
23 | productReference: productFile.fileRef,
24 | productType: `"com.apple.product-type.app-extension"`,
25 | buildConfigurationList: xCConfigurationList.uuid,
26 | buildPhases: [],
27 | buildRules: [],
28 | dependencies: [],
29 | },
30 | };
31 |
32 | xcodeProject.addToPbxNativeTargetSection(target);
33 |
34 | return target;
35 | }
36 |
--------------------------------------------------------------------------------
/plugin/src/xcode/addToPbxProjectSection.ts:
--------------------------------------------------------------------------------
1 | import { XcodeProject } from "expo/config-plugins";
2 |
3 | export function addToPbxProjectSection(
4 | xcodeProject: XcodeProject,
5 | target: { uuid: string }
6 | ) {
7 | xcodeProject.addToPbxProjectSection(target);
8 |
9 | // Add target attributes to project section
10 | if (
11 | !xcodeProject.pbxProjectSection()[xcodeProject.getFirstProject().uuid]
12 | .attributes.TargetAttributes
13 | ) {
14 | xcodeProject.pbxProjectSection()[
15 | xcodeProject.getFirstProject().uuid
16 | ].attributes.TargetAttributes = {};
17 | }
18 | xcodeProject.pbxProjectSection()[
19 | xcodeProject.getFirstProject().uuid
20 | ].attributes.TargetAttributes[target.uuid] = {
21 | LastSwiftMigration: 1250,
22 | };
23 | }
24 |
--------------------------------------------------------------------------------
/plugin/src/xcode/addToXCConfigurationList.ts:
--------------------------------------------------------------------------------
1 | import { XcodeProject } from "expo/config-plugins";
2 |
3 | export function addXCConfigurationList(
4 | xcodeProject: XcodeProject,
5 | {
6 | targetName,
7 | currentProjectVersion,
8 | bundleIdentifier,
9 | marketingVersion,
10 | }: {
11 | targetName: string;
12 | currentProjectVersion: string;
13 | bundleIdentifier: string;
14 | marketingVersion?: string;
15 | },
16 | ) {
17 | const commonBuildSettings = {
18 | CLANG_ENABLE_MODULES: "YES",
19 | CURRENT_PROJECT_VERSION: `"${currentProjectVersion}"`,
20 | INFOPLIST_FILE: `${targetName}/Info.plist`,
21 | IPHONEOS_DEPLOYMENT_TARGET: `"15.1"`,
22 | LD_RUNPATH_SEARCH_PATHS: `"$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"`,
23 | PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`,
24 | PRODUCT_NAME: `"${targetName}"`,
25 | SWIFT_VERSION: "5.0",
26 | VERSIONING_SYSTEM: `"apple-generic"`,
27 | CODE_SIGN_ENTITLEMENTS: `${targetName}/${targetName}.entitlements`,
28 | GENERATE_INFOPLIST_FILE: "YES",
29 | MARKETING_VERSION: `"${marketingVersion ?? 1}"`,
30 | ENABLE_ON_DEMAND_RESOURCES: "NO",
31 | };
32 |
33 | const buildConfigurationsList = [
34 | {
35 | name: "Debug",
36 | isa: "XCBuildConfiguration",
37 | buildSettings: {
38 | ...commonBuildSettings,
39 | SWIFT_ACTIVE_COMPILATION_CONDITIONS: "DEBUG",
40 | },
41 | },
42 | {
43 | name: "Release",
44 | isa: "XCBuildConfiguration",
45 | buildSettings: {
46 | ...commonBuildSettings,
47 | },
48 | },
49 | ];
50 |
51 | const xCConfigurationList = xcodeProject.addXCConfigurationList(
52 | buildConfigurationsList,
53 | "Release",
54 | `Build configuration list for PBXNativeTarget "${targetName}"`,
55 | );
56 |
57 | return xCConfigurationList;
58 | }
59 |
--------------------------------------------------------------------------------
/plugin/swift/ShareExtensionViewController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import React
3 | import React_RCTAppDelegate
4 | import AVFoundation
5 | // switch to UniformTypeIdentifiers, once 14.0 is the minimum deploymnt target on expo (currently 13.4 in expo v50)
6 | import MobileCoreServices
7 | // if react native firebase is installed, we import and configure it
8 | #if canImport(FirebaseCore)
9 | import FirebaseCore
10 | #endif
11 | #if canImport(FirebaseAuth)
12 | import FirebaseAuth
13 | #endif
14 |
15 | // MARK: - Objective-C Bridge
16 | @objc class RCTShareExtensionBridge: NSObject {
17 | @objc static func createRootViewFactory() -> RCTRootViewFactory {
18 | let configuration = RCTRootViewFactoryConfiguration(
19 | bundleURLBlock: {
20 | #if DEBUG
21 | let settings = RCTBundleURLProvider.sharedSettings()
22 | settings.enableDev = true
23 | settings.enableMinification = false
24 | if let bundleURL = settings.jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") {
25 | if var components = URLComponents(url: bundleURL, resolvingAgainstBaseURL: false) {
26 | components.queryItems = (components.queryItems ?? []) + [URLQueryItem(name: "shareExtension", value: "true")]
27 | return components.url ?? bundleURL
28 | }
29 | return bundleURL
30 | }
31 | fatalError("Could not create bundle URL")
32 | #else
33 | guard let bundleURL = Bundle.main.url(forResource: "main", withExtension: "jsbundle") else {
34 | fatalError("Could not load bundle URL")
35 | }
36 | return bundleURL
37 | #endif
38 | },
39 | newArchEnabled: false,
40 | turboModuleEnabled: true,
41 | bridgelessEnabled: false
42 | )
43 |
44 | return RCTRootViewFactory(configuration: configuration)
45 | }
46 | }
47 |
48 | class ShareExtensionViewController: UIViewController {
49 | private var rootViewFactory: RCTRootViewFactory?
50 | private weak var rootView: UIView?
51 | private let loadingIndicator = UIActivityIndicatorView(style: .large)
52 |
53 | deinit {
54 | print("🧹 ShareExtensionViewController deinit")
55 | cleanupAfterClose()
56 | }
57 |
58 | override func viewWillDisappear(_ animated: Bool) {
59 | super.viewWillDisappear(animated)
60 | // Start cleanup earlier to ensure proper surface teardown
61 | if isBeingDismissed {
62 | cleanupAfterClose()
63 | }
64 | }
65 |
66 | override func viewDidLoad() {
67 | super.viewDidLoad()
68 | setupLoadingIndicator()
69 |
70 | #if canImport(FirebaseCore)
71 | if Bundle.main.object(forInfoDictionaryKey: "WithFirebase") as? Bool ?? false {
72 | FirebaseApp.configure()
73 | }
74 | #endif
75 |
76 | initializeRootViewFactory()
77 | loadReactNativeContent()
78 | setupNotificationCenterObserver()
79 | }
80 |
81 | override func viewDidDisappear(_ animated: Bool) {
82 | super.viewDidDisappear(animated)
83 | // we need to clean up when the view is closed via a swipe
84 | cleanupAfterClose()
85 | }
86 |
87 | func close() {
88 | self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
89 | // we need to clean up when the view is closed via the close() method in react native
90 | cleanupAfterClose()
91 | }
92 |
93 | private func setupLoadingIndicator() {
94 | view.addSubview(loadingIndicator)
95 | loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
96 | NSLayoutConstraint.activate([
97 | loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
98 | loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
99 | ])
100 | loadingIndicator.startAnimating()
101 | }
102 |
103 | private func initializeRootViewFactory() {
104 | if rootViewFactory == nil {
105 | rootViewFactory = RCTShareExtensionBridge.createRootViewFactory()
106 | }
107 | }
108 |
109 | private func openHostApp(path: String?) {
110 | guard let scheme = Bundle.main.object(forInfoDictionaryKey: "HostAppScheme") as? String else { return }
111 | var urlComponents = URLComponents()
112 | urlComponents.scheme = scheme
113 | urlComponents.host = ""
114 |
115 | if let path = path {
116 | let pathComponents = path.split(separator: "?", maxSplits: 1)
117 | let pathWithoutQuery = String(pathComponents[0])
118 | let queryString = pathComponents.count > 1 ? String(pathComponents[1]) : nil
119 |
120 | // Parse and set query items
121 | if let queryString = queryString {
122 | let queryItems = queryString.split(separator: "&").map { queryParam -> URLQueryItem in
123 | let paramComponents = queryParam.split(separator: "=", maxSplits: 1)
124 | let name = String(paramComponents[0])
125 | let value = paramComponents.count > 1 ? String(paramComponents[1]) : nil
126 | return URLQueryItem(name: name, value: value)
127 | }
128 | urlComponents.queryItems = queryItems
129 | }
130 |
131 | let pathWithSlashEnsured = pathWithoutQuery.hasPrefix("/") ? pathWithoutQuery : "/\(pathWithoutQuery)"
132 | urlComponents.path = pathWithSlashEnsured
133 | }
134 |
135 | guard let url = urlComponents.url else { return }
136 | openURL(url)
137 | self.close()
138 | }
139 |
140 | @objc @discardableResult private func openURL(_ url: URL) -> Bool {
141 | var responder: UIResponder? = self
142 | while responder != nil {
143 | if let application = responder as? UIApplication {
144 | if #available(iOS 18.0, *) {
145 | application.open(url, options: [:], completionHandler: nil)
146 | return true
147 | } else {
148 | return application.perform(#selector(UIApplication.open(_:options:completionHandler:)), with: url, with: [:]) != nil
149 | }
150 | }
151 | responder = responder?.next
152 | }
153 | return false
154 | }
155 |
156 | private func loadReactNativeContent() {
157 | getShareData { [weak self] sharedData in
158 | guard let self = self else {
159 | print("❌ Self was deallocated")
160 | return
161 | }
162 |
163 | DispatchQueue.main.async {
164 | if self.rootView == nil {
165 | guard let factory = self.rootViewFactory else {
166 | print("🚨 Factory is nil")
167 | return
168 | }
169 |
170 | let rootView = factory.view(
171 | withModuleName: "shareExtension",
172 | initialProperties: sharedData
173 | )
174 | let backgroundFromInfoPlist = Bundle.main.object(forInfoDictionaryKey: "ShareExtensionBackgroundColor") as? [String: CGFloat]
175 | let heightFromInfoPlist = Bundle.main.object(forInfoDictionaryKey: "ShareExtensionHeight") as? CGFloat
176 |
177 | self.configureRootView(rootView, withBackgroundColorDict: backgroundFromInfoPlist, withHeight: heightFromInfoPlist)
178 | self.rootView = rootView
179 | } else {
180 | // Update properties based on view type
181 | if let rctView = self.rootView as? RCTRootView {
182 | rctView.appProperties = sharedData
183 | } else if let proxyView = self.rootView as? RCTSurfaceHostingProxyRootView {
184 | proxyView.appProperties = sharedData ?? [:]
185 | }
186 | }
187 |
188 | self.loadingIndicator.stopAnimating()
189 | self.loadingIndicator.removeFromSuperview()
190 | }
191 | }
192 | }
193 |
194 | private func setupNotificationCenterObserver() {
195 | NotificationCenter.default.addObserver(forName: NSNotification.Name("close"), object: nil, queue: nil) { [weak self] _ in
196 | DispatchQueue.main.async {
197 | self?.close()
198 | }
199 | }
200 |
201 | NotificationCenter.default.addObserver(forName: NSNotification.Name("openHostApp"), object: nil, queue: nil) { [weak self] notification in
202 | DispatchQueue.main.async {
203 | if let userInfo = notification.userInfo {
204 | if let path = userInfo["path"] as? String {
205 | self?.openHostApp(path: path)
206 | }
207 | }
208 | }
209 | }
210 | }
211 |
212 | private func cleanupAfterClose() {
213 | // Clean up notification observers first
214 | NotificationCenter.default.removeObserver(self)
215 |
216 | // Clean up properties based on view type
217 | if let rctView = rootView as? RCTRootView {
218 | rctView.appProperties = nil
219 | } else if let proxyView = rootView as? RCTSurfaceHostingProxyRootView {
220 | proxyView.appProperties = [:]
221 | }
222 |
223 | rootView?.removeFromSuperview()
224 | rootView = nil
225 |
226 | // Clean up factory last
227 | rootViewFactory = nil
228 | }
229 |
230 | private func configureRootView(_ rootView: UIView, withBackgroundColorDict dict: [String: CGFloat]?, withHeight: CGFloat?) {
231 | rootView.backgroundColor = backgroundColor(from: dict)
232 |
233 | // Get the screen bounds and scale
234 | let screen = UIScreen.main
235 | let screenBounds = screen.bounds
236 | let screenScale = screen.scale
237 |
238 | // Calculate proper frame
239 | let frame: CGRect
240 | if let withHeight = withHeight {
241 | frame = CGRect(
242 | x: 0,
243 | y: screenBounds.height - withHeight,
244 | width: screenBounds.width,
245 | height: withHeight
246 | )
247 | } else {
248 | frame = screenBounds
249 | }
250 |
251 | if let proxyRootView = rootView as? RCTSurfaceHostingProxyRootView {
252 | // Set surface size in points (not pixels)
253 | let surfaceSize = CGSize(
254 | width: frame.width * screenScale,
255 | height: frame.height * screenScale
256 | )
257 |
258 | proxyRootView.surface.setMinimumSize(surfaceSize, maximumSize: surfaceSize)
259 |
260 | // Set bounds in points
261 | proxyRootView.bounds = CGRect(origin: .zero, size: frame.size)
262 | proxyRootView.center = CGPoint(x: frame.midX, y: frame.midY)
263 | } else {
264 | rootView.frame = frame
265 | }
266 |
267 | rootView.translatesAutoresizingMaskIntoConstraints = false
268 | self.view.addSubview(rootView)
269 |
270 | NSLayoutConstraint.activate([
271 | rootView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
272 | rootView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
273 | rootView.heightAnchor.constraint(equalToConstant: frame.height)
274 | ])
275 |
276 | if let withHeight = withHeight {
277 | NSLayoutConstraint.activate([
278 | rootView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
279 | ])
280 | } else {
281 | NSLayoutConstraint.activate([
282 | rootView.topAnchor.constraint(equalTo: self.view.topAnchor)
283 | ])
284 | }
285 |
286 | if withHeight == nil {
287 | rootView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
288 | }
289 | }
290 |
291 | private func backgroundColor(from dict: [String: CGFloat]?) -> UIColor {
292 | guard let dict = dict else { return .white }
293 | let red = dict["red"] ?? 255.0
294 | let green = dict["green"] ?? 255.0
295 | let blue = dict["blue"] ?? 255.0
296 | let alpha = dict["alpha"] ?? 1
297 | return UIColor(red: red / 255.0, green: green / 255.0, blue: blue / 255.0, alpha: alpha)
298 | }
299 |
300 | private func getShareData(completion: @escaping ([String: Any]?) -> Void) {
301 | guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else {
302 | completion(nil)
303 | return
304 | }
305 |
306 | var sharedItems: [String: Any] = [:]
307 |
308 | let group = DispatchGroup()
309 |
310 | let fileManager = FileManager.default
311 |
312 | for item in extensionItems {
313 | for provider in item.attachments ?? [] {
314 | if provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
315 | group.enter()
316 | provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil) { (urlItem, error) in
317 | DispatchQueue.main.async {
318 | if let sharedURL = urlItem as? URL {
319 | if sharedURL.isFileURL {
320 | if sharedItems["files"] == nil {
321 | sharedItems["files"] = [String]()
322 | }
323 | if var fileArray = sharedItems["files"] as? [String] {
324 | fileArray.append(sharedURL.absoluteString)
325 | sharedItems["files"] = fileArray
326 | }
327 | } else {
328 | sharedItems["url"] = sharedURL.absoluteString
329 | }
330 | }
331 | group.leave()
332 | }
333 | }
334 | } else if provider.hasItemConformingToTypeIdentifier(kUTTypePropertyList as String) {
335 | group.enter()
336 | provider.loadItem(forTypeIdentifier: kUTTypePropertyList as String, options: nil) { (item, error) in
337 | DispatchQueue.main.async {
338 | if let itemDict = item as? NSDictionary,
339 | let results = itemDict[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary {
340 | sharedItems["preprocessingResults"] = results
341 | }
342 | group.leave()
343 | }
344 | }
345 | } else if provider.hasItemConformingToTypeIdentifier(kUTTypeText as String) {
346 | group.enter()
347 | provider.loadItem(forTypeIdentifier: kUTTypeText as String, options: nil) { (textItem, error) in
348 | DispatchQueue.main.async {
349 | if let text = textItem as? String {
350 | sharedItems["text"] = text
351 | }
352 | group.leave()
353 | }
354 | }
355 | } else if provider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
356 | group.enter()
357 | provider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { (imageItem, error) in
358 | DispatchQueue.main.async {
359 |
360 | // Ensure the array exists
361 | if sharedItems["images"] == nil {
362 | sharedItems["images"] = [String]()
363 | }
364 |
365 | guard let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as? String else {
366 | print("Could not find AppGroup in info.plist")
367 | return
368 | }
369 |
370 | guard let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
371 | print("Could not set up file manager container URL for app group")
372 | return
373 | }
374 |
375 | if let imageUri = imageItem as? NSURL {
376 | if let tempFilePath = imageUri.path {
377 | let fileExtension = imageUri.pathExtension ?? "jpg"
378 | let fileName = UUID().uuidString + "." + fileExtension
379 |
380 | let sharedDataUrl = containerUrl.appendingPathComponent("sharedData")
381 |
382 | if !fileManager.fileExists(atPath: sharedDataUrl.path) {
383 | do {
384 | try fileManager.createDirectory(at: sharedDataUrl, withIntermediateDirectories: true)
385 | } catch {
386 | print("Failed to create sharedData directory: \(error)")
387 | }
388 | }
389 |
390 | let persistentURL = sharedDataUrl.appendingPathComponent(fileName)
391 |
392 | do {
393 | try fileManager.copyItem(atPath: tempFilePath, toPath: persistentURL.path)
394 | if var videoArray = sharedItems["images"] as? [String] {
395 | videoArray.append(persistentURL.absoluteString)
396 | sharedItems["images"] = videoArray
397 | }
398 | } catch {
399 | print("Failed to copy image: \(error)")
400 | }
401 | }
402 | } else if let image = imageItem as? UIImage {
403 | // Handle UIImage if needed (e.g., save to disk and get the file path)
404 | if let imageData = image.jpegData(compressionQuality: 1.0) {
405 | let fileName = UUID().uuidString + ".jpg"
406 |
407 | let sharedDataUrl = containerUrl.appendingPathComponent("sharedData")
408 |
409 | if !fileManager.fileExists(atPath: sharedDataUrl.path) {
410 | do {
411 | try fileManager.createDirectory(at: sharedDataUrl, withIntermediateDirectories: true)
412 | } catch {
413 | print("Failed to create sharedData directory: \(error)")
414 | }
415 | }
416 |
417 | let persistentURL = sharedDataUrl.appendingPathComponent(fileName)
418 |
419 | do {
420 | try imageData.write(to: persistentURL)
421 | if var imageArray = sharedItems["images"] as? [String] {
422 | imageArray.append(persistentURL.absoluteString)
423 | sharedItems["images"] = imageArray
424 | }
425 | } catch {
426 | print("Failed to save image: \(error)")
427 | }
428 | }
429 | } else {
430 | print("imageItem is not a recognized type")
431 | }
432 | group.leave()
433 | }
434 | }
435 | } else if provider.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) {
436 | group.enter()
437 | provider.loadItem(forTypeIdentifier: kUTTypeMovie as String, options: nil) { (videoItem, error) in
438 | DispatchQueue.main.async {
439 | print("videoItem type: \(type(of: videoItem))")
440 |
441 | // Ensure the array exists
442 | if sharedItems["videos"] == nil {
443 | sharedItems["videos"] = [String]()
444 | }
445 |
446 | guard let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as? String else {
447 | print("Could not find AppGroup in info.plist")
448 | return
449 | }
450 |
451 | guard let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
452 | print("Could not set up file manager container URL for app group")
453 | return
454 | }
455 |
456 | // Check if videoItem is NSURL
457 | if let videoUri = videoItem as? NSURL {
458 | if let tempFilePath = videoUri.path {
459 | let fileExtension = videoUri.pathExtension ?? "mov"
460 | let fileName = UUID().uuidString + "." + fileExtension
461 |
462 | let sharedDataUrl = containerUrl.appendingPathComponent("sharedData")
463 |
464 | if !fileManager.fileExists(atPath: sharedDataUrl.path) {
465 | do {
466 | try fileManager.createDirectory(at: sharedDataUrl, withIntermediateDirectories: true)
467 | } catch {
468 | print("Failed to create sharedData directory: \(error)")
469 | }
470 | }
471 |
472 | let persistentURL = sharedDataUrl.appendingPathComponent(fileName)
473 |
474 | do {
475 | try fileManager.copyItem(atPath: tempFilePath, toPath: persistentURL.path)
476 | if var videoArray = sharedItems["videos"] as? [String] {
477 | videoArray.append(persistentURL.path)
478 | sharedItems["videos"] = videoArray
479 | }
480 | } catch {
481 | print("Failed to copy video: \(error)")
482 | }
483 | }
484 | }
485 | // Check if videoItem is NSData
486 | else if let videoData = videoItem as? NSData {
487 | let fileExtension = "mov" // Using mov as default type extension
488 | let fileName = UUID().uuidString + "." + fileExtension
489 |
490 | let sharedDataUrl = containerUrl.appendingPathComponent("sharedData")
491 |
492 | if !fileManager.fileExists(atPath: sharedDataUrl.path) {
493 | do {
494 | try fileManager.createDirectory(at: sharedDataUrl, withIntermediateDirectories: true)
495 | } catch {
496 | print("Failed to create sharedData directory: \(error)")
497 | }
498 | }
499 |
500 | let persistentURL = sharedDataUrl.appendingPathComponent(fileName)
501 |
502 | do {
503 | try videoData.write(to: persistentURL)
504 | if var videoArray = sharedItems["videos"] as? [String] {
505 | videoArray.append(persistentURL.path)
506 | sharedItems["videos"] = videoArray
507 | }
508 | } catch {
509 | print("Failed to save video: \(error)")
510 | }
511 | }
512 | // Check if videoItem is AVAsset
513 | else if let asset = videoItem as? AVAsset {
514 | let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough)
515 |
516 | let fileExtension = "mov" // Using mov as default type extension
517 | let fileName = UUID().uuidString + "." + fileExtension
518 |
519 | let sharedDataUrl = containerUrl.appendingPathComponent("sharedData")
520 |
521 | if !fileManager.fileExists(atPath: sharedDataUrl.path) {
522 | do {
523 | try fileManager.createDirectory(at: sharedDataUrl, withIntermediateDirectories: true)
524 | } catch {
525 | print("Failed to create sharedData directory: \(error)")
526 | }
527 | }
528 |
529 | let persistentURL = sharedDataUrl.appendingPathComponent(fileName)
530 |
531 | exportSession?.outputURL = persistentURL
532 | exportSession?.outputFileType = .mov
533 | exportSession?.exportAsynchronously {
534 | switch exportSession?.status {
535 | case .completed:
536 | if var videoArray = sharedItems["videos"] as? [String] {
537 | videoArray.append(persistentURL.absoluteString)
538 | sharedItems["videos"] = videoArray
539 | }
540 | case .failed:
541 | print("Failed to export video: \(String(describing: exportSession?.error))")
542 | default:
543 | break
544 | }
545 | }
546 | } else {
547 | print("videoItem is not a recognized type")
548 | }
549 | group.leave()
550 | }
551 | }
552 | }
553 | }
554 | }
555 |
556 | group.notify(queue: .main) {
557 | completion(sharedItems.isEmpty ? nil : sharedItems)
558 | }
559 | }
560 | }
--------------------------------------------------------------------------------
/plugin/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo-module-scripts/tsconfig.plugin",
3 | "compilerOptions": {
4 | "outDir": "build",
5 | "rootDir": "src"
6 | },
7 | "include": ["./src"],
8 | "exclude": ["**/__mocks__/*", "**/__tests__/*"]
9 | }
10 |
--------------------------------------------------------------------------------
/src/ExpoShareExtensionModule.ts:
--------------------------------------------------------------------------------
1 | import { requireNativeModule } from "expo-modules-core";
2 |
3 | // It loads the native module object from the JSI or falls back to
4 | // the bridge module (from NativeModulesProxy) if the remote debugger is on.
5 | export default requireNativeModule("ExpoShareExtension");
6 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import ExpoShareExtensionModule from "./ExpoShareExtensionModule";
2 |
3 | export function close(): void {
4 | return ExpoShareExtensionModule.close();
5 | }
6 |
7 | export function openHostApp(path: string): void {
8 | return ExpoShareExtensionModule.openHostApp(path);
9 | }
10 |
11 | export async function clearAppGroupContainer(args?: {
12 | cleanUpBefore?: Date;
13 | }): Promise {
14 | return await ExpoShareExtensionModule.clearAppGroupContainer(
15 | args?.cleanUpBefore?.toISOString(),
16 | );
17 | }
18 |
19 | export interface IExtensionPreprocessingJS {
20 | run: (args: { completionFunction: (data: unknown) => void }) => void;
21 | finalize: (args: unknown) => void;
22 | }
23 |
24 | export type InitialProps = {
25 | files?: string[];
26 | images?: string[];
27 | videos?: string[];
28 | text?: string;
29 | url?: string;
30 | preprocessingResults?: unknown;
31 | };
32 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo-module-scripts/tsconfig.base",
3 | "compilerOptions": {
4 | "outDir": "./build",
5 | "allowJs": true
6 | },
7 | "include": ["./src"],
8 | "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
9 | }
10 |
--------------------------------------------------------------------------------