import { ExpoConfig } from '@expo/config-types'; import assert from 'assert'; import path from 'path'; import slugify from 'slugify'; import xcode, { PBXFile, PBXGroup, PBXNativeTarget, PBXProject, UUID, XCBuildConfiguration, XCConfigurationList, XcodeProject, } from 'xcode'; import pbxFile from 'xcode/lib/pbxFile'; import { trimQuotes } from './string'; import { addWarningIOS } from '../../utils/warnings'; import * as Paths from '../Paths'; export type ProjectSectionEntry = [string, PBXProject]; export type NativeTargetSection = Record; export type NativeTargetSectionEntry = [string, PBXNativeTarget]; export type ConfigurationLists = Record; export type ConfigurationListEntry = [string, XCConfigurationList]; export type ConfigurationSectionEntry = [string, XCBuildConfiguration]; export function getProjectName(projectRoot: string) { const sourceRoot = Paths.getSourceRoot(projectRoot); return path.basename(sourceRoot); } export function resolvePathOrProject( projectRootOrProject: string | XcodeProject ): XcodeProject | null { if (typeof projectRootOrProject === 'string') { try { return getPbxproj(projectRootOrProject); } catch { return null; } } return projectRootOrProject; } // TODO: come up with a better solution for using app.json expo.name in various places export function sanitizedName(name: string) { // Default to the name `app` when every safe character has been sanitized return sanitizedNameForProjects(name) || sanitizedNameForProjects(slugify(name)) || 'app'; } function sanitizedNameForProjects(name: string) { return name .replace(/[\W_]+/g, '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, ''); } // TODO: it's silly and kind of fragile that we look at app config to determine // the ios project paths. Overall this function needs to be revamped, just a // placeholder for now! Make this more robust when we support applying config // at any time (currently it's only applied on eject). export function getHackyProjectName(projectRoot: string, config: ExpoConfig): string { // Attempt to get the current ios folder name (apply). try { return getProjectName(projectRoot); } catch { // If no iOS project exists then create a new one (eject). const projectName = config.name; assert(projectName, 'Your project needs a name in app.json/app.config.js.'); return sanitizedName(projectName); } } function createProjectFileForGroup({ filepath, group }: { filepath: string; group: PBXGroup }) { const file = new pbxFile(filepath); const conflictingFile = group.children.find((child) => child.comment === file.basename); if (conflictingFile) { // This can happen when a file like the GoogleService-Info.plist needs to be added and the eject command is run twice. // Not much we can do here since it might be a conflicting file. return null; } return file; } /** * Add a resource file (ex: `SplashScreen.storyboard`, `Images.xcassets`) to an Xcode project. * This is akin to creating a new code file in Xcode with `⌘+n`. */ export function addResourceFileToGroup({ filepath, groupName, // Should add to `PBXBuildFile Section` isBuildFile, project, verbose, targetUuid, }: { filepath: string; groupName: string; isBuildFile?: boolean; project: XcodeProject; verbose?: boolean; targetUuid?: string; }): XcodeProject { return addFileToGroupAndLink({ filepath, groupName, project, verbose, targetUuid, addFileToProject({ project, file }) { project.addToPbxFileReferenceSection(file); if (isBuildFile) { project.addToPbxBuildFileSection(file); } project.addToPbxResourcesBuildPhase(file); }, }); } /** * Add a build source file (ex: `AppDelegate.m`, `ViewController.swift`) to an Xcode project. * This is akin to creating a new code file in Xcode with `⌘+n`. */ export function addBuildSourceFileToGroup({ filepath, groupName, project, verbose, targetUuid, }: { filepath: string; groupName: string; project: XcodeProject; verbose?: boolean; targetUuid?: string; }): XcodeProject { return addFileToGroupAndLink({ filepath, groupName, project, verbose, targetUuid, addFileToProject({ project, file }) { project.addToPbxFileReferenceSection(file); project.addToPbxBuildFileSection(file); project.addToPbxSourcesBuildPhase(file); }, }); } // TODO(brentvatne): I couldn't figure out how to do this with an existing // higher level function exposed by the xcode library, but we should find out how to do // that and replace this with it export function addFileToGroupAndLink({ filepath, groupName, project, verbose, addFileToProject, targetUuid, }: { filepath: string; groupName: string; project: XcodeProject; verbose?: boolean; targetUuid?: string; addFileToProject: (props: { file: PBXFile; project: XcodeProject }) => void; }): XcodeProject { const group = pbxGroupByPathOrAssert(project, groupName); const file = createProjectFileForGroup({ filepath, group }); if (!file) { if (verbose) { // This can happen when a file like the GoogleService-Info.plist needs to be added and the eject command is run twice. // Not much we can do here since it might be a conflicting file. addWarningIOS( 'ios-xcode-project', `Skipped adding duplicate file "${filepath}" to PBXGroup named "${groupName}"` ); } return project; } if (targetUuid != null) { file.target = targetUuid; } else { const applicationNativeTarget = project.getTarget('com.apple.product-type.application'); file.target = applicationNativeTarget?.uuid; } file.uuid = project.generateUuid(); file.fileRef = project.generateUuid(); addFileToProject({ project, file }); group.children.push({ value: file.fileRef, comment: file.basename, }); return project; } export function getApplicationNativeTarget({ project, projectName, }: { project: XcodeProject; projectName: string; }) { const applicationNativeTarget = project.getTarget('com.apple.product-type.application'); assert( applicationNativeTarget, `Couldn't locate application PBXNativeTarget in '.xcodeproj' file.` ); assert( String(applicationNativeTarget.target.name) === projectName, `Application native target name mismatch. Expected ${projectName}, but found ${applicationNativeTarget.target.name}.` ); return applicationNativeTarget; } /** * Add a framework to the default app native target. * * @param projectName Name of the PBX project. * @param framework String ending in `.framework`, i.e. `StoreKit.framework` */ export function addFramework({ project, projectName, framework, }: { project: XcodeProject; projectName: string; framework: string; }) { const target = getApplicationNativeTarget({ project, projectName }); return project.addFramework(framework, { target: target.uuid }); } function splitPath(path: string): string[] { // TODO: Should we account for other platforms that may not use `/` return path.split('/'); } const findGroup = ( group: PBXGroup | undefined, name: string ): | { value: UUID; comment?: string; } | undefined => { if (!group) { return undefined; } return group.children.find((group) => group.comment === name); }; function findGroupInsideGroup( project: XcodeProject, group: PBXGroup | undefined, name: string ): null | PBXGroup { const foundGroup = findGroup(group, name); if (foundGroup) { return project.getPBXGroupByKey(foundGroup.value) ?? null; } return null; } function pbxGroupByPathOrAssert(project: XcodeProject, path: string): PBXGroup { const { firstProject } = project.getFirstProject(); let group = project.getPBXGroupByKey(firstProject.mainGroup); const components = splitPath(path); for (const name of components) { const nextGroup = findGroupInsideGroup(project, group, name); if (nextGroup) { group = nextGroup; } else { break; } } if (!group) { throw Error(`Xcode PBXGroup with name "${path}" could not be found in the Xcode project.`); } return group; } export function ensureGroupRecursively(project: XcodeProject, filepath: string): PBXGroup | null { const components = splitPath(filepath); const hasChild = (group: PBXGroup, name: string) => group.children.find(({ comment }) => comment === name); const { firstProject } = project.getFirstProject(); let topMostGroup = project.getPBXGroupByKey(firstProject.mainGroup); for (const pathComponent of components) { if (topMostGroup && !hasChild(topMostGroup, pathComponent)) { topMostGroup.children.push({ comment: pathComponent, value: project.pbxCreateGroup(pathComponent, '""'), }); } topMostGroup = project.pbxGroupByName(pathComponent); } return topMostGroup ?? null; } /** * Get the pbxproj for the given path */ export function getPbxproj(projectRoot: string): XcodeProject { const projectPath = Paths.getPBXProjectPath(projectRoot); const project = xcode.project(projectPath); project.parseSync(); return project; } /** * Get the productName for a project, if the name is using a variable `$(TARGET_NAME)`, then attempt to get the value of that variable. * * @param project */ export function getProductName(project: XcodeProject): string { let productName = '$(TARGET_NAME)'; try { // If the product name is numeric, this will fail (it's a getter). // If the bundle identifier' final component is only numeric values, then the PRODUCT_NAME // will be a numeric value, this results in a bug where the product name isn't useful, // i.e. `com.bacon.001` -> `1` -- in this case, use the first target name. productName = project.productName; } catch {} if (productName === '$(TARGET_NAME)') { const targetName = project.getFirstTarget()?.firstTarget?.productName; productName = targetName ?? productName; } return productName; } export function getProjectSection(project: XcodeProject) { return project.pbxProjectSection(); } export function getXCConfigurationListEntries(project: XcodeProject): ConfigurationListEntry[] { const lists = project.pbxXCConfigurationList(); return Object.entries(lists).filter(isNotComment); } export function getBuildConfigurationsForListId( project: XcodeProject, configurationListId: string ): ConfigurationSectionEntry[] { const configurationListEntries = getXCConfigurationListEntries(project); const [, configurationList] = configurationListEntries.find( ([key]) => key === configurationListId ) as ConfigurationListEntry; const buildConfigurations = configurationList.buildConfigurations.map((i) => i.value); return Object.entries(project.pbxXCBuildConfigurationSection()) .filter(isNotComment) .filter(isBuildConfig) .filter(([key]: ConfigurationSectionEntry) => buildConfigurations.includes(key)); } export function getBuildConfigurationForListIdAndName( project: XcodeProject, { configurationListId, buildConfiguration, }: { configurationListId: string; buildConfiguration: string } ): ConfigurationSectionEntry { const xcBuildConfigurationEntry = getBuildConfigurationsForListId( project, configurationListId ).find((i) => trimQuotes(i[1].name) === buildConfiguration); if (!xcBuildConfigurationEntry) { throw new Error( `Build configuration '${buildConfiguration}' does not exist in list with id '${configurationListId}'` ); } return xcBuildConfigurationEntry; } export function isBuildConfig([, sectionItem]: ConfigurationSectionEntry): boolean { return sectionItem.isa === 'XCBuildConfiguration'; } export function isNotTestHost([, sectionItem]: ConfigurationSectionEntry): boolean { return !sectionItem.buildSettings.TEST_HOST; } export function isNotComment([key]: | ConfigurationSectionEntry | ProjectSectionEntry | ConfigurationListEntry | NativeTargetSectionEntry): boolean { return !key.endsWith(`_comment`); } // Remove surrounding double quotes if they exist. export function unquote(value: string): string { // projects with numeric names will fail due to a bug in the xcode package. if (typeof value === 'number') { value = String(value); } return value.match(/^"(.*)"$/)?.[1] ?? value; }