1import { ExpoConfig } from '@expo/config-types'; 2import fs from 'fs'; 3import path from 'path'; 4import resolveFrom from 'resolve-from'; 5 6import { ConfigPlugin, InfoPlist } from '../Plugin.types'; 7import { createInfoPlistPlugin, withAppDelegate } from '../plugins/ios-plugins'; 8import { withDangerousMod } from '../plugins/withDangerousMod'; 9import { mergeContents, MergeResults, removeContents } from '../utils/generateCode'; 10 11const debug = require('debug')('expo:config-plugins:ios:maps') as typeof console.log; 12 13export const MATCH_INIT = 14 /-\s*\(BOOL\)\s*application:\s*\(UIApplication\s*\*\s*\)\s*\w+\s+didFinishLaunchingWithOptions:/g; 15 16const withGoogleMapsKey = createInfoPlistPlugin(setGoogleMapsApiKey, 'withGoogleMapsKey'); 17 18export const withMaps: ConfigPlugin = (config) => { 19 config = withGoogleMapsKey(config); 20 21 const apiKey = getGoogleMapsApiKey(config); 22 // Technically adds react-native-maps (Apple maps) and google maps. 23 24 debug('Google Maps API Key:', apiKey); 25 config = withMapsCocoaPods(config, { useGoogleMaps: !!apiKey }); 26 27 // Adds/Removes AppDelegate setup for Google Maps API on iOS 28 config = withGoogleMapsAppDelegate(config, { apiKey }); 29 30 return config; 31}; 32 33export function getGoogleMapsApiKey(config: Pick<ExpoConfig, 'ios'>) { 34 return config.ios?.config?.googleMapsApiKey ?? null; 35} 36 37export function setGoogleMapsApiKey( 38 config: Pick<ExpoConfig, 'ios'>, 39 { GMSApiKey, ...infoPlist }: InfoPlist 40): InfoPlist { 41 const apiKey = getGoogleMapsApiKey(config); 42 43 if (apiKey === null) { 44 return infoPlist; 45 } 46 47 return { 48 ...infoPlist, 49 GMSApiKey: apiKey, 50 }; 51} 52 53export function addGoogleMapsAppDelegateImport(src: string): MergeResults { 54 const newSrc = []; 55 newSrc.push( 56 '#if __has_include(<GoogleMaps/GoogleMaps.h>)', 57 '#import <GoogleMaps/GoogleMaps.h>', 58 '#endif' 59 ); 60 61 return mergeContents({ 62 tag: 'react-native-maps-import', 63 src, 64 newSrc: newSrc.join('\n'), 65 anchor: /#import "AppDelegate\.h"/, 66 offset: 1, 67 comment: '//', 68 }); 69} 70 71export function removeGoogleMapsAppDelegateImport(src: string): MergeResults { 72 return removeContents({ 73 tag: 'react-native-maps-import', 74 src, 75 }); 76} 77 78export function addGoogleMapsAppDelegateInit(src: string, apiKey: string): MergeResults { 79 const newSrc = []; 80 newSrc.push( 81 '#if __has_include(<GoogleMaps/GoogleMaps.h>)', 82 ` [GMSServices provideAPIKey:@"${apiKey}"];`, 83 '#endif' 84 ); 85 86 return mergeContents({ 87 tag: 'react-native-maps-init', 88 src, 89 newSrc: newSrc.join('\n'), 90 anchor: MATCH_INIT, 91 offset: 2, 92 comment: '//', 93 }); 94} 95 96export function removeGoogleMapsAppDelegateInit(src: string): MergeResults { 97 return removeContents({ 98 tag: 'react-native-maps-init', 99 src, 100 }); 101} 102 103/** 104 * @param src The contents of the Podfile. 105 * @returns Podfile with Google Maps added. 106 */ 107export function addMapsCocoaPods(src: string): MergeResults { 108 return mergeContents({ 109 tag: 'react-native-maps', 110 src, 111 newSrc: ` pod 'react-native-google-maps', path: File.dirname(\`node --print "require.resolve('react-native-maps/package.json')"\`)`, 112 anchor: /use_native_modules/, 113 offset: 0, 114 comment: '#', 115 }); 116} 117 118export function removeMapsCocoaPods(src: string): MergeResults { 119 return removeContents({ 120 tag: 'react-native-maps', 121 src, 122 }); 123} 124 125function isReactNativeMapsInstalled(projectRoot: string): string | null { 126 const resolved = resolveFrom.silent(projectRoot, 'react-native-maps/package.json'); 127 return resolved ? path.dirname(resolved) : null; 128} 129 130function isReactNativeMapsAutolinked(config: Pick<ExpoConfig, '_internal'>): boolean { 131 // Only add the native code changes if we know that the package is going to be linked natively. 132 // This is specifically for monorepo support where one app might have react-native-maps (adding it to the node_modules) 133 // but another app will not have it installed in the package.json, causing it to not be linked natively. 134 // This workaround only exists because react-native-maps doesn't have a config plugin vendored in the package. 135 136 // TODO: `react-native-maps` doesn't use Expo autolinking so we cannot safely disable the module. 137 return true; 138 139 // return ( 140 // !config._internal?.autolinkedModules || 141 // config._internal.autolinkedModules.includes('react-native-maps') 142 // ); 143} 144 145const withMapsCocoaPods: ConfigPlugin<{ useGoogleMaps: boolean }> = (config, { useGoogleMaps }) => { 146 return withDangerousMod(config, [ 147 'ios', 148 async (config) => { 149 const filePath = path.join(config.modRequest.platformProjectRoot, 'Podfile'); 150 const contents = await fs.promises.readFile(filePath, 'utf-8'); 151 let results: MergeResults; 152 // Only add the block if react-native-maps is installed in the project (best effort). 153 // Generally prebuild runs after a yarn install so this should always work as expected. 154 const googleMapsPath = isReactNativeMapsInstalled(config.modRequest.projectRoot); 155 const isLinked = isReactNativeMapsAutolinked(config); 156 debug('Is Expo Autolinked:', isLinked); 157 debug('react-native-maps path:', googleMapsPath); 158 if (isLinked && googleMapsPath && useGoogleMaps) { 159 try { 160 results = addMapsCocoaPods(contents); 161 } catch (error: any) { 162 if (error.code === 'ERR_NO_MATCH') { 163 throw new Error( 164 `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.` 165 ); 166 } 167 throw error; 168 } 169 } else { 170 // If the package is no longer installed, then remove the block. 171 results = removeMapsCocoaPods(contents); 172 } 173 if (results.didMerge || results.didClear) { 174 await fs.promises.writeFile(filePath, results.contents); 175 } 176 return config; 177 }, 178 ]); 179}; 180 181const withGoogleMapsAppDelegate: ConfigPlugin<{ apiKey: string | null }> = (config, { apiKey }) => { 182 return withAppDelegate(config, (config) => { 183 if (['objc', 'objcpp'].includes(config.modResults.language)) { 184 if ( 185 apiKey && 186 isReactNativeMapsAutolinked(config) && 187 isReactNativeMapsInstalled(config.modRequest.projectRoot) 188 ) { 189 try { 190 config.modResults.contents = addGoogleMapsAppDelegateImport( 191 config.modResults.contents 192 ).contents; 193 config.modResults.contents = addGoogleMapsAppDelegateInit( 194 config.modResults.contents, 195 apiKey 196 ).contents; 197 } catch (error: any) { 198 if (error.code === 'ERR_NO_MATCH') { 199 throw new Error( 200 `Cannot add Google Maps to the project's AppDelegate because it's malformed. Please report this with a copy of your project AppDelegate.` 201 ); 202 } 203 throw error; 204 } 205 } else { 206 config.modResults.contents = removeGoogleMapsAppDelegateImport( 207 config.modResults.contents 208 ).contents; 209 config.modResults.contents = removeGoogleMapsAppDelegateInit( 210 config.modResults.contents 211 ).contents; 212 } 213 } else { 214 throw new Error( 215 `Cannot setup Google Maps because the project AppDelegate is not a supported language: ${config.modResults.language}` 216 ); 217 } 218 return config; 219 }); 220}; 221