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 (e) {
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 (error) {
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
76async function generateDynamicMacrosAsync(args) {
77  try {
78    const { platform } = args;
79    const templateSubstitutions = await getTemplateSubstitutionsAsync();
80
81    const macros = await generateMacrosAsync(platform, args.configuration);
82    const macrosGenerator = getMacrosGeneratorForPlatform(platform);
83
84    await macrosGenerator.generateAsync({ ...args, macros, templateSubstitutions });
85
86    // Copy template files - it is platform-agnostic.
87    await copyTemplateFilesAsync(platform, args, templateSubstitutions);
88  } catch (error) {
89    console.error(
90      `There was an error while generating Expo template files, which could lead to unexpected behavior at runtime:\n${error.stack}`
91    );
92    process.exit(1);
93  }
94}
95
96async function readExistingSourceAsync(filepath): Promise<string | null> {
97  try {
98    return await fs.readFile(filepath, 'utf8');
99  } catch (e) {
100    return null;
101  }
102}
103
104async function copyTemplateFileAsync(
105  source: string,
106  dest: string,
107  templateSubstitutions: TemplateSubstitutions,
108  configuration,
109  isOptional: boolean
110): Promise<void> {
111  let [currentSourceFile, currentDestFile] = await Promise.all([
112    readExistingSourceAsync(source),
113    readExistingSourceAsync(dest),
114  ]);
115
116  if (!currentSourceFile) {
117    console.error(`Couldn't find ${chalk.magenta(source)} file.`);
118    process.exit(1);
119  }
120
121  for (const [textToReplace, value] of Object.entries(templateSubstitutions)) {
122    currentSourceFile = currentSourceFile.replace(
123      new RegExp(`\\$\\{${textToReplace}\\}`, 'g'),
124      value
125    );
126  }
127
128  if (configuration === 'debug') {
129    // We need these permissions when testing but don't want them
130    // ending up in our release.
131    currentSourceFile = currentSourceFile.replace(
132      `<!-- ADD TEST PERMISSIONS HERE -->`,
133      `<uses-permission android:name="android.permission.WRITE_CONTACTS" />`
134    );
135  }
136
137  if (currentSourceFile !== currentDestFile) {
138    try {
139      await fs.writeFile(dest, currentSourceFile, 'utf8');
140    } catch (error) {
141      if (!isOptional) throw error;
142    }
143  }
144}
145
146type TemplatePaths = Record<string, string>;
147type TemplatePathsFile = {
148  paths: TemplatePaths;
149  generateOnly: TemplatePaths;
150};
151
152async function copyTemplateFilesAsync(platform: string, args: any, templateSubstitutions: any) {
153  const templateFilesPath = args.templateFilesPath || path.join(EXPO_DIR, 'template-files');
154  const templatePathsFile = (await new JsonFile(
155    path.join(templateFilesPath, `${platform}-paths.json`)
156  ).readAsync()) as TemplatePathsFile;
157  const templatePaths = { ...templatePathsFile.paths, ...templatePathsFile.generateOnly };
158  const checkIgnoredTemplatePaths = Object.values(templatePathsFile.generateOnly);
159  const promises: Promise<any>[] = [];
160  const skipTemplates: string[] = args.skipTemplates || [];
161  for (const [source, dest] of Object.entries(templatePaths)) {
162    if (skipTemplates.includes(source)) {
163      console.log(
164        'Skipping template %s ...',
165        chalk.cyan(path.join(templateFilesPath, platform, source))
166      );
167      continue;
168    }
169
170    const isOptional = checkIgnoredTemplatePaths.includes(dest);
171    console.log(
172      'Rendering %s from template %s %s...',
173      chalk.cyan(path.join(EXPO_DIR, dest)),
174      chalk.cyan(path.join(templateFilesPath, platform, source)),
175      isOptional ? chalk.yellow('(Optional) ') : ''
176    );
177
178    promises.push(
179      copyTemplateFileAsync(
180        path.join(templateFilesPath, platform, source),
181        path.join(EXPO_DIR, dest),
182        templateSubstitutions,
183        args.configuration,
184        isOptional
185      )
186    );
187  }
188
189  await Promise.all(promises);
190}
191
192export { generateDynamicMacrosAsync, getTemplateSubstitutionsAsync };
193