1import Debug from 'debug'; 2import path from 'path'; 3 4import { assertModResults, ForwardedBaseModOptions } from './createBaseMod'; 5import { withAndroidBaseMods } from './withAndroidBaseMods'; 6import { withIosBaseMods } from './withIosBaseMods'; 7import { ExportedConfig, Mod, ModConfig, ModPlatform } from '../Plugin.types'; 8import { getHackyProjectName } from '../ios/utils/Xcodeproj'; 9import { PluginError } from '../utils/errors'; 10import * as Warnings from '../utils/warnings'; 11 12const debug = Debug('expo:config-plugins:mod-compiler'); 13 14export function withDefaultBaseMods( 15 config: ExportedConfig, 16 props: ForwardedBaseModOptions = {} 17): ExportedConfig { 18 config = withIosBaseMods(config, props); 19 config = withAndroidBaseMods(config, props); 20 return config; 21} 22 23/** 24 * Get a prebuild config that safely evaluates mods without persisting any changes to the file system. 25 * Currently this only supports infoPlist, entitlements, androidManifest, strings, gradleProperties, and expoPlist mods. 26 * This plugin should be evaluated directly: 27 */ 28export function withIntrospectionBaseMods( 29 config: ExportedConfig, 30 props: ForwardedBaseModOptions = {} 31): ExportedConfig { 32 config = withIosBaseMods(config, { 33 saveToInternal: true, 34 // This writing optimization can be skipped since we never write in introspection mode. 35 // Including empty mods will ensure that all mods get introspected. 36 skipEmptyMod: false, 37 ...props, 38 }); 39 config = withAndroidBaseMods(config, { 40 saveToInternal: true, 41 skipEmptyMod: false, 42 ...props, 43 }); 44 45 if (config.mods) { 46 // Remove all mods that don't have an introspection base mod, for instance `dangerous` mods. 47 for (const platform of Object.keys(config.mods) as ModPlatform[]) { 48 // const platformPreserve = preserve[platform]; 49 for (const key of Object.keys(config.mods[platform] || {})) { 50 // @ts-ignore 51 if (!config.mods[platform]?.[key]?.isIntrospective) { 52 debug(`removing non-idempotent mod: ${platform}.${key}`); 53 // @ts-ignore 54 delete config.mods[platform]?.[key]; 55 } 56 } 57 } 58 } 59 60 return config; 61} 62 63/** 64 * 65 * @param projectRoot 66 * @param config 67 */ 68export async function compileModsAsync( 69 config: ExportedConfig, 70 props: { 71 projectRoot: string; 72 platforms?: ModPlatform[]; 73 introspect?: boolean; 74 assertMissingModProviders?: boolean; 75 ignoreExistingNativeFiles?: boolean; 76 } 77): Promise<ExportedConfig> { 78 if (props.introspect === true) { 79 config = withIntrospectionBaseMods(config); 80 } else { 81 config = withDefaultBaseMods(config); 82 } 83 return await evalModsAsync(config, props); 84} 85 86function sortMods(commands: [string, any][], order: string[]): [string, any][] { 87 const allKeys = commands.map(([key]) => key); 88 const completeOrder = [...new Set([...order, ...allKeys])]; 89 const sorted: [string, any][] = []; 90 while (completeOrder.length) { 91 const group = completeOrder.shift()!; 92 const commandSet = commands.find(([key]) => key === group); 93 if (commandSet) { 94 sorted.push(commandSet); 95 } 96 } 97 return sorted; 98} 99 100function getRawClone({ mods, ...config }: ExportedConfig) { 101 // Configs should be fully serializable, so we can clone them without worrying about 102 // the mods. 103 return Object.freeze(JSON.parse(JSON.stringify(config))); 104} 105 106const orders: Record<string, string[]> = { 107 ios: [ 108 // dangerous runs first 109 'dangerous', 110 // run the XcodeProject mod second because many plugins attempt to read from it. 111 'xcodeproj', 112 ], 113}; 114/** 115 * A generic plugin compiler. 116 * 117 * @param config 118 */ 119export async function evalModsAsync( 120 config: ExportedConfig, 121 { 122 projectRoot, 123 introspect, 124 platforms, 125 assertMissingModProviders, 126 ignoreExistingNativeFiles = false, 127 }: { 128 projectRoot: string; 129 introspect?: boolean; 130 platforms?: ModPlatform[]; 131 /** 132 * Throw errors when mods are missing providers. 133 * @default true 134 */ 135 assertMissingModProviders?: boolean; 136 /** Ignore any existing native files, only use the generated prebuild results. */ 137 ignoreExistingNativeFiles?: boolean; 138 } 139): Promise<ExportedConfig> { 140 const modRawConfig = getRawClone(config); 141 for (const [platformName, platform] of Object.entries(config.mods ?? ({} as ModConfig))) { 142 if (platforms && !platforms.includes(platformName as any)) { 143 debug(`skip platform: ${platformName}`); 144 continue; 145 } 146 147 let entries = Object.entries(platform); 148 if (entries.length) { 149 // Move dangerous item to the first position if it exists, this ensures that all dangerous code runs first. 150 entries = sortMods(entries, orders[platformName] ?? ['dangerous']); 151 debug(`run in order: ${entries.map(([name]) => name).join(', ')}`); 152 const platformProjectRoot = path.join(projectRoot, platformName); 153 const projectName = 154 platformName === 'ios' ? getHackyProjectName(projectRoot, config) : undefined; 155 156 for (const [modName, mod] of entries) { 157 const modRequest = { 158 projectRoot, 159 projectName, 160 platformProjectRoot, 161 platform: platformName as ModPlatform, 162 modName, 163 introspect: !!introspect, 164 ignoreExistingNativeFiles, 165 }; 166 167 if (!(mod as Mod).isProvider) { 168 // In strict mode, throw an error. 169 const errorMessage = `Initial base modifier for "${platformName}.${modName}" is not a provider and therefore will not provide modResults to child mods`; 170 if (assertMissingModProviders !== false) { 171 throw new PluginError(errorMessage, 'MISSING_PROVIDER'); 172 } else { 173 Warnings.addWarningForPlatform( 174 platformName as ModPlatform, 175 `${platformName}.${modName}`, 176 `Skipping: Initial base modifier for "${platformName}.${modName}" is not a provider and therefore will not provide modResults to child mods. This may be due to an outdated version of Expo CLI.` 177 ); 178 // In loose mode, just skip the mod entirely. 179 continue; 180 } 181 } 182 183 const results = await (mod as Mod)({ 184 ...config, 185 modResults: null, 186 modRequest, 187 modRawConfig, 188 }); 189 190 // Sanity check to help locate non compliant mods. 191 config = assertModResults(results, platformName, modName); 192 // @ts-ignore: `modResults` is added for modifications 193 delete config.modResults; 194 // @ts-ignore: `modRequest` is added for modifications 195 delete config.modRequest; 196 // @ts-ignore: `modRawConfig` is added for modifications 197 delete config.modRawConfig; 198 } 199 } 200 } 201 202 return config; 203} 204