Skip to content

Commit

Permalink
Merge pull request #50 from bndkt/nathan-ahn/0.5.0
Browse files Browse the repository at this point in the history
0.5.0 Release

Features:
- `.clip` extension support and docs
- `enabled` configuration option
- Push notification support
Fixes:
- Device family bugs
- Unsupported plist keys
- Cross platform issues
- Non-Expo module excluded packages
  • Loading branch information
nathan-ahn authored Nov 24, 2024
2 parents c11addf + e3d4083 commit e15ea2f
Show file tree
Hide file tree
Showing 24 changed files with 242 additions and 52 deletions.
53 changes: 38 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,27 @@ In your app’s Expo config (app.json, or app.config.js), make sure that react-n

## Additional parameters:

- **name** (string): The public name of the App Clip (displayed when opening it).
- **bundleIdSuffix** (string, default: "Clip"): The suffix that is appended to the bundle id to form the App Clip's bundle id. Please note: If you change this, you need to pass the new suffix when using the isClip() function (e.g. `isClip("AppClip")`).
- **targetSuffix** (string, default: "Clip"): The suffix that is appended to the target name.
- **groupIdentifier** (string): Configures an app group to share data between App Clip and full app (see [Apple Developer Docs](https://developer.apple.com/documentation/xcode/configuring-app-groups))
- **deploymentTarget** (string): Sets the deployment target for the App Clip. If you set this to "16.0", your App Clip can be 15 MB instead of 10 MB.
- **requestEphemeralUserNotification** (boolean): Enables notifications for the App Clip (see [Apple Developer Docs](https://developer.apple.com/documentation/app_clips/enabling_notifications_in_app_clips))
- **requestLocationConfirmation** (boolean): Allow App Clip access to location data (see [Apple Developer Docs](https://developer.apple.com/documentation/app_clips/confirming_the_user_s_physical_location))
- **appleSignin** (boolean): Enable "Sign in with Apple" for the App Clip
- **applePayMerchantIds** (string[]): Enable Apple Pay capability with provided merchant IDs.
- **excludedPackages** (string[]): Packages to exclude from autolinking for the App Clip to reduce bundle size (see below).
- **name** (string): The public name of the App Clip (displayed when opening it).
- **enabled** (boolean, default: true): Whether the App Clip should be built.
- **bundleIdSuffix** (string, default: "Clip"): The suffix that is appended to the bundle id to form the App Clip's bundle id. Please note: If you change this, you need to pass the new suffix when using the isClip() function (e.g. `isClip("AppClip")`).
- **targetSuffix** (string, default: "Clip"): The suffix that is appended to the target name.
- **groupIdentifier** (string): Configures an app group to share data between App Clip and full app (see [Apple Developer Docs](https://developer.apple.com/documentation/xcode/configuring-app-groups))
- **deploymentTarget** (string): Sets the deployment target for the App Clip. If you set this to "16.0", your App Clip can be 15 MB instead of 10 MB.
- **requestEphemeralUserNotification** (boolean): Enables notifications for the App Clip (see [Apple Developer Docs](https://developer.apple.com/documentation/app_clips/enabling_notifications_in_app_clips))
- **requestLocationConfirmation** (boolean): Allow App Clip access to location data (see [Apple Developer Docs](https://developer.apple.com/documentation/app_clips/confirming_the_user_s_physical_location))
- **appleSignin** (boolean): Enable "Sign in with Apple" for the App Clip
- **applePayMerchantIds** (string[]): Enable Apple Pay capability with provided merchant IDs.
- **excludedPackages** (string[]): Packages to exclude from autolinking for the App Clip to reduce bundle size (see below).
- **pushNotifications** (boolean): Enable push notification compatibility for the App Clip

## Native capabilities

```typescript
import {
isClip,
displayOverlay,
setSharedCredential,
getSharedCredential,
isClip,
displayOverlay,
setSharedCredential,
getSharedCredential,
} from "react-native-app-clip";
```

Expand All @@ -54,7 +56,26 @@ import {

**setSharedCredential()** and **getSharedCredential()** allows sharing login data from the App Clip to the full app so that the user doesn't have to sign in again after downloading the full app (see [Apple Developer Docs](https://developer.apple.com/documentation/app_clips/sharing_data_between_your_app_clip_and_your_full_app)).

## Before building for the App Store
## App Clip file extension (.clip)

You can configure an App Clip specific file extension similar to [React Native's native platform extensions](https://reactnative.dev/docs/platform-specific-code#native-specific-extensions-ie-sharing-code-with-nodejs-and-web). Enabling this can help optimize build sizes since only the `.clip` file will be included in the App Clip JS bundle. You can replace heavy assets, large components, or unsupported library features using this file extension. The `.clip` extension preference can be configured in `metro.config.js` by prepending `clip` to each of the existing source extensions when the env variable `BUILDING_FOR_APP_CLIP` is enabled.

### Usage

```typescript
if (process.env.BUILDING_FOR_APP_CLIP) {
console.info("Building for App Clip");
config.resolver = {
...config.resolver,
sourceExts: [].concat(
config.resolver.sourceExts.map((e) => `clip.${e}`),
config.resolver.sourceExts,
),
};
}
```

## Before building for release (TestFlight and App Store)

Please note that you must register a new App ID for the App Clip in your Apple Developer profile. Under "Certificates, Identifiers & Profiles", go to “Identifiers”, click on the plus icon and select "App IDs" to create a new App ID. Select "App Clip" as the type and on the next screen, select your main app as the "Parent App ID" and enter Clip as the product name (it is crucial that the product name is "Clip" and nothing else. At the bottom of the page, Apple shows a preview of the App Clip Bundle ID. If your main app’s bundle ID is com.example.my-app, the App Clip Bundle ID should now be com.example.my-app.Clip.

Expand All @@ -78,6 +99,8 @@ Now you can simply type "npm run clip" in your terminal to open the App Clip.

Run `expo prebuild -p ios` (see https://docs.expo.dev/workflow/expo-cli/#expo-prebuild) in your Expo project folder to generate the ios folder with all native sources. Then open the file `ios/my-app.xcworkspace` (with my-app being your app’s name) in Xcode. In your project, you should find two targets, one named like your app (e.g. "my-app") and one with a "Clip" suffix (e.g. "my-appClip"). For both targets, select a team in the "Signing & Capabilities" tab and make sure a signing certificate is selected by Xcode. Then, using the menu bar, select "Product", followed by "Scheme", where you should see two themes listed at the bottom of the menu, named after the two targets. Select the scheme that ends with "Clip". Now you can build and run the App Clip (using the menu bar via "Product" followed by "Run" or using the shortcut ⌘R).

Launching from Xcode can often fix issues in which the App Clip doesn't show up as an installed app on the device. Once you launch from Xcode once, your App Clip should be available with subsequent CLI builds.

### Build for production using EAS Build and test via TestFlight

You can build your app and submit it to the App Store (see https://docs.expo.dev/build/introduction/) to test the App Clip using TestFlight. Refer to Apple’s developer docs about testing App Clips: https://developer.apple.com/documentation/app_clips/testing_the_launch_experience_of_your_app_clip.
13 changes: 13 additions & 0 deletions example/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,17 @@ config.transformer.getTransformOptions = async () => ({
},
});

// If you enable this flag, `*.clip.*` files will be prioritized. You can use this to optimize your JS bundle.
const USE_CLIP_FILE_EXT = false;
if (USE_CLIP_FILE_EXT && process.env.BUILDING_FOR_APP_CLIP) {
console.info("Building for App Clip");
config.resolver = {
...config.resolver,
sourceExts: [].concat(
config.resolver.sourceExts.map(e => `clip.${e}`),
config.resolver.sourceExts,
),
}
}

module.exports = config;
29 changes: 29 additions & 0 deletions plugin/src/cliPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export const run = async (
config: {
dependencies: Record<string, object>;
},
exclude: string[],
) => {
// Validation
if (typeof config !== "object") {
throw new Error("config must be an object");
}
if (!config.dependencies) {
throw new Error("config must have a dependencies key");
}
if (!Array.isArray(exclude)) {
throw new Error("exclude must be an array");
}
for (const packageName of exclude) {
if (typeof packageName !== "string") {
throw new Error("exclude must be an array of strings");
}
}

// Removing excluded packages
for (const packageName of exclude) {
delete config.dependencies[packageName];
}

console.log(JSON.stringify(config));
};
17 changes: 15 additions & 2 deletions plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { withEntitlements } from "./withEntitlements";
import { withPlist } from "./withPlist";
import { withPodfile } from "./withPodfile";
import { withXcode } from "./withXcode";
import { withDeviceFamily } from "@expo/config-plugins/build/ios/DeviceFamily";

const withAppClip: ConfigPlugin<{
name?: string;
enabled?: boolean;
bundleIdSuffix?: string;
targetSuffix?: string;
groupIdentifier?: string;
Expand All @@ -17,10 +19,12 @@ const withAppClip: ConfigPlugin<{
appleSignin?: boolean;
applePayMerchantIds?: string[];
excludedPackages?: string[];
pushNotifications?: boolean;
}> = (
config,
{
name,
enabled,
bundleIdSuffix,
targetSuffix,
groupIdentifier,
Expand All @@ -30,14 +34,22 @@ const withAppClip: ConfigPlugin<{
appleSignin,
applePayMerchantIds,
excludedPackages,
pushNotifications,
} = {},
) => {
name ??= "Clip";
bundleIdSuffix ??= "Clip";
targetSuffix ??= "Clip";
deploymentTarget ??= "15.1";
requestEphemeralUserNotification ??= false;
requestLocationConfirmation ??= false;
appleSignin ??= false;
enabled ??= true;
pushNotifications ??= false;

if(!enabled) {
return config;
}
if (!config.ios?.bundleIdentifier) {
throw new Error("No bundle identifier specified in app config");
}
Expand All @@ -46,13 +58,14 @@ const withAppClip: ConfigPlugin<{
const targetName = `${IOSConfig.XcodeUtils.sanitizedName(config.name)}${targetSuffix}`;

const modifiedConfig = withPlugins(config, [
withDeviceFamily as ConfigPlugin,
[
withConfig,
{ targetName, bundleIdentifier, appleSignin, applePayMerchantIds },
{ targetName, bundleIdentifier, appleSignin, applePayMerchantIds, pushNotifications },
],
[
withEntitlements,
{ targetName, groupIdentifier, appleSignin, applePayMerchantIds },
{ targetName, groupIdentifier, appleSignin, applePayMerchantIds, pushNotifications },
],
[withPodfile, { targetName, excludedPackages }],
[
Expand Down
7 changes: 7 additions & 0 deletions plugin/src/lib/getAppClipEntitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ export function getAppClipEntitlements(
groupIdentifier,
appleSignin,
applePayMerchantIds,
pushNotifications,
}: {
groupIdentifier?: string;
appleSignin: boolean;
applePayMerchantIds?: string[];
pushNotifications: boolean;
},
) {
const appBundleIdentifier = iosConfig?.bundleIdentifier;
Expand All @@ -31,6 +33,11 @@ export function getAppClipEntitlements(
entitlements["com.apple.developer.in-app-payments"] = applePayMerchantIds;
}

if (pushNotifications) {
// XCode automatically changes this to "production" when archiving https://stackoverflow.com/a/42293632/4047926
entitlements["aps-environment"] = "development";
}

if (iosConfig?.associatedDomains) {
entitlements["com.apple.developer.associated-domains"] =
iosConfig.associatedDomains;
Expand Down
4 changes: 3 additions & 1 deletion plugin/src/withConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ export const withConfig: ConfigPlugin<{
bundleIdentifier: string;
appleSignin: boolean;
applePayMerchantIds: string[];
pushNotifications: boolean;
}> = (
config,
{ targetName, bundleIdentifier, appleSignin, applePayMerchantIds },
{ targetName, bundleIdentifier, appleSignin, applePayMerchantIds, pushNotifications },
) => {
let configIndex: null | number = null;
config.extra?.eas?.build?.experimental?.ios?.appExtensions?.forEach(
Expand Down Expand Up @@ -56,6 +57,7 @@ export const withConfig: ConfigPlugin<{
appleSignin,
applePayMerchantIds,
// groupIdentifier, // Throws an error in EAS
pushNotifications,
}),
};
}
Expand Down
4 changes: 3 additions & 1 deletion plugin/src/withEntitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ export const withEntitlements: ConfigPlugin<{
groupIdentifier: string;
appleSignin: boolean;
applePayMerchantIds: string[];
pushNotifications: boolean;
}> = (
config,
{ targetName, groupIdentifier, appleSignin, applePayMerchantIds },
{ targetName, groupIdentifier, appleSignin, applePayMerchantIds, pushNotifications },
) => {
return withInfoPlist(config, (config) => {
const targetPath = path.join(
Expand All @@ -29,6 +30,7 @@ export const withEntitlements: ConfigPlugin<{
groupIdentifier,
appleSignin,
applePayMerchantIds,
pushNotifications,
});

fs.mkdirSync(path.dirname(filePath), { recursive: true });
Expand Down
10 changes: 9 additions & 1 deletion plugin/src/withPlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import {
import fs from "node:fs";
import path from "node:path";

/**
* List of keys that are not allowed in the Info.plist due to not being supported in App Clip. Add to this array as new build issues arise.
*/
const DisallowedPlistKeys = [
"UIBackgroundModes",
];

export const withPlist: ConfigPlugin<{
targetName: string;
deploymentTarget: string;
Expand Down Expand Up @@ -46,6 +53,7 @@ export const withPlist: ConfigPlugin<{
NSAllowsLocalNetworking: config.developmentClient,
},
CFBundleName: "$(PRODUCT_NAME)",
CFBundleDisplayName: "$(PRODUCT_NAME)",
CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)",
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)",
CFBundleExecutable: "$(EXECUTABLE_NAME)",
Expand All @@ -59,7 +67,7 @@ export const withPlist: ConfigPlugin<{

if (config.ios?.infoPlist) {
for (const key of Object.keys(config.ios?.infoPlist)) {
if (config.ios?.infoPlist) {
if (config.ios?.infoPlist && !DisallowedPlistKeys.some((disallowedKey) => disallowedKey === key)) {
infoPlist[key] = config.ios.infoPlist[key];
}
}
Expand Down
20 changes: 19 additions & 1 deletion plugin/src/withPodfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,25 @@ target '${targetName}' do
'ios'
]
end
config = use_native_modules!(config_command)
# Running the command in the same manner as \`use_react_native\` then running that result through our cliPlugin
json, _, status = Pod::Executable.capture_command(config_command[0], config_command[1..], capture: :both)
if not status.success?
Pod::UI.warn "The command: '#{config_command.join(" ").bold.yellow}' returned a status code of #{status.exitstatus.to_s.bold.red}", [
"App Clip autolinking failed. Please ensure autolinking works correctly for the main app target and try again.",
]
exit(status.exitstatus)
end
# \`react-native-app-clip\` resolves to react-native-app-clip/build/index.js
clip_command = [
'node',
'--no-warnings',
'--eval',
'require(require.resolve(\\'react-native-app-clip\\')+\\'/../../plugin/build/cliPlugin.js\\').run(' + json + ', [${(excludedPackages ?? []).map((packageName) => `"${packageName}"`).join(", ")}])'
]
config = use_native_modules!(clip_command)
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
Expand Down
2 changes: 1 addition & 1 deletion plugin/src/xcode/addBuildPhases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function addBuildPhases(
{
shellPath: "/bin/sh",
shellScript:
'if [[ -f "$PODS_ROOT/../.xcode.env" ]]; then\n source "$PODS_ROOT/../.xcode.env"\nfi\nif [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then\n source "$PODS_ROOT/../.xcode.env.local"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT="$PROJECT_DIR"/..\n\nif [[ "$CONFIGURATION" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z "$ENTRY_FILE" ]]; then\n # Set the entry JS file using the bundler\'s entry resolution.\n export ENTRY_FILE="$("$NODE_BINARY" -e "require(\'expo/scripts/resolveAppEntry\')" "$PROJECT_ROOT" ios relative | tail -n 1)"\nfi\n\nif [[ -z "$CLI_PATH" ]]; then\n # Use Expo CLI\n export CLI_PATH="$("$NODE_BINARY" --print "require.resolve(\'@expo/cli\')")"\nfi\nif [[ -z "$BUNDLE_COMMAND" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND="export:embed"\nfi\n\n`"$NODE_BINARY" --print "require(\'path\').dirname(require.resolve(\'react-native/package.json\')) + \'/scripts/react-native-xcode.sh\'"`\n\n',
'export BUILDING_FOR_APP_CLIP=1\nif [[ -f "$PODS_ROOT/../.xcode.env" ]]; then\n source "$PODS_ROOT/../.xcode.env"\nfi\nif [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then\n source "$PODS_ROOT/../.xcode.env.local"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT="$PROJECT_DIR"/..\n\nif [[ "$CONFIGURATION" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z "$ENTRY_FILE" ]]; then\n # Set the entry JS file using the bundler\'s entry resolution.\n export ENTRY_FILE="$("$NODE_BINARY" -e "require(\'expo/scripts/resolveAppEntry\')" "$PROJECT_ROOT" ios relative | tail -n 1)"\nfi\n\nif [[ -z "$CLI_PATH" ]]; then\n # Use Expo CLI\n export CLI_PATH="$("$NODE_BINARY" --print "require.resolve(\'@expo/cli\')")"\nfi\nif [[ -z "$BUNDLE_COMMAND" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND="export:embed"\nfi\n\n`"$NODE_BINARY" --print "require(\'path\').dirname(require.resolve(\'react-native/package.json\')) + \'/scripts/react-native-xcode.sh\'"`\n\n',
},
buildPath,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { requireNativeModule } from "expo-modules-core";
import { ReactNativeAppClipModuleType } from "./types";

// It loads the native module object from the JSI or falls back to
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
export default requireNativeModule("ReactNativeAppClip");
export default requireNativeModule<ReactNativeAppClipModuleType>(
"ReactNativeAppClip",
);
16 changes: 16 additions & 0 deletions src/ReactNativeAppClipModule/ReactNativeAppClipModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ReactNativeAppClipModuleType } from "./types";

const ReactNativeAppClipModule: ReactNativeAppClipModuleType = {
getContainerURL: (groupIdentifier: string) => {
return null;
},
getBundleIdentifier: () => {
return null;
},
displayOverlay: () => {},
setSharedCredential: () => {},
getSharedCredential: () => {
return null;
},
};
export default ReactNativeAppClipModule;
2 changes: 2 additions & 0 deletions src/ReactNativeAppClipModule/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import ReactNativeAppClipModule from "./ReactNativeAppClipModule";
export default ReactNativeAppClipModule;
9 changes: 9 additions & 0 deletions src/ReactNativeAppClipModule/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { GetBundleIdentiferFn } from "../getBundleIdentifier/types";

export interface ReactNativeAppClipModuleType {
getContainerURL: (groupIdentifier: string) => string | null;
getBundleIdentifier: GetBundleIdentiferFn;
displayOverlay: () => void;
setSharedCredential: (groupIdentifier: string, credential: string) => void;
getSharedCredential: (groupIdentifier: string) => string | null;
}
11 changes: 11 additions & 0 deletions src/getBundleIdentifier/getBundleIdentifier.ios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import ReactNativeAppClipModule from "../ReactNativeAppClipModule";
import { GetBundleIdentiferFn } from "./types";

let bundleIdentifier: string | null = null;
const getBundleIdentifier: GetBundleIdentiferFn = () => {
// Bundle identifier doesn't change during runtime
if (bundleIdentifier) return bundleIdentifier;
bundleIdentifier = ReactNativeAppClipModule.getBundleIdentifier();
return bundleIdentifier;
};
export default getBundleIdentifier;
6 changes: 6 additions & 0 deletions src/getBundleIdentifier/getBundleIdentifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { GetBundleIdentiferFn } from "./types";

const getBundleIdentifier: GetBundleIdentiferFn = () => {
return null;
};
export default getBundleIdentifier;
2 changes: 2 additions & 0 deletions src/getBundleIdentifier/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import getBundleIdentifier from "./getBundleIdentifier";
export default getBundleIdentifier;
1 change: 1 addition & 0 deletions src/getBundleIdentifier/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type GetBundleIdentiferFn = () => string | null;
Loading

0 comments on commit e15ea2f

Please sign in to comment.