diff --git a/packages/react-native/React/Base/RCTUtils.h b/packages/react-native/React/Base/RCTUtils.h index 95b49b79a417..be9574c9167d 100644 --- a/packages/react-native/React/Base/RCTUtils.h +++ b/packages/react-native/React/Base/RCTUtils.h @@ -139,6 +139,11 @@ RCT_EXTERN NSData *__nullable RCTDecompressGzipData(NSData *__nullable data, NSU // (or nil, if the URL does not specify a path within the main bundle) RCT_EXTERN NSString *__nullable RCTBundlePathForURL(NSURL *__nullable URL); +// Returns the asset catalog image name for a packager asset URL, or nil if the +// URL is not a main-bundle packager asset. The name matches the identifier the +// CLI uses when generating the catalog (see assetPathUtils.getResourceIdentifier). +RCT_EXTERN NSString *__nullable RCTAssetCatalogNameForURL(NSURL *__nullable URL); + // Returns the Path of Library directory RCT_EXTERN NSString *__nullable RCTLibraryPath(void); diff --git a/packages/react-native/React/Base/RCTUtils.mm b/packages/react-native/React/Base/RCTUtils.mm index 60e2cb19cbec..2d4cfa0872f0 100644 --- a/packages/react-native/React/Base/RCTUtils.mm +++ b/packages/react-native/React/Base/RCTUtils.mm @@ -883,10 +883,25 @@ BOOL RCTIsGzippedData(NSData *__nullable data) return RCTRelativePathForURL(RCTHomePath(), URL); } +static BOOL RCTUseAssetCatalog(void); + +// Image types the CLI emits into the asset catalog (see isCatalogAsset in +// @react-native/community-cli-plugin). +static BOOL RCTIsCatalogImagePath(NSString *path) +{ + NSString *extension = [path pathExtension]; + return [extension isEqualToString:@"png"] || [extension isEqualToString:@"jpg"] || + [extension isEqualToString:@"jpeg"]; +} + static BOOL RCTIsImageAssetsPath(NSString *path) { NSString *extension = [path pathExtension]; - return [extension isEqualToString:@"png"] || [extension isEqualToString:@"jpg"]; + // jpeg is only routed through the bundle-asset loaders when the asset catalog + // serves it; apps that have not opted in keep the previous behavior (jpeg + // handled by the file request handler). + return [extension isEqualToString:@"png"] || [extension isEqualToString:@"jpg"] || + (RCTUseAssetCatalog() && [extension isEqualToString:@"jpeg"]); } BOOL RCTIsBundleAssetURL(NSURL *__nullable imageURL) @@ -939,6 +954,113 @@ BOOL RCTIsLocalAssetURL(NSURL *__nullable imageURL) return bundleCache[key]; } +static BOOL RCTUseAssetCatalog(void) +{ + static BOOL useAssetCatalog = NO; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + useAssetCatalog = [[[NSBundle mainBundle] objectForInfoDictionaryKey:@"RCTUseAssetCatalog"] boolValue]; + }); + return useAssetCatalog; +} + +// The bundle react-native-xcode.sh compiles packager image assets into +// (RNAssets.bundle, an actool-compiled asset catalog inside the app). +static NSBundle *__nullable RCTAssetCatalogBundle(void) +{ + static NSBundle *bundle; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSURL *bundleURL = [[NSBundle mainBundle] URLForResource:@"RNAssets" withExtension:@"bundle"]; + if (bundleURL != nil) { + bundle = [NSBundle bundleWithURL:bundleURL]; + } + }); + return bundle; +} + +NSString *__nullable RCTAssetCatalogNameForURL(NSURL *__nullable URL) +{ + // The "assets/" prefix the packager uses for all image assets. The CLI strips + // it from the identifiers it names the imagesets with. + const NSUInteger assetsPrefixLength = 7; + + NSString *path = RCTBundlePathForURL(URL); + // Packager assets always live under "assets/". Anything else (sub-bundles, + // CodePush/OTA assets outside the main bundle) is not in the catalog. + if (path == nil || ![path hasPrefix:@"assets/"]) { + return nil; + } + + // Other packager assets (gif, webp, ...) are copied as plain files and must + // use the regular loader. + if (!RCTIsCatalogImagePath(path)) { + return nil; + } + + // File system paths come back decomposed (NFD); restore the precomposed form + // the packager saw on disk so non-ASCII characters filter out the same way + // they do in the CLI's identifier. + path = path.precomposedStringWithCanonicalMapping; + + const NSUInteger length = path.length; + unichar stackBuffer[256]; + unichar *chars = length <= 256 ? stackBuffer : (unichar *)malloc(length * sizeof(unichar)); + [path getCharacters:chars range:NSMakeRange(0, length)]; + + // Strip the file extension (guaranteed present by RCTIsImageAssetsPath) and + // an optional "@x" suffix (integer or fractional, e.g. "@2x", + // "@1.5x"). The catalog stores a single imageset per image and resolves the + // scale by name at runtime, see + // https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs + NSUInteger end = length - 1; + while (end > assetsPrefixLength && chars[end] != '.') { + end--; + } + if (end > assetsPrefixLength && chars[end - 1] == 'x') { + // Walk back over "@(.)?" ending at the "x". + NSUInteger cursor = end - 1; + while (cursor > assetsPrefixLength && chars[cursor - 1] >= '0' && chars[cursor - 1] <= '9') { + cursor--; + } + if (cursor < end - 1) { + if (chars[cursor - 1] == '@') { + end = cursor - 1; + } else if (chars[cursor - 1] == '.') { + NSUInteger integerPart = cursor - 1; + while (integerPart > assetsPrefixLength && chars[integerPart - 1] >= '0' && chars[integerPart - 1] <= '9') { + integerPart--; + } + if (integerPart < cursor - 1 && chars[integerPart - 1] == '@') { + end = integerPart - 1; + } + } + } + } + + // Build the identifier in place in a single pass: skip the "assets/" prefix, + // lowercase, encode the folder structure with "_" and drop anything that is + // not a valid identifier character, producing the same identifier as + // getResourceIdentifier in the CLI, which names the imagesets. + NSUInteger resultLength = 0; + for (NSUInteger i = assetsPrefixLength; i < end; i++) { + unichar c = chars[i]; + if (c == '/') { + chars[resultLength++] = '_'; + } else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { + chars[resultLength++] = c; + } else if (c >= 'A' && c <= 'Z') { + chars[resultLength++] = c + ('a' - 'A'); + } + } + + NSString *name = [NSString stringWithCharacters:chars length:resultLength]; + if (chars != stackBuffer) { + free(chars); + } + return name; +} + UIImage *__nullable RCTImageFromLocalBundleAssetURL(NSURL *imageURL) { if (![imageURL.scheme isEqualToString:@"file"]) { @@ -955,6 +1077,37 @@ BOOL RCTIsLocalAssetURL(NSURL *__nullable imageURL) UIImage *__nullable RCTImageFromLocalAssetURL(NSURL *imageURL) { + if (RCTUseAssetCatalog()) { + NSString *catalogName = RCTAssetCatalogNameForURL(imageURL); + if (catalogName != nil) { + // The app opted into the asset catalog and this is a packager asset, so it + // was compiled into RNAssets.bundle at build time. Trust the catalog and + // return directly, keeping the common path a single lookup with no + // filesystem fallback. Non-catalog assets (nil name) fall through below. + NSBundle *assetCatalogBundle = RCTAssetCatalogBundle(); + if (assetCatalogBundle == nil) { + // Passing a nil bundle to imageNamed:inBundle: would silently search the + // main bundle instead, potentially resolving an unrelated app image. + RCTLogError( + @"RCTUseAssetCatalog is enabled but RNAssets.bundle was not found in the app. Image assets must be " + "bundled by react-native-xcode.sh, which compiles them into the app at build time. (loading %@)", + imageURL); + return nil; + } + UIImage *image = [UIImage imageNamed:catalogName + inBundle:assetCatalogBundle + compatibleWithTraitCollection:nil]; + if (image == nil) { + RCTLogError( + @"Image \"%@\" (%@) was not found in the asset catalog. RCTUseAssetCatalog is enabled, " + "so image assets must be compiled into the app's RNAssets.bundle at build time.", + catalogName, + imageURL); + } + return image; + } + } + NSString *imageName = RCTBundlePathForURL(imageURL); NSBundle *bundle = nil; diff --git a/packages/react-native/scripts/react-native-xcode.sh b/packages/react-native/scripts/react-native-xcode.sh index 1dcdf55e1902..b46cf2954524 100755 --- a/packages/react-native/scripts/react-native-xcode.sh +++ b/packages/react-native/scripts/react-native-xcode.sh @@ -151,6 +151,14 @@ else EXTRA_ARGS+=("--config-cmd" "'$NODE_BINARY' $NODE_ARGS '$REACT_NATIVE_DIR/cli.js' config") fi +# iOS asset catalog: when the app opts in with the RCTUseAssetCatalog key in its +# Info.plist, emit image assets into an asset catalog compiled into +# RNAssets.bundle inside the app (see scripts/xcode/asset-catalog.sh). This also +# covers Mac Catalyst (BUNDLE_PLATFORM is forced to "ios" above). +# shellcheck source=/dev/null +source "$REACT_NATIVE_DIR/scripts/xcode/asset-catalog.sh" +asset_catalog_prepare + # shellcheck disable=SC2086 "$NODE_BINARY" $NODE_ARGS "$CLI_PATH" $BUNDLE_COMMAND \ $CONFIG_ARG \ @@ -163,6 +171,8 @@ fi "${EXTRA_ARGS[@]}" \ $EXTRA_PACKAGER_ARGS +asset_catalog_compile + if [[ $USE_HERMES == false ]]; then cp "$BUNDLE_FILE" "$DEST/" BUNDLE_FILE="$DEST/$BUNDLE_NAME.jsbundle" diff --git a/packages/react-native/scripts/xcode/asset-catalog.sh b/packages/react-native/scripts/xcode/asset-catalog.sh new file mode 100644 index 000000000000..f6754b06a70b --- /dev/null +++ b/packages/react-native/scripts/xcode/asset-catalog.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +# Bundles packager image assets into a compiled asset catalog (RNAssets.bundle) +# inside the app, where the native image loader resolves them by name. Sourced +# by react-native-xcode.sh. +# +# The feature is opt-in via the RCTUseAssetCatalog key in the app's Info.plist, +# which is the same key the native image loader reads, so bundling and runtime +# cannot disagree on where image assets live. The catalog is fully owned by +# these functions (staged in derived files, compiled with actool into the app's +# resources next to the js bundle), so the app's Xcode project needs no +# changes. Apps that have not migrated are unaffected. +# +# After changing the RCTUseAssetCatalog key, do a clean build: incremental +# builds do not remove image assets a previous build placed in the app with the +# other setting (they are unused but add dead weight). + +# Decides whether the app opted in and stages an empty catalog for the bundle +# command to emit imagesets into. Sets USE_ASSET_CATALOG and +# ASSET_CATALOG_STAGING_DIR, and appends --asset-catalog-dest to EXTRA_ARGS. +asset_catalog_prepare() { + USE_ASSET_CATALOG="" + if [[ "$BUNDLE_PLATFORM" == "ios" && -n "$PRODUCT_SETTINGS_PATH" ]]; then + USE_ASSET_CATALOG="$(/usr/libexec/PlistBuddy -c 'Print :RCTUseAssetCatalog' "$PRODUCT_SETTINGS_PATH" 2>/dev/null || true)" + # Accept the value forms NSBundle's boolValue treats as true, so this check + # cannot disagree with the native runtime check. + case "$(echo "$USE_ASSET_CATALOG" | tr '[:upper:]' '[:lower:]')" in + true | yes | 1) USE_ASSET_CATALOG="true" ;; + *) USE_ASSET_CATALOG="" ;; + esac + if [[ "$USE_ASSET_CATALOG" == "true" ]]; then + ASSET_CATALOG_STAGING_DIR="${DERIVED_FILE_DIR:-$(mktemp -d)}/rn-assets" + rm -rf "$ASSET_CATALOG_STAGING_DIR" + mkdir -p "$ASSET_CATALOG_STAGING_DIR/RNAssets.xcassets" + EXTRA_ARGS+=("--asset-catalog-dest" "$ASSET_CATALOG_STAGING_DIR") + fi + fi +} + +# Compiles the staged catalog into RNAssets.bundle inside the app. The compiled +# Assets.car resolves the right scale by name at runtime, see +# https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs +asset_catalog_compile() { + local rn_assets_bundle="$DEST/RNAssets.bundle" + # Always remove first so a stale bundle from a previous build does not ship + # when the app opts out or has no image assets. + rm -rf "$rn_assets_bundle" + if [[ "$USE_ASSET_CATALOG" != "true" ]]; then + return + fi + if [[ -z "$(find "$ASSET_CATALOG_STAGING_DIR/RNAssets.xcassets" -maxdepth 1 -name '*.imageset' -print -quit)" ]]; then + return + fi + + mkdir -p "$rn_assets_bundle" + local actool_args=("--platform" "${PLATFORM_NAME:-iphoneos}") + # These are always iOS-family image assets (this runs only for BUNDLE_PLATFORM + # "ios", which includes Mac Catalyst), so the deployment target must be an iOS + # version. On Catalyst the platform is macosx but MACOSX_DEPLOYMENT_TARGET is a + # macOS version (e.g. 10.15); passing that as the target makes actool silently + # emit loose files instead of a compiled Assets.car, so it must not be used. + actool_args+=("--minimum-deployment-target" "${IPHONEOS_DEPLOYMENT_TARGET:-15.1}") + case "${TARGETED_DEVICE_FAMILY:-1}" in *1*) actool_args+=("--target-device" "iphone") ;; esac + case "${TARGETED_DEVICE_FAMILY:-1}" in *2*) actool_args+=("--target-device" "ipad") ;; esac + if [[ "${IS_MACCATALYST:-NO}" == "YES" ]]; then + actool_args+=("--ui-framework-family" "uikit") + fi + + # Surface actool diagnostics in the build log: without these flags actool + # suppresses them entirely, and it exits 0 even when it drops an imageset. + local actool_output + actool_output="$(xcrun actool "$ASSET_CATALOG_STAGING_DIR/RNAssets.xcassets" \ + --compile "$rn_assets_bundle" \ + --output-format human-readable-text \ + --errors --warnings --notices \ + "${actool_args[@]}" 2>&1)" || true + echo "$actool_output" + if [[ ! -f "$rn_assets_bundle/Assets.car" ]]; then + echo "error: failed to compile image assets into RNAssets.bundle. See actool output above." >&2 + exit 2 + fi + + cat > "$rn_assets_bundle/Info.plist" <<'RN_ASSETS_PLIST' + + + + + CFBundleIdentifier + org.reactjs.RNAssets + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + RNAssets + CFBundlePackageType + BNDL + + +RN_ASSETS_PLIST +} diff --git a/packages/rn-tester/RNTester/Info.plist b/packages/rn-tester/RNTester/Info.plist index 373b8322fdc7..b785e4857907 100644 --- a/packages/rn-tester/RNTester/Info.plist +++ b/packages/rn-tester/RNTester/Info.plist @@ -48,6 +48,8 @@ You need to add NSPhotoLibraryUsageDescription key in Info.plist to enable photo library usage, otherwise it is going to *fail silently*! RCTNewArchEnabled + RCTUseAssetCatalog + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities diff --git a/packages/rn-tester/RNTesterUnitTests/RCTURLUtilsTests.m b/packages/rn-tester/RNTesterUnitTests/RCTURLUtilsTests.m index 318ed4890381..7128377b5de1 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTURLUtilsTests.m +++ b/packages/rn-tester/RNTesterUnitTests/RCTURLUtilsTests.m @@ -100,4 +100,97 @@ - (void)testIsLocalAssetsURLParam XCTAssertFalse(RCTIsLocalAssetURL(otherAssetsURL)); } +- (void)testAssetCatalogNameForURL +{ + NSString *resourcePath = [[NSBundle mainBundle] resourcePath]; + + // Nested folder + "@2x" scale suffix: folders are encoded into the name, the + // scale suffix and extension are stripped, and the "assets_" prefix removed. + NSURL *scaledURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/icon@2x.png"]]; + XCTAssertEqualObjects(RCTAssetCatalogNameForURL(scaledURL), @"awesomemodule_icon"); + + // Same asset without a scale suffix resolves to the same name. + NSURL *unscaledURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/icon.png"]]; + XCTAssertEqualObjects(RCTAssetCatalogNameForURL(unscaledURL), @"awesomemodule_icon"); + + // Illegal characters (e.g. "-") are stripped, matching the CLI identifier. + NSURL *illegalCharsURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/my-module/my-icon@3x.png"]]; + XCTAssertEqualObjects(RCTAssetCatalogNameForURL(illegalCharsURL), @"mymodule_myicon"); + + // Non-integer scale suffixes are also stripped. + NSURL *fractionalScaleURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/icon@1.5x.png"]]; + XCTAssertEqualObjects(RCTAssetCatalogNameForURL(fractionalScaleURL), @"awesomemodule_icon"); + + // Fractional scale with a leading zero (the packager emits e.g. "@0.5x"). + NSURL *zeroFractionalScaleURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/icon@0.5x.png"]]; + XCTAssertEqualObjects(RCTAssetCatalogNameForURL(zeroFractionalScaleURL), @"awesomemodule_icon"); + + // An uppercase "X" is not a scale suffix (the packager's scale format is + // case-sensitive), so it stays in the name like any other character. + NSURL *uppercaseScaleURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/icon@2X.png"]]; + XCTAssertEqualObjects(RCTAssetCatalogNameForURL(uppercaseScaleURL), @"awesomemodule_icon2x"); + + // Only the extension is stripped from a name containing dots; the inner dot + // is an illegal identifier character and is removed. + NSURL *multiDotURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/photo.small.png"]]; + XCTAssertEqualObjects(RCTAssetCatalogNameForURL(multiDotURL), @"awesomemodule_photosmall"); + + // Non-ASCII characters are not valid identifier characters and are removed. + // File system paths are decomposed (NFD), so this also verifies the name is + // normalized back to the precomposed form the CLI derived the identifier + // from ("ü" must not leave a stray "u" behind). + NSURL *unicodeURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/ünïcode.png"]]; + XCTAssertEqualObjects(RCTAssetCatalogNameForURL(unicodeURL), @"awesomemodule_ncode"); + + // Uppercase extensions are not catalog assets: the CLI's isCatalogAsset + // check is case-sensitive, so these are copied as plain files. + NSURL *uppercaseExtensionURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/Icon.PNG"]]; + XCTAssertNil(RCTAssetCatalogNameForURL(uppercaseExtensionURL)); + + // A packager asset without an image extension is not a catalog asset. + NSURL *noExtensionURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/data"]]; + XCTAssertNil(RCTAssetCatalogNameForURL(noExtensionURL)); + + // ".jpeg" is a catalog image type (matching the CLI's isCatalogAsset). + NSURL *jpegURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/photo@2x.jpeg"]]; + XCTAssertEqualObjects(RCTAssetCatalogNameForURL(jpegURL), @"awesomemodule_photo"); + + // Non-catalog image types (the CLI only emits png/jpg/jpeg into the catalog) + // are not catalog assets and use the regular loader. + NSURL *gifURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/anim.gif"]]; + XCTAssertNil(RCTAssetCatalogNameForURL(gifURL)); + + // Assets outside the project root are encoded with "_" by the packager + // (e.g. "assets/../../shared" -> "assets/__shared") and resolve to the same + // identifier the CLI generates. + NSURL *outOfRootURL = + [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/__shared/icon@2x.png"]]; + XCTAssertEqualObjects(RCTAssetCatalogNameForURL(outOfRootURL), @"__shared_icon"); + + // Query strings are ignored, same as the legacy loader. + NSURL *queryURL = [NSURL + URLWithString:[NSString stringWithFormat:@"file://%@/assets/AwesomeModule/icon@2x.png?platform=ios&hash=abc", + resourcePath]]; + XCTAssertEqualObjects(RCTAssetCatalogNameForURL(queryURL), @"awesomemodule_icon"); + + // A non-packager path (not under "assets/") is not a catalog asset. + NSURL *notPackagerURL = [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"icon.png"]]; + XCTAssertNil(RCTAssetCatalogNameForURL(notPackagerURL)); + + // A nil URL is handled gracefully. + XCTAssertNil(RCTAssetCatalogNameForURL(nil)); +} + @end diff --git a/private/helloworld/ios/HelloWorld.xcodeproj/project.pbxproj b/private/helloworld/ios/HelloWorld.xcodeproj/project.pbxproj index dca49419fb7c..b36116e685e8 100644 --- a/private/helloworld/ios/HelloWorld.xcodeproj/project.pbxproj +++ b/private/helloworld/ios/HelloWorld.xcodeproj/project.pbxproj @@ -265,7 +265,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n\nexport CONFIG_JSON=$(sed -e \"s|HELLOWORLD_PATH|$(realpath \"${SRCROOT}/../\")|g\" \"${SRCROOT}/../.react-native.config\")\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; + shellScript = "set -e\n\nexport CONFIG_JSON=$(sed -e \"s|HELLOWORLD_PATH|$(realpath \"${SRCROOT}/../\")|g\" -e \"s|REACT_NATIVE_PATH|$(realpath \"${REACT_NATIVE_PATH}\")|g\" \"${SRCROOT}/../.react-native.config\")\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; }; A44ED3CC3037C88F69E3AF15 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; diff --git a/private/helloworld/ios/HelloWorld/Info.plist b/private/helloworld/ios/HelloWorld/Info.plist index 07b050898d9d..a766cc2814e5 100644 --- a/private/helloworld/ios/HelloWorld/Info.plist +++ b/private/helloworld/ios/HelloWorld/Info.plist @@ -34,6 +34,8 @@ NSLocationWhenInUseUsageDescription + RCTUseAssetCatalog + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities