1import { ExpoConfig } from '@expo/config-types'; 2import plist, { PlistObject } from '@expo/plist'; 3import assert from 'assert'; 4import fs from 'fs'; 5import xcode, { XCBuildConfiguration } from 'xcode'; 6 7import { ConfigPlugin } from '../Plugin.types'; 8import { withDangerousMod } from '../plugins/withDangerousMod'; 9import { InfoPlist } from './IosConfig.types'; 10import { getAllInfoPlistPaths, getAllPBXProjectPaths, getPBXProjectPath } from './Paths'; 11import { findFirstNativeTarget, getXCBuildConfigurationFromPbxproj } from './Target'; 12import { ConfigurationSectionEntry, getBuildConfigurationsForListId } from './utils/Xcodeproj'; 13import { trimQuotes } from './utils/string'; 14 15export const withBundleIdentifier: ConfigPlugin<{ bundleIdentifier?: string }> = ( 16 config, 17 { bundleIdentifier } 18) => { 19 return withDangerousMod(config, [ 20 'ios', 21 async (config) => { 22 const bundleId = bundleIdentifier ?? config.ios?.bundleIdentifier; 23 assert( 24 bundleId, 25 '`bundleIdentifier` must be defined in the app config (`expo.ios.bundleIdentifier`) or passed to the plugin `withBundleIdentifier`.' 26 ); 27 await setBundleIdentifierForPbxproj(config.modRequest.projectRoot, bundleId!); 28 return config; 29 }, 30 ]); 31}; 32 33function getBundleIdentifier(config: Pick<ExpoConfig, 'ios'>): string | null { 34 return config.ios?.bundleIdentifier ?? null; 35} 36 37/** 38 * In Turtle v1 we set the bundleIdentifier directly on Info.plist rather 39 * than in pbxproj 40 */ 41function setBundleIdentifier(config: ExpoConfig, infoPlist: InfoPlist): InfoPlist { 42 const bundleIdentifier = getBundleIdentifier(config); 43 44 if (!bundleIdentifier) { 45 return infoPlist; 46 } 47 48 return { 49 ...infoPlist, 50 CFBundleIdentifier: bundleIdentifier, 51 }; 52} 53 54/** 55 * Gets the bundle identifier defined in the Xcode project found in the project directory. 56 * 57 * A bundle identifier is stored as a value in XCBuildConfiguration entry. 58 * Those entries exist for every pair (build target, build configuration). 59 * Unless target name is passed, the first target defined in the pbxproj is used 60 * (to keep compatibility with the inaccurate legacy implementation of this function). 61 * The build configuration is usually 'Release' or 'Debug'. However, it could be any arbitrary string. 62 * Defaults to 'Release'. 63 * 64 * @param {string} projectRoot Path to project root containing the ios directory 65 * @param {string} targetName Target name 66 * @param {string} buildConfiguration Build configuration. Defaults to 'Release'. 67 * @returns {string | null} bundle identifier of the Xcode project or null if the project is not configured 68 */ 69function getBundleIdentifierFromPbxproj( 70 projectRoot: string, 71 { 72 targetName, 73 buildConfiguration = 'Release', 74 }: { targetName?: string; buildConfiguration?: string } = {} 75): string | null { 76 let pbxprojPath: string; 77 try { 78 pbxprojPath = getPBXProjectPath(projectRoot); 79 } catch { 80 return null; 81 } 82 const project = xcode.project(pbxprojPath); 83 project.parseSync(); 84 85 const xcBuildConfiguration = getXCBuildConfigurationFromPbxproj(project, { 86 targetName, 87 buildConfiguration, 88 }); 89 if (!xcBuildConfiguration) { 90 return null; 91 } 92 return getProductBundleIdentifierFromBuildConfiguration(xcBuildConfiguration); 93} 94 95function getProductBundleIdentifierFromBuildConfiguration( 96 xcBuildConfiguration: XCBuildConfiguration 97): string | null { 98 const bundleIdentifierRaw = xcBuildConfiguration.buildSettings.PRODUCT_BUNDLE_IDENTIFIER; 99 if (bundleIdentifierRaw) { 100 const bundleIdentifier = trimQuotes(bundleIdentifierRaw); 101 // it's possible to use interpolation for the bundle identifier 102 // the most common case is when the last part of the id is set to `$(PRODUCT_NAME:rfc1034identifier)` 103 // in this case, PRODUCT_NAME should be replaced with its value 104 // the `rfc1034identifier` modifier replaces all non-alphanumeric characters with dashes 105 const bundleIdentifierParts = bundleIdentifier.split('.'); 106 if ( 107 bundleIdentifierParts[bundleIdentifierParts.length - 1] === 108 '$(PRODUCT_NAME:rfc1034identifier)' && 109 xcBuildConfiguration.buildSettings.PRODUCT_NAME 110 ) { 111 bundleIdentifierParts[bundleIdentifierParts.length - 1] = 112 xcBuildConfiguration.buildSettings.PRODUCT_NAME.replace(/[^a-zA-Z0-9]/g, '-'); 113 } 114 return bundleIdentifierParts.join('.'); 115 } else { 116 return null; 117 } 118} 119 120/** 121 * Updates the bundle identifier for a given pbxproj 122 * 123 * @param {string} pbxprojPath Path to pbxproj file 124 * @param {string} bundleIdentifier Bundle identifier to set in the pbxproj 125 * @param {boolean} [updateProductName=true] Whether to update PRODUCT_NAME 126 */ 127function updateBundleIdentifierForPbxproj( 128 pbxprojPath: string, 129 bundleIdentifier: string, 130 updateProductName: boolean = true 131): void { 132 const project = xcode.project(pbxprojPath); 133 project.parseSync(); 134 135 const [, nativeTarget] = findFirstNativeTarget(project); 136 137 getBuildConfigurationsForListId(project, nativeTarget.buildConfigurationList).forEach( 138 ([, item]: ConfigurationSectionEntry) => { 139 if (item.buildSettings.PRODUCT_BUNDLE_IDENTIFIER === bundleIdentifier) { 140 return; 141 } 142 143 item.buildSettings.PRODUCT_BUNDLE_IDENTIFIER = `"${bundleIdentifier}"`; 144 145 if (updateProductName) { 146 const productName = bundleIdentifier.split('.').pop(); 147 if (!productName?.includes('$')) { 148 item.buildSettings.PRODUCT_NAME = productName; 149 } 150 } 151 } 152 ); 153 fs.writeFileSync(pbxprojPath, project.writeSync()); 154} 155 156/** 157 * Updates the bundle identifier for pbx projects inside the ios directory of the given project root 158 * 159 * @param {string} projectRoot Path to project root containing the ios directory 160 * @param {string} bundleIdentifier Desired bundle identifier 161 * @param {boolean} [updateProductName=true] Whether to update PRODUCT_NAME 162 */ 163function setBundleIdentifierForPbxproj( 164 projectRoot: string, 165 bundleIdentifier: string, 166 updateProductName: boolean = true 167): void { 168 // Get all pbx projects in the ${projectRoot}/ios directory 169 let pbxprojPaths: string[] = []; 170 try { 171 pbxprojPaths = getAllPBXProjectPaths(projectRoot); 172 } catch {} 173 174 for (const pbxprojPath of pbxprojPaths) { 175 updateBundleIdentifierForPbxproj(pbxprojPath, bundleIdentifier, updateProductName); 176 } 177} 178 179/** 180 * Reset bundle identifier field in Info.plist to use PRODUCT_BUNDLE_IDENTIFIER, as recommended by Apple. 181 */ 182 183const defaultBundleId = '$(PRODUCT_BUNDLE_IDENTIFIER)'; 184 185function resetAllPlistBundleIdentifiers(projectRoot: string): void { 186 const infoPlistPaths = getAllInfoPlistPaths(projectRoot); 187 188 for (const plistPath of infoPlistPaths) { 189 resetPlistBundleIdentifier(plistPath); 190 } 191} 192 193function resetPlistBundleIdentifier(plistPath: string): void { 194 const rawPlist = fs.readFileSync(plistPath, 'utf8'); 195 const plistObject = plist.parse(rawPlist) as PlistObject; 196 197 if (plistObject.CFBundleIdentifier) { 198 if (plistObject.CFBundleIdentifier === defaultBundleId) return; 199 200 // attempt to match default Info.plist format 201 const format = { pretty: true, indent: `\t` }; 202 203 const xml = plist.build( 204 { 205 ...plistObject, 206 CFBundleIdentifier: defaultBundleId, 207 }, 208 format 209 ); 210 211 if (xml !== rawPlist) { 212 fs.writeFileSync(plistPath, xml); 213 } 214 } 215} 216 217export { 218 getBundleIdentifier, 219 setBundleIdentifier, 220 getBundleIdentifierFromPbxproj, 221 updateBundleIdentifierForPbxproj, 222 setBundleIdentifierForPbxproj, 223 resetAllPlistBundleIdentifiers, 224 resetPlistBundleIdentifier, 225}; 226