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