1import JsonFile from '@expo/json-file';
2import chalk from 'chalk';
3import fs from 'fs-extra';
4import path from 'path';
5
6import { Directories } from '../expotools';
7import AndroidMacrosGenerator from './AndroidMacrosGenerator';
8import IosMacrosGenerator from './IosMacrosGenerator';
9import macros from './macros';
10
11const EXPO_DIR = Directories.getExpoRepositoryRootDir();
12
13type TemplateSubstitutions = {
14  [key: string]: string;
15};
16
17async function getTemplateSubstitutionsFromSecrets(): Promise<TemplateSubstitutions> {
18  try {
19    return await new JsonFile<TemplateSubstitutions>(
20      path.join(EXPO_DIR, 'secrets', 'keys.json')
21    ).readAsync();
22  } catch {
23    // Don't have access to decrypted secrets, use public keys
24    console.log(
25      "You don't have access to decrypted secrets. Falling back to `template-files/keys.json`."
26    );
27    return await new JsonFile<TemplateSubstitutions>(
28      path.join(EXPO_DIR, 'template-files', 'keys.json')
29    ).readAsync();
30  }
31}
32
33async function getTemplateSubstitutionsAsync() {
34  const defaultKeys = await getTemplateSubstitutionsFromSecrets();
35
36  try {
37    // Keys from secrets/template-files can be overwritten by private-keys.json file.
38    const privateKeys = await new JsonFile(path.join(EXPO_DIR, 'private-keys.json')).readAsync();
39    return { ...defaultKeys, ...privateKeys };
40  } catch {
41    return defaultKeys;
42  }
43}
44
45async function generateMacrosAsync(platform, configuration) {
46  const macrosObject = {};
47
48  console.log('Resolving macros...');
49
50  for (const [name, func] of Object.entries(macros)) {
51    // @ts-ignore
52    const macroValue = await func.call(macros, platform, configuration);
53
54    macrosObject[name] = macroValue;
55
56    console.log(
57      'Resolved %s macro to %s',
58      chalk.green(name),
59      chalk.yellow(JSON.stringify(macroValue))
60    );
61  }
62  console.log();
63  return macrosObject;
64}
65
66function getMacrosGeneratorForPlatform(platform) {
67  if (platform === 'ios') {
68    return new IosMacrosGenerator();
69  }
70  if (platform === 'android') {
71    return new AndroidMacrosGenerator();
72  }
73  throw new Error(`Platform '${platform}' is not supported.`);
74}
75
76function getSkippedTemplates(isBare: boolean): string[] {
77  if (isBare) {
78    return ['AndroidManifest.xml', 'google-services.json'];
79  }
80
81  return [];
82}
83
84async function generateDynamicMacrosAsync(args) {
85  try {
86    const { platform, bareExpo } = args;
87    const templateSubstitutions = await getTemplateSubstitutionsAsync();
88
89    if (!bareExpo) {
90      const macros = await generateMacrosAsync(platform, args.configuration);
91      const macrosGenerator = getMacrosGeneratorForPlatform(platform);
92      await macrosGenerator.generateAsync({ ...args, macros, templateSubstitutions });
93    }
94    // Copy template files - it is platform-agnostic.
95    await copyTemplateFilesAsync(
96      platform,
97      { ...args, skipTemplates: getSkippedTemplates(bareExpo) },
98      templateSubstitutions
99    );
100  } catch (error) {
101    console.error(
102      `There was an error while generating Expo template files, which could lead to unexpected behavior at runtime:\n${error.stack}`
103    );
104    process.exit(1);
105  }
106}
107
108async function readExistingSourceAsync(filepath): Promise<string | null> {
109  try {
110    return await fs.readFile(filepath, 'utf8');
111  } catch {
112    return null;
113  }
114}
115
116async function copyTemplateFileAsync(
117  source: string,
118  dest: string,
119  templateSubstitutions: TemplateSubstitutions,
120  configuration,
121  isOptional: boolean
122): Promise<void> {
123  let [currentSourceFile, currentDestFile] = await Promise.all([
124    readExistingSourceAsync(source),
125    readExistingSourceAsync(dest),
126  ]);
127
128  if (!currentSourceFile) {
129    console.error(`Couldn't find ${chalk.magenta(source)} file.`);
130    process.exit(1);
131  }
132
133  for (const [textToReplace, value] of Object.entries(templateSubstitutions)) {
134    currentSourceFile = currentSourceFile.replace(
135      new RegExp(`\\$\\{${textToReplace}\\}`, 'g'),
136      value
137    );
138  }
139
140  if (configuration === 'debug') {
141    // We need these permissions when testing but don't want them
142    // ending up in our release.
143    currentSourceFile = currentSourceFile.replace(
144      `<!-- ADD TEST PERMISSIONS HERE -->`,
145      `<uses-permission android:name="android.permission.WRITE_CONTACTS" />`
146    );
147  }
148
149  if (currentSourceFile !== currentDestFile) {
150    try {
151      await fs.writeFile(dest, currentSourceFile, 'utf8');
152    } catch (error) {
153      if (!isOptional) throw error;
154    }
155  }
156}
157
158type TemplatePaths = Record<string, string>;
159type TemplatePathsFile = {
160  paths: TemplatePaths;
161  generateOnly: TemplatePaths;
162};
163
164async function copyTemplateFilesAsync(platform: string, args: any, templateSubstitutions: any) {
165  const templateFilesPath = args.templateFilesPath || path.join(EXPO_DIR, 'template-files');
166  const templatePathsFile = (await new JsonFile(
167    path.join(templateFilesPath, `${platform}-paths.json`)
168  ).readAsync()) as TemplatePathsFile;
169  const templatePaths = { ...templatePathsFile.paths, ...templatePathsFile.generateOnly };
170  const checkIgnoredTemplatePaths = Object.values(templatePathsFile.generateOnly);
171  const promises: Promise<any>[] = [];
172  const skipTemplates: string[] = args.skipTemplates || [];
173  for (const [source, dest] of Object.entries(templatePaths)) {
174    if (skipTemplates.includes(source)) {
175      console.log(
176        'Skipping template %s ...',
177        chalk.cyan(path.join(templateFilesPath, platform, source))
178      );
179      continue;
180    }
181
182    const isOptional = checkIgnoredTemplatePaths.includes(dest);
183    console.log(
184      'Rendering %s from template %s %s...',
185      chalk.cyan(path.join(EXPO_DIR, dest)),
186      chalk.cyan(path.join(templateFilesPath, platform, source)),
187      isOptional ? chalk.yellow('(Optional) ') : ''
188    );
189
190    promises.push(
191      copyTemplateFileAsync(
192        path.join(templateFilesPath, platform, source),
193        path.join(EXPO_DIR, dest),
194        templateSubstitutions,
195        args.configuration,
196        isOptional
197      )
198    );
199  }
200
201  await Promise.all(promises);
202}
203
204export { generateDynamicMacrosAsync, getTemplateSubstitutionsAsync };
205