import { ExpoConfig } from '@expo/config-types'; import fs from 'fs'; import path from 'path'; import resolveFrom from 'resolve-from'; import { ConfigPlugin, InfoPlist } from '../Plugin.types'; import { createInfoPlistPlugin, withAppDelegate } from '../plugins/ios-plugins'; import { withDangerousMod } from '../plugins/withDangerousMod'; import { mergeContents, MergeResults, removeContents } from '../utils/generateCode'; const debug = require('debug')('expo:config-plugins:ios:maps') as typeof console.log; export const MATCH_INIT = /-\s*\(BOOL\)\s*application:\s*\(UIApplication\s*\*\s*\)\s*\w+\s+didFinishLaunchingWithOptions:/g; const withGoogleMapsKey = createInfoPlistPlugin(setGoogleMapsApiKey, 'withGoogleMapsKey'); export const withMaps: ConfigPlugin = (config) => { config = withGoogleMapsKey(config); const apiKey = getGoogleMapsApiKey(config); // Technically adds react-native-maps (Apple maps) and google maps. debug('Google Maps API Key:', apiKey); config = withMapsCocoaPods(config, { useGoogleMaps: !!apiKey }); // Adds/Removes AppDelegate setup for Google Maps API on iOS config = withGoogleMapsAppDelegate(config, { apiKey }); return config; }; export function getGoogleMapsApiKey(config: Pick) { return config.ios?.config?.googleMapsApiKey ?? null; } export function setGoogleMapsApiKey( config: Pick, { GMSApiKey, ...infoPlist }: InfoPlist ): InfoPlist { const apiKey = getGoogleMapsApiKey(config); if (apiKey === null) { return infoPlist; } return { ...infoPlist, GMSApiKey: apiKey, }; } export function addGoogleMapsAppDelegateImport(src: string): MergeResults { const newSrc = []; newSrc.push( '#if __has_include()', '#import ', '#endif' ); return mergeContents({ tag: 'react-native-maps-import', src, newSrc: newSrc.join('\n'), anchor: /#import "AppDelegate\.h"/, offset: 1, comment: '//', }); } export function removeGoogleMapsAppDelegateImport(src: string): MergeResults { return removeContents({ tag: 'react-native-maps-import', src, }); } export function addGoogleMapsAppDelegateInit(src: string, apiKey: string): MergeResults { const newSrc = []; newSrc.push( '#if __has_include()', ` [GMSServices provideAPIKey:@"${apiKey}"];`, '#endif' ); return mergeContents({ tag: 'react-native-maps-init', src, newSrc: newSrc.join('\n'), anchor: MATCH_INIT, offset: 2, comment: '//', }); } export function removeGoogleMapsAppDelegateInit(src: string): MergeResults { return removeContents({ tag: 'react-native-maps-init', src, }); } /** * @param src The contents of the Podfile. * @returns Podfile with Google Maps added. */ export function addMapsCocoaPods(src: string): MergeResults { return mergeContents({ tag: 'react-native-maps', src, newSrc: ` pod 'react-native-google-maps', path: File.dirname(\`node --print "require.resolve('react-native-maps/package.json')"\`)`, anchor: /use_native_modules/, offset: 0, comment: '#', }); } export function removeMapsCocoaPods(src: string): MergeResults { return removeContents({ tag: 'react-native-maps', src, }); } function isReactNativeMapsInstalled(projectRoot: string): string | null { const resolved = resolveFrom.silent(projectRoot, 'react-native-maps/package.json'); return resolved ? path.dirname(resolved) : null; } function isReactNativeMapsAutolinked(config: Pick): boolean { // Only add the native code changes if we know that the package is going to be linked natively. // This is specifically for monorepo support where one app might have react-native-maps (adding it to the node_modules) // but another app will not have it installed in the package.json, causing it to not be linked natively. // This workaround only exists because react-native-maps doesn't have a config plugin vendored in the package. // TODO: `react-native-maps` doesn't use Expo autolinking so we cannot safely disable the module. return true; // return ( // !config._internal?.autolinkedModules || // config._internal.autolinkedModules.includes('react-native-maps') // ); } const withMapsCocoaPods: ConfigPlugin<{ useGoogleMaps: boolean }> = (config, { useGoogleMaps }) => { return withDangerousMod(config, [ 'ios', async (config) => { const filePath = path.join(config.modRequest.platformProjectRoot, 'Podfile'); const contents = await fs.promises.readFile(filePath, 'utf-8'); let results: MergeResults; // Only add the block if react-native-maps is installed in the project (best effort). // Generally prebuild runs after a yarn install so this should always work as expected. const googleMapsPath = isReactNativeMapsInstalled(config.modRequest.projectRoot); const isLinked = isReactNativeMapsAutolinked(config); debug('Is Expo Autolinked:', isLinked); debug('react-native-maps path:', googleMapsPath); if (isLinked && googleMapsPath && useGoogleMaps) { try { results = addMapsCocoaPods(contents); } catch (error: any) { if (error.code === 'ERR_NO_MATCH') { throw new Error( `Cannot add react-native-maps to the project's ios/Podfile because it's malformed. Please report this with a copy of your project Podfile.` ); } throw error; } } else { // If the package is no longer installed, then remove the block. results = removeMapsCocoaPods(contents); } if (results.didMerge || results.didClear) { await fs.promises.writeFile(filePath, results.contents); } return config; }, ]); }; const withGoogleMapsAppDelegate: ConfigPlugin<{ apiKey: string | null }> = (config, { apiKey }) => { return withAppDelegate(config, (config) => { if (['objc', 'objcpp'].includes(config.modResults.language)) { if ( apiKey && isReactNativeMapsAutolinked(config) && isReactNativeMapsInstalled(config.modRequest.projectRoot) ) { try { config.modResults.contents = addGoogleMapsAppDelegateImport( config.modResults.contents ).contents; config.modResults.contents = addGoogleMapsAppDelegateInit( config.modResults.contents, apiKey ).contents; } catch (error: any) { if (error.code === 'ERR_NO_MATCH') { throw new Error( `Cannot add Google Maps to the project's AppDelegate because it's malformed. Please report this with a copy of your project AppDelegate.` ); } throw error; } } else { config.modResults.contents = removeGoogleMapsAppDelegateImport( config.modResults.contents ).contents; config.modResults.contents = removeGoogleMapsAppDelegateInit( config.modResults.contents ).contents; } } else { throw new Error( `Cannot setup Google Maps because the project AppDelegate is not a supported language: ${config.modResults.language}` ); } return config; }); };