1import fs from 'fs-extra'; 2import path from 'path'; 3import semver from 'semver'; 4 5import { Podspec } from '../CocoaPods'; 6import { EXPOTOOLS_DIR, IOS_DIR } from '../Constants'; 7import { arrayize, spawnAsync } from '../Utils'; 8import { 9 ProjectSpec, 10 ProjectSpecDependency, 11 ProjectSpecPlatform, 12 XcodeConfig, 13} from './XcodeGen.types'; 14 15const PODS_DIR = path.join(IOS_DIR, 'Pods'); 16const PODS_PUBLIC_HEADERS_DIR = path.join(PODS_DIR, 'Headers', 'Public'); 17const PODS_PRIVATE_HEADERS_DIR = path.join(PODS_DIR, 'Headers', 'Private'); 18const PLATFORMS_MAPPING: Record<string, ProjectSpecPlatform> = { 19 ios: 'iOS', 20 osx: 'macOS', 21 macos: 'macOS', 22 tvos: 'tvOS', 23 watchos: 'watchOS', 24}; 25 26export const INFO_PLIST_FILENAME = 'Info-generated.plist'; 27export const GENERATED_MODULEMAP_FILENAME = 'generated.modulemap'; 28 29/** 30 * Generates `.xcodeproj` from given project spec and saves it at given dir. 31 */ 32export async function generateXcodeProjectAsync(dir: string, spec: ProjectSpec): Promise<string> { 33 const specPath = path.join(dir, `${spec.name}.spec.json`); 34 35 // Save the spec to the file so `xcodegen` can use it. 36 await fs.outputJSON(specPath, spec, { 37 spaces: 2, 38 }); 39 40 // Generate `.xcodeproj` from given spec. The binary is provided by `@expo/xcodegen` package. 41 await spawnAsync('yarn', ['--silent', 'run', 'xcodegen', '--quiet', '--spec', specPath], { 42 cwd: EXPOTOOLS_DIR, 43 stdio: 'inherit', 44 }); 45 46 // Remove temporary spec file. 47 await fs.remove(specPath); 48 49 return path.join(dir, `${spec.name}.xcodeproj`); 50} 51 52/** 53 * Creates `xcodegen` spec from the podspec. It's very naive, but covers all our cases so far. 54 */ 55export async function createSpecFromPodspecAsync( 56 podspec: Podspec, 57 dependencyResolver: (dependencyName: string) => Promise<ProjectSpecDependency | null> 58): Promise<ProjectSpec> { 59 const platforms = Object.keys(podspec.platforms); 60 const deploymentTarget = platforms.reduce((acc, platform) => { 61 acc[PLATFORMS_MAPPING[platform]] = podspec.platforms[platform]; 62 return acc; 63 }, {} as Record<ProjectSpecPlatform, string>); 64 65 const dependenciesNames = podspec.dependencies ? Object.keys(podspec.dependencies) : []; 66 67 const dependencies = ( 68 await Promise.all(dependenciesNames.map((dependencyName) => dependencyResolver(dependencyName))) 69 ).filter(Boolean) as ProjectSpecDependency[]; 70 71 const bundleId = podNameToBundleId(podspec.name); 72 73 return { 74 name: podspec.name, 75 targets: { 76 [podspec.name]: { 77 type: 'framework', 78 platform: platforms.map((platform) => PLATFORMS_MAPPING[platform]), 79 sources: [ 80 { 81 path: '', 82 name: podspec.name, 83 createIntermediateGroups: true, 84 includes: arrayize(podspec.source_files), 85 excludes: [ 86 INFO_PLIST_FILENAME, 87 `${podspec.name}.spec.json`, 88 '*.xcodeproj', 89 '*.xcframework', 90 '*.podspec', 91 ...arrayize(podspec.exclude_files), 92 ], 93 compilerFlags: podspec.compiler_flags, 94 }, 95 ], 96 dependencies: [ 97 ...arrayize(podspec.frameworks).map((framework) => ({ 98 sdk: `${framework}.framework`, 99 })), 100 ...dependencies, 101 ], 102 settings: { 103 base: mergeXcodeConfigs(podspec.pod_target_xcconfig ?? {}, { 104 MACH_O_TYPE: 'staticlib', 105 }), 106 }, 107 info: { 108 path: INFO_PLIST_FILENAME, 109 properties: mergeXcodeConfigs( 110 { 111 CFBundleIdentifier: bundleId, 112 CFBundleName: podspec.name, 113 CFBundleShortVersionString: podspec.version, 114 CFBundleVersion: String(semver.major(podspec.version)), 115 }, 116 podspec.info_plist ?? {} 117 ), 118 }, 119 }, 120 }, 121 options: { 122 minimumXcodeGenVersion: '2.18.0', 123 deploymentTarget, 124 }, 125 settings: { 126 base: { 127 PRODUCT_BUNDLE_IDENTIFIER: bundleId, 128 IPHONEOS_DEPLOYMENT_TARGET: podspec.platforms.ios, 129 FRAMEWORK_SEARCH_PATHS: constructFrameworkSearchPaths(dependencies), 130 HEADER_SEARCH_PATHS: constructHeaderSearchPaths(dependenciesNames), 131 MODULEMAP_FILE: podspec.modulemap_file ?? '', 132 133 // Suppresses deprecation warnings coming from frameworks like OpenGLES. 134 VALIDATE_WORKSPACE_SKIPPED_SDK_FRAMEWORKS: arrayize(podspec.frameworks).join(' '), 135 }, 136 }, 137 }; 138} 139 140function constructFrameworkSearchPaths(dependencies: ProjectSpecDependency[]): string { 141 const frameworks = dependencies.filter((dependency) => !!dependency.framework) as { 142 framework: string; 143 }[]; 144 145 return ( 146 '$(inherited) ' + frameworks.map((dependency) => path.dirname(dependency.framework)).join(' ') 147 ).trim(); 148} 149 150function constructHeaderSearchPaths(dependencies: string[]): string { 151 // A set of pod names to include in header search paths. 152 // For simplicity, we add some more (usually transitive) than direct dependencies. 153 const podsToSearchForHeaders = new Set([ 154 // Some pods' have headers at its root level (ZXingObjC and all our modules). 155 // Without this we would have to use `#import <ZXingObjC*.h>` instead of `#import <ZXingObjC/ZXingObjC*.h>` 156 '', 157 158 ...dependencies, 159 160 'DoubleConversion', 161 'React-callinvoker', 162 'React-Core', 163 'React-cxxreact', 164 'React-jsi', 165 'React-jsiexecutor', 166 'React-jsinspector', 167 'Yoga', 168 'glog', 169 ]); 170 171 function headerSearchPathsForDir(dir: string): string { 172 return [...podsToSearchForHeaders] 173 .map((podName) => '"' + path.join(dir, podName) + '"') 174 .join(' '); 175 } 176 177 return [ 178 '$(inherited)', 179 headerSearchPathsForDir(PODS_PUBLIC_HEADERS_DIR), 180 headerSearchPathsForDir(PODS_PRIVATE_HEADERS_DIR), 181 ].join(' '); 182} 183 184/** 185 * Merges Xcode config from left to right. 186 * Values containing `$(inherited)` are properly taken into account. 187 */ 188function mergeXcodeConfigs(...configs: XcodeConfig[]): XcodeConfig { 189 const result: XcodeConfig = {}; 190 191 for (const config of configs) { 192 for (const key in config) { 193 const value = config[key]; 194 result[key] = mergeXcodeConfigValue(result[key], value); 195 } 196 } 197 return result; 198} 199 200function mergeXcodeConfigValue(prevValue: string | undefined, nextValue: string): string { 201 if (prevValue && typeof prevValue === 'string' && prevValue.includes('$(inherited)')) { 202 return '$(inherited) ' + (prevValue + ' ' + nextValue).replace(/\\s*$\(inherited\)\s*/g, ' '); 203 } 204 return nextValue; 205} 206 207/** 208 * Simple conversion from pod name to framework's bundle identifier. 209 */ 210function podNameToBundleId(podName: string): string { 211 return podName 212 .replace(/^UM/, 'unimodules') 213 .replace(/^EX/, 'expo') 214 .replace(/(\_|[^\w\d\.])+/g, '.') 215 .replace(/\.*([A-Z]+)/g, (_, p1) => `.${p1.toLowerCase()}`); 216} 217 218/** 219 * Generate custom modulemap for expo-modules-core which needs to make React-Core headers modular 220 */ 221export async function generateExpoModulesCoreModulemapAsync(destDir: string): Promise<string> { 222 const expoModulesCoreUmbrellaHeaderName = 'ExpoModulesCore-umbrella.h'; 223 const expoModulesCoreUmbrellaHeader = path.join( 224 PODS_PUBLIC_HEADERS_DIR, 225 'ExpoModulesCore', 226 expoModulesCoreUmbrellaHeaderName 227 ); 228 await fs.copyFile( 229 expoModulesCoreUmbrellaHeader, 230 path.join(destDir, expoModulesCoreUmbrellaHeaderName) 231 ); 232 233 const reactCoreHeaderDir = path.join(PODS_PUBLIC_HEADERS_DIR, 'React-Core'); 234 const yogaUmbrellaHeader = path.join(PODS_PUBLIC_HEADERS_DIR, 'Yoga', 'Yoga-umbrella.h'); 235 const modulemapContent = ` 236framework module ExpoModulesCore { 237 umbrella header "ExpoModulesCore.h" 238 export * 239 module * { export * } 240 241 module React { 242 umbrella "${reactCoreHeaderDir}" 243 export * 244 module * { export * } 245 } 246 247 module Yoga { 248 umbrella header "${yogaUmbrellaHeader}" 249 export * 250 module * { export * } 251 } 252} 253`; 254 255 const modulemapFile = path.join(destDir, GENERATED_MODULEMAP_FILENAME); 256 await fs.writeFile(modulemapFile, modulemapContent); 257 return modulemapFile; 258} 259