1import { ExpoConfig } from '@expo/config-types'; 2import assert from 'assert'; 3import path from 'path'; 4import slugify from 'slugify'; 5import xcode, { 6 PBXFile, 7 PBXGroup, 8 PBXNativeTarget, 9 PBXProject, 10 UUID, 11 XCBuildConfiguration, 12 XCConfigurationList, 13 XcodeProject, 14} from 'xcode'; 15import pbxFile from 'xcode/lib/pbxFile'; 16 17import { trimQuotes } from './string'; 18import { addWarningIOS } from '../../utils/warnings'; 19import * as Paths from '../Paths'; 20 21export type ProjectSectionEntry = [string, PBXProject]; 22 23export type NativeTargetSection = Record<string, PBXNativeTarget>; 24 25export type NativeTargetSectionEntry = [string, PBXNativeTarget]; 26 27export type ConfigurationLists = Record<string, XCConfigurationList>; 28 29export type ConfigurationListEntry = [string, XCConfigurationList]; 30 31export type ConfigurationSectionEntry = [string, XCBuildConfiguration]; 32 33export function getProjectName(projectRoot: string) { 34 const sourceRoot = Paths.getSourceRoot(projectRoot); 35 return path.basename(sourceRoot); 36} 37 38export function resolvePathOrProject( 39 projectRootOrProject: string | XcodeProject 40): XcodeProject | null { 41 if (typeof projectRootOrProject === 'string') { 42 try { 43 return getPbxproj(projectRootOrProject); 44 } catch { 45 return null; 46 } 47 } 48 return projectRootOrProject; 49} 50 51// TODO: come up with a better solution for using app.json expo.name in various places 52export function sanitizedName(name: string) { 53 // Default to the name `app` when every safe character has been sanitized 54 return sanitizedNameForProjects(name) || sanitizedNameForProjects(slugify(name)) || 'app'; 55} 56 57function sanitizedNameForProjects(name: string) { 58 return name 59 .replace(/[\W_]+/g, '') 60 .normalize('NFD') 61 .replace(/[\u0300-\u036f]/g, ''); 62} 63 64// TODO: it's silly and kind of fragile that we look at app config to determine 65// the ios project paths. Overall this function needs to be revamped, just a 66// placeholder for now! Make this more robust when we support applying config 67// at any time (currently it's only applied on eject). 68export function getHackyProjectName(projectRoot: string, config: ExpoConfig): string { 69 // Attempt to get the current ios folder name (apply). 70 try { 71 return getProjectName(projectRoot); 72 } catch { 73 // If no iOS project exists then create a new one (eject). 74 const projectName = config.name; 75 assert(projectName, 'Your project needs a name in app.json/app.config.js.'); 76 return sanitizedName(projectName); 77 } 78} 79 80function createProjectFileForGroup({ filepath, group }: { filepath: string; group: PBXGroup }) { 81 const file = new pbxFile(filepath); 82 83 const conflictingFile = group.children.find((child) => child.comment === file.basename); 84 if (conflictingFile) { 85 // This can happen when a file like the GoogleService-Info.plist needs to be added and the eject command is run twice. 86 // Not much we can do here since it might be a conflicting file. 87 return null; 88 } 89 return file; 90} 91 92/** 93 * Add a resource file (ex: `SplashScreen.storyboard`, `Images.xcassets`) to an Xcode project. 94 * This is akin to creating a new code file in Xcode with `⌘+n`. 95 */ 96export function addResourceFileToGroup({ 97 filepath, 98 groupName, 99 // Should add to `PBXBuildFile Section` 100 isBuildFile, 101 project, 102 verbose, 103 targetUuid, 104}: { 105 filepath: string; 106 groupName: string; 107 isBuildFile?: boolean; 108 project: XcodeProject; 109 verbose?: boolean; 110 targetUuid?: string; 111}): XcodeProject { 112 return addFileToGroupAndLink({ 113 filepath, 114 groupName, 115 project, 116 verbose, 117 targetUuid, 118 addFileToProject({ project, file }) { 119 project.addToPbxFileReferenceSection(file); 120 if (isBuildFile) { 121 project.addToPbxBuildFileSection(file); 122 } 123 project.addToPbxResourcesBuildPhase(file); 124 }, 125 }); 126} 127 128/** 129 * Add a build source file (ex: `AppDelegate.m`, `ViewController.swift`) to an Xcode project. 130 * This is akin to creating a new code file in Xcode with `⌘+n`. 131 */ 132export function addBuildSourceFileToGroup({ 133 filepath, 134 groupName, 135 project, 136 verbose, 137 targetUuid, 138}: { 139 filepath: string; 140 groupName: string; 141 project: XcodeProject; 142 verbose?: boolean; 143 targetUuid?: string; 144}): XcodeProject { 145 return addFileToGroupAndLink({ 146 filepath, 147 groupName, 148 project, 149 verbose, 150 targetUuid, 151 addFileToProject({ project, file }) { 152 project.addToPbxFileReferenceSection(file); 153 project.addToPbxBuildFileSection(file); 154 project.addToPbxSourcesBuildPhase(file); 155 }, 156 }); 157} 158 159// TODO(brentvatne): I couldn't figure out how to do this with an existing 160// higher level function exposed by the xcode library, but we should find out how to do 161// that and replace this with it 162export function addFileToGroupAndLink({ 163 filepath, 164 groupName, 165 project, 166 verbose, 167 addFileToProject, 168 targetUuid, 169}: { 170 filepath: string; 171 groupName: string; 172 project: XcodeProject; 173 verbose?: boolean; 174 targetUuid?: string; 175 addFileToProject: (props: { file: PBXFile; project: XcodeProject }) => void; 176}): XcodeProject { 177 const group = pbxGroupByPathOrAssert(project, groupName); 178 179 const file = createProjectFileForGroup({ filepath, group }); 180 181 if (!file) { 182 if (verbose) { 183 // This can happen when a file like the GoogleService-Info.plist needs to be added and the eject command is run twice. 184 // Not much we can do here since it might be a conflicting file. 185 addWarningIOS( 186 'ios-xcode-project', 187 `Skipped adding duplicate file "${filepath}" to PBXGroup named "${groupName}"` 188 ); 189 } 190 return project; 191 } 192 193 if (targetUuid != null) { 194 file.target = targetUuid; 195 } else { 196 const applicationNativeTarget = project.getTarget('com.apple.product-type.application'); 197 file.target = applicationNativeTarget?.uuid; 198 } 199 200 file.uuid = project.generateUuid(); 201 file.fileRef = project.generateUuid(); 202 203 addFileToProject({ project, file }); 204 205 group.children.push({ 206 value: file.fileRef, 207 comment: file.basename, 208 }); 209 return project; 210} 211 212export function getApplicationNativeTarget({ 213 project, 214 projectName, 215}: { 216 project: XcodeProject; 217 projectName: string; 218}) { 219 const applicationNativeTarget = project.getTarget('com.apple.product-type.application'); 220 assert( 221 applicationNativeTarget, 222 `Couldn't locate application PBXNativeTarget in '.xcodeproj' file.` 223 ); 224 assert( 225 String(applicationNativeTarget.target.name) === projectName, 226 `Application native target name mismatch. Expected ${projectName}, but found ${applicationNativeTarget.target.name}.` 227 ); 228 return applicationNativeTarget; 229} 230 231/** 232 * Add a framework to the default app native target. 233 * 234 * @param projectName Name of the PBX project. 235 * @param framework String ending in `.framework`, i.e. `StoreKit.framework` 236 */ 237export function addFramework({ 238 project, 239 projectName, 240 framework, 241}: { 242 project: XcodeProject; 243 projectName: string; 244 framework: string; 245}) { 246 const target = getApplicationNativeTarget({ project, projectName }); 247 return project.addFramework(framework, { target: target.uuid }); 248} 249 250function splitPath(path: string): string[] { 251 // TODO: Should we account for other platforms that may not use `/` 252 return path.split('/'); 253} 254 255const findGroup = ( 256 group: PBXGroup | undefined, 257 name: string 258): 259 | { 260 value: UUID; 261 comment?: string; 262 } 263 | undefined => { 264 if (!group) { 265 return undefined; 266 } 267 268 return group.children.find((group) => group.comment === name); 269}; 270 271function findGroupInsideGroup( 272 project: XcodeProject, 273 group: PBXGroup | undefined, 274 name: string 275): null | PBXGroup { 276 const foundGroup = findGroup(group, name); 277 if (foundGroup) { 278 return project.getPBXGroupByKey(foundGroup.value) ?? null; 279 } 280 return null; 281} 282 283function pbxGroupByPathOrAssert(project: XcodeProject, path: string): PBXGroup { 284 const { firstProject } = project.getFirstProject(); 285 286 let group = project.getPBXGroupByKey(firstProject.mainGroup); 287 288 const components = splitPath(path); 289 for (const name of components) { 290 const nextGroup = findGroupInsideGroup(project, group, name); 291 if (nextGroup) { 292 group = nextGroup; 293 } else { 294 break; 295 } 296 } 297 298 if (!group) { 299 throw Error(`Xcode PBXGroup with name "${path}" could not be found in the Xcode project.`); 300 } 301 302 return group; 303} 304 305export function ensureGroupRecursively(project: XcodeProject, filepath: string): PBXGroup | null { 306 const components = splitPath(filepath); 307 const hasChild = (group: PBXGroup, name: string) => 308 group.children.find(({ comment }) => comment === name); 309 const { firstProject } = project.getFirstProject(); 310 311 let topMostGroup = project.getPBXGroupByKey(firstProject.mainGroup); 312 313 for (const pathComponent of components) { 314 if (topMostGroup && !hasChild(topMostGroup, pathComponent)) { 315 topMostGroup.children.push({ 316 comment: pathComponent, 317 value: project.pbxCreateGroup(pathComponent, '""'), 318 }); 319 } 320 topMostGroup = project.pbxGroupByName(pathComponent); 321 } 322 return topMostGroup ?? null; 323} 324 325/** 326 * Get the pbxproj for the given path 327 */ 328export function getPbxproj(projectRoot: string): XcodeProject { 329 const projectPath = Paths.getPBXProjectPath(projectRoot); 330 const project = xcode.project(projectPath); 331 project.parseSync(); 332 return project; 333} 334 335/** 336 * Get the productName for a project, if the name is using a variable `$(TARGET_NAME)`, then attempt to get the value of that variable. 337 * 338 * @param project 339 */ 340export function getProductName(project: XcodeProject): string { 341 let productName = '$(TARGET_NAME)'; 342 try { 343 // If the product name is numeric, this will fail (it's a getter). 344 // If the bundle identifier' final component is only numeric values, then the PRODUCT_NAME 345 // will be a numeric value, this results in a bug where the product name isn't useful, 346 // i.e. `com.bacon.001` -> `1` -- in this case, use the first target name. 347 productName = project.productName; 348 } catch {} 349 350 if (productName === '$(TARGET_NAME)') { 351 const targetName = project.getFirstTarget()?.firstTarget?.productName; 352 productName = targetName ?? productName; 353 } 354 355 return productName; 356} 357 358export function getProjectSection(project: XcodeProject) { 359 return project.pbxProjectSection(); 360} 361 362export function getXCConfigurationListEntries(project: XcodeProject): ConfigurationListEntry[] { 363 const lists = project.pbxXCConfigurationList(); 364 return Object.entries(lists).filter(isNotComment); 365} 366 367export function getBuildConfigurationsForListId( 368 project: XcodeProject, 369 configurationListId: string 370): ConfigurationSectionEntry[] { 371 const configurationListEntries = getXCConfigurationListEntries(project); 372 const [, configurationList] = configurationListEntries.find( 373 ([key]) => key === configurationListId 374 ) as ConfigurationListEntry; 375 376 const buildConfigurations = configurationList.buildConfigurations.map((i) => i.value); 377 378 return Object.entries(project.pbxXCBuildConfigurationSection()) 379 .filter(isNotComment) 380 .filter(isBuildConfig) 381 .filter(([key]: ConfigurationSectionEntry) => buildConfigurations.includes(key)); 382} 383 384export function getBuildConfigurationForListIdAndName( 385 project: XcodeProject, 386 { 387 configurationListId, 388 buildConfiguration, 389 }: { configurationListId: string; buildConfiguration: string } 390): ConfigurationSectionEntry { 391 const xcBuildConfigurationEntry = getBuildConfigurationsForListId( 392 project, 393 configurationListId 394 ).find((i) => trimQuotes(i[1].name) === buildConfiguration); 395 if (!xcBuildConfigurationEntry) { 396 throw new Error( 397 `Build configuration '${buildConfiguration}' does not exist in list with id '${configurationListId}'` 398 ); 399 } 400 return xcBuildConfigurationEntry; 401} 402 403export function isBuildConfig([, sectionItem]: ConfigurationSectionEntry): boolean { 404 return sectionItem.isa === 'XCBuildConfiguration'; 405} 406 407export function isNotTestHost([, sectionItem]: ConfigurationSectionEntry): boolean { 408 return !sectionItem.buildSettings.TEST_HOST; 409} 410 411export function isNotComment([key]: 412 | ConfigurationSectionEntry 413 | ProjectSectionEntry 414 | ConfigurationListEntry 415 | NativeTargetSectionEntry): boolean { 416 return !key.endsWith(`_comment`); 417} 418 419// Remove surrounding double quotes if they exist. 420export function unquote(value: string): string { 421 // projects with numeric names will fail due to a bug in the xcode package. 422 if (typeof value === 'number') { 423 value = String(value); 424 } 425 return value.match(/^"(.*)"$/)?.[1] ?? value; 426} 427