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};
113/**
114 * A generic plugin compiler.
115 *
116 * @param config
117 */
118export async function evalModsAsync(
119  config: ExportedConfig,
120  {
121    projectRoot,
122    introspect,
123    platforms,
124    /**
125     * Throw errors when mods are missing providers.
126     * @default true
127     */
128    assertMissingModProviders,
129  }: {
130    projectRoot: string;
131    introspect?: boolean;
132    assertMissingModProviders?: boolean;
133    platforms?: ModPlatform[];
134  }
135): Promise<ExportedConfig> {
136  const modRawConfig = getRawClone(config);
137  for (const [platformName, platform] of Object.entries(config.mods ?? ({} as ModConfig))) {
138    if (platforms && !platforms.includes(platformName as any)) {
139      debug(`skip platform: ${platformName}`);
140      continue;
141    }
142
143    let entries = Object.entries(platform);
144    if (entries.length) {
145      // Move dangerous item to the first position if it exists, this ensures that all dangerous code runs first.
146      entries = sortMods(entries, orders[platformName] ?? ['dangerous']);
147      debug(`run in order: ${entries.map(([name]) => name).join(', ')}`);
148      const platformProjectRoot = path.join(projectRoot, platformName);
149      const projectName =
150        platformName === 'ios' ? getHackyProjectName(projectRoot, config) : undefined;
151
152      for (const [modName, mod] of entries) {
153        const modRequest = {
154          projectRoot,
155          projectName,
156          platformProjectRoot,
157          platform: platformName as ModPlatform,
158          modName,
159          introspect: !!introspect,
160        };
161
162        if (!(mod as Mod).isProvider) {
163          // In strict mode, throw an error.
164          const errorMessage = `Initial base modifier for "${platformName}.${modName}" is not a provider and therefore will not provide modResults to child mods`;
165          if (assertMissingModProviders !== false) {
166            throw new PluginError(errorMessage, 'MISSING_PROVIDER');
167          } else {
168            Warnings.addWarningForPlatform(
169              platformName as ModPlatform,
170              `${platformName}.${modName}`,
171              `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.`
172            );
173            // In loose mode, just skip the mod entirely.
174            continue;
175          }
176        }
177
178        const results = await (mod as Mod)({
179          ...config,
180          modResults: null,
181          modRequest,
182          modRawConfig,
183        });
184
185        // Sanity check to help locate non compliant mods.
186        config = assertModResults(results, platformName, modName);
187        // @ts-ignore: `modResults` is added for modifications
188        delete config.modResults;
189        // @ts-ignore: `modRequest` is added for modifications
190        delete config.modRequest;
191        // @ts-ignore: `modRawConfig` is added for modifications
192        delete config.modRawConfig;
193      }
194    }
195  }
196
197  return config;
198}
199