1import Debug from 'debug'; 2import path from 'path'; 3 4import { ExportedConfig, Mod, ModConfig, ModPlatform } from '../Plugin.types'; 5import { getHackyProjectName } from '../ios/utils/Xcodeproj'; 6import { PluginError } from '../utils/errors'; 7import * as Warnings from '../utils/warnings'; 8import { assertModResults, ForwardedBaseModOptions } from './createBaseMod'; 9import { withAndroidBaseMods } from './withAndroidBaseMods'; 10import { withIosBaseMods } from './withIosBaseMods'; 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 } 76): Promise<ExportedConfig> { 77 if (props.introspect === true) { 78 config = withIntrospectionBaseMods(config); 79 } else { 80 config = withDefaultBaseMods(config); 81 } 82 return await evalModsAsync(config, props); 83} 84 85function sortMods(commands: [string, any][], order: string[]): [string, any][] { 86 const allKeys = commands.map(([key]) => key); 87 const completeOrder = [...new Set([...order, ...allKeys])]; 88 const sorted: [string, any][] = []; 89 while (completeOrder.length) { 90 const group = completeOrder.shift()!; 91 const commandSet = commands.find(([key]) => key === group); 92 if (commandSet) { 93 sorted.push(commandSet); 94 } 95 } 96 return sorted; 97} 98 99function getRawClone({ mods, ...config }: ExportedConfig) { 100 // Configs should be fully serializable, so we can clone them without worrying about 101 // the mods. 102 return Object.freeze(JSON.parse(JSON.stringify(config))); 103} 104 105const orders: Record<string, string[]> = { 106 ios: [ 107 // dangerous runs first 108 'dangerous', 109 // run the XcodeProject mod second because many plugins attempt to read from it. 110 'xcodeproj', 111 ], 112 android: ['dangerous'], 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 /** 126 * Throw errors when mods are missing providers. 127 * @default true 128 */ 129 assertMissingModProviders, 130 }: { 131 projectRoot: string; 132 introspect?: boolean; 133 assertMissingModProviders?: boolean; 134 platforms?: ModPlatform[]; 135 } 136): Promise<ExportedConfig> { 137 const modRawConfig = getRawClone(config); 138 for (const [platformName, platform] of Object.entries(config.mods ?? ({} as ModConfig))) { 139 if (platforms && !platforms.includes(platformName as any)) { 140 debug(`skip platform: ${platformName}`); 141 continue; 142 } 143 144 let entries = Object.entries(platform); 145 if (entries.length) { 146 // Move dangerous item to the first position if it exists, this ensures that all dangerous code runs first. 147 entries = sortMods(entries, orders[platformName]!); 148 debug(`run in order: ${entries.map(([name]) => name).join(', ')}`); 149 const platformProjectRoot = path.join(projectRoot, platformName); 150 const projectName = 151 platformName === 'ios' ? getHackyProjectName(projectRoot, config) : undefined; 152 153 for (const [modName, mod] of entries) { 154 const modRequest = { 155 projectRoot, 156 projectName, 157 platformProjectRoot, 158 platform: platformName as ModPlatform, 159 modName, 160 introspect: !!introspect, 161 }; 162 163 if (!(mod as Mod).isProvider) { 164 // In strict mode, throw an error. 165 const errorMessage = `Initial base modifier for "${platformName}.${modName}" is not a provider and therefore will not provide modResults to child mods`; 166 if (assertMissingModProviders !== false) { 167 throw new PluginError(errorMessage, 'MISSING_PROVIDER'); 168 } else { 169 Warnings.addWarningForPlatform( 170 platformName as ModPlatform, 171 `${platformName}.${modName}`, 172 `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.` 173 ); 174 // In loose mode, just skip the mod entirely. 175 continue; 176 } 177 } 178 179 const results = await (mod as Mod)({ 180 ...config, 181 modResults: null, 182 modRequest, 183 modRawConfig, 184 }); 185 186 // Sanity check to help locate non compliant mods. 187 config = assertModResults(results, platformName, modName); 188 // @ts-ignore: `modResults` is added for modifications 189 delete config.modResults; 190 // @ts-ignore: `modRequest` is added for modifications 191 delete config.modRequest; 192 // @ts-ignore: `modRawConfig` is added for modifications 193 delete config.modRawConfig; 194 } 195 } 196 } 197 198 return config; 199} 200