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