1import * as path from 'path';
2import resolveFrom from 'resolve-from';
3import xcode from 'xcode';
4
5import { ConfigPlugin } from '../Plugin.types';
6import { withExpoPlist } from '../plugins/ios-plugins';
7import {
8  ExpoConfigUpdates,
9  getExpoUpdatesPackageVersion,
10  getRuntimeVersionNullable,
11  getSDKVersion,
12  getUpdatesCheckOnLaunch,
13  getUpdatesCodeSigningCertificate,
14  getUpdatesCodeSigningMetadata,
15  getUpdatesEnabled,
16  getUpdatesTimeout,
17  getUpdateUrl,
18} from '../utils/Updates';
19import { ExpoPlist } from './IosConfig.types';
20
21const CREATE_MANIFEST_IOS_PATH = 'expo-updates/scripts/create-manifest-ios.sh';
22
23export enum Config {
24  ENABLED = 'EXUpdatesEnabled',
25  CHECK_ON_LAUNCH = 'EXUpdatesCheckOnLaunch',
26  LAUNCH_WAIT_MS = 'EXUpdatesLaunchWaitMs',
27  RUNTIME_VERSION = 'EXUpdatesRuntimeVersion',
28  SDK_VERSION = 'EXUpdatesSDKVersion',
29  UPDATE_URL = 'EXUpdatesURL',
30  RELEASE_CHANNEL = 'EXUpdatesReleaseChannel',
31  UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY = 'EXUpdatesRequestHeaders',
32  CODE_SIGNING_CERTIFICATE = 'EXUpdatesCodeSigningCertificate',
33  CODE_SIGNING_METADATA = 'EXUpdatesCodeSigningMetadata',
34}
35
36export const withUpdates: ConfigPlugin<{ expoUsername: string | null }> = (
37  config,
38  { expoUsername }
39) => {
40  return withExpoPlist(config, (config) => {
41    const projectRoot = config.modRequest.projectRoot;
42    const expoUpdatesPackageVersion = getExpoUpdatesPackageVersion(projectRoot);
43    config.modResults = setUpdatesConfig(
44      projectRoot,
45      config,
46      config.modResults,
47      expoUsername,
48      expoUpdatesPackageVersion
49    );
50    return config;
51  });
52};
53
54export function setUpdatesConfig(
55  projectRoot: string,
56  config: ExpoConfigUpdates,
57  expoPlist: ExpoPlist,
58  username: string | null,
59  expoUpdatesPackageVersion?: string | null
60): ExpoPlist {
61  const newExpoPlist = {
62    ...expoPlist,
63    [Config.ENABLED]: getUpdatesEnabled(config),
64    [Config.CHECK_ON_LAUNCH]: getUpdatesCheckOnLaunch(config, expoUpdatesPackageVersion),
65    [Config.LAUNCH_WAIT_MS]: getUpdatesTimeout(config),
66  };
67
68  const updateUrl = getUpdateUrl(config, username);
69  if (updateUrl) {
70    newExpoPlist[Config.UPDATE_URL] = updateUrl;
71  } else {
72    delete newExpoPlist[Config.UPDATE_URL];
73  }
74
75  const codeSigningCertificate = getUpdatesCodeSigningCertificate(projectRoot, config);
76  if (codeSigningCertificate) {
77    newExpoPlist[Config.CODE_SIGNING_CERTIFICATE] = codeSigningCertificate;
78  } else {
79    delete newExpoPlist[Config.CODE_SIGNING_CERTIFICATE];
80  }
81
82  const codeSigningMetadata = getUpdatesCodeSigningMetadata(config);
83  if (codeSigningMetadata) {
84    newExpoPlist[Config.CODE_SIGNING_METADATA] = codeSigningMetadata;
85  } else {
86    delete newExpoPlist[Config.CODE_SIGNING_METADATA];
87  }
88
89  return setVersionsConfig(config, newExpoPlist);
90}
91
92export function setVersionsConfig(config: ExpoConfigUpdates, expoPlist: ExpoPlist): ExpoPlist {
93  const newExpoPlist = { ...expoPlist };
94
95  const runtimeVersion = getRuntimeVersionNullable(config, 'ios');
96  if (!runtimeVersion && expoPlist[Config.RUNTIME_VERSION]) {
97    throw new Error(
98      'A runtime version is set in your Expo.plist, but is missing from your app.json/app.config.js. Please either set runtimeVersion in your app.json/app.config.js or remove EXUpdatesRuntimeVersion from your Expo.plist.'
99    );
100  }
101  const sdkVersion = getSDKVersion(config);
102  if (runtimeVersion) {
103    delete newExpoPlist[Config.SDK_VERSION];
104    newExpoPlist[Config.RUNTIME_VERSION] = runtimeVersion;
105  } else if (sdkVersion) {
106    /**
107     * runtime version maybe null in projects using classic updates. In that
108     * case we use SDK version
109     */
110    delete newExpoPlist[Config.RUNTIME_VERSION];
111    newExpoPlist[Config.SDK_VERSION] = sdkVersion;
112  } else {
113    delete newExpoPlist[Config.SDK_VERSION];
114    delete newExpoPlist[Config.RUNTIME_VERSION];
115  }
116
117  return newExpoPlist;
118}
119
120function formatConfigurationScriptPath(projectRoot: string): string {
121  const buildScriptPath = resolveFrom.silent(projectRoot, CREATE_MANIFEST_IOS_PATH);
122
123  if (!buildScriptPath) {
124    throw new Error(
125      "Could not find the build script for iOS. This could happen in case of outdated 'node_modules'. Run 'npm install' to make sure that it's up-to-date."
126    );
127  }
128
129  const relativePath = path.relative(path.join(projectRoot, 'ios'), buildScriptPath);
130  return process.platform === 'win32' ? relativePath.replace(/\\/g, '/') : relativePath;
131}
132
133interface ShellScriptBuildPhase {
134  isa: 'PBXShellScriptBuildPhase';
135  name: string;
136  shellScript: string;
137  [key: string]: any;
138}
139
140export function getBundleReactNativePhase(project: xcode.XcodeProject): ShellScriptBuildPhase {
141  const shellScriptBuildPhase = project.hash.project.objects.PBXShellScriptBuildPhase as Record<
142    string,
143    ShellScriptBuildPhase
144  >;
145  const bundleReactNative = Object.values(shellScriptBuildPhase).find(
146    (buildPhase) => buildPhase.name === '"Bundle React Native code and images"'
147  );
148
149  if (!bundleReactNative) {
150    throw new Error(`Couldn't find a build phase "Bundle React Native code and images"`);
151  }
152
153  return bundleReactNative;
154}
155
156export function ensureBundleReactNativePhaseContainsConfigurationScript(
157  projectRoot: string,
158  project: xcode.XcodeProject
159): xcode.XcodeProject {
160  const bundleReactNative = getBundleReactNativePhase(project);
161  const buildPhaseShellScriptPath = formatConfigurationScriptPath(projectRoot);
162
163  if (!isShellScriptBuildPhaseConfigured(projectRoot, project)) {
164    // check if there's already another path to create-manifest-ios.sh
165    // this might be the case for monorepos
166    if (bundleReactNative.shellScript.includes(CREATE_MANIFEST_IOS_PATH)) {
167      bundleReactNative.shellScript = bundleReactNative.shellScript.replace(
168        new RegExp(`(\\\\n)(\\.\\.)+/node_modules/${CREATE_MANIFEST_IOS_PATH}`),
169        ''
170      );
171    }
172    bundleReactNative.shellScript = `${bundleReactNative.shellScript.replace(
173      /"$/,
174      ''
175    )}${buildPhaseShellScriptPath}\\n"`;
176  }
177  return project;
178}
179
180export function isShellScriptBuildPhaseConfigured(
181  projectRoot: string,
182  project: xcode.XcodeProject
183): boolean {
184  const bundleReactNative = getBundleReactNativePhase(project);
185  const buildPhaseShellScriptPath = formatConfigurationScriptPath(projectRoot);
186  return bundleReactNative.shellScript.includes(buildPhaseShellScriptPath);
187}
188
189export function isPlistConfigurationSet(expoPlist: ExpoPlist): boolean {
190  return Boolean(
191    expoPlist.EXUpdatesURL && (expoPlist.EXUpdatesSDKVersion || expoPlist.EXUpdatesRuntimeVersion)
192  );
193}
194
195export function isPlistConfigurationSynced(
196  projectRoot: string,
197  config: ExpoConfigUpdates,
198  expoPlist: ExpoPlist,
199  username: string | null
200): boolean {
201  return (
202    getUpdateUrl(config, username) === expoPlist.EXUpdatesURL &&
203    getUpdatesEnabled(config) === expoPlist.EXUpdatesEnabled &&
204    getUpdatesTimeout(config) === expoPlist.EXUpdatesLaunchWaitMs &&
205    getUpdatesCheckOnLaunch(config) === expoPlist.EXUpdatesCheckOnLaunch &&
206    getUpdatesCodeSigningCertificate(projectRoot, config) ===
207      expoPlist.EXUpdatesCodeSigningCertificate &&
208    getUpdatesCodeSigningMetadata(config) === expoPlist.EXUpdatesCodeSigningMetadata &&
209    isPlistVersionConfigurationSynced(config, expoPlist)
210  );
211}
212
213export function isPlistVersionConfigurationSynced(
214  config: Pick<ExpoConfigUpdates, 'sdkVersion' | 'runtimeVersion'>,
215  expoPlist: ExpoPlist
216): boolean {
217  const expectedRuntimeVersion = getRuntimeVersionNullable(config, 'ios');
218  const expectedSdkVersion = getSDKVersion(config);
219
220  const currentRuntimeVersion = expoPlist.EXUpdatesRuntimeVersion ?? null;
221  const currentSdkVersion = expoPlist.EXUpdatesSDKVersion ?? null;
222
223  if (expectedRuntimeVersion !== null) {
224    return currentRuntimeVersion === expectedRuntimeVersion && currentSdkVersion === null;
225  } else if (expectedSdkVersion !== null) {
226    return currentSdkVersion === expectedSdkVersion && currentRuntimeVersion === null;
227  } else {
228    return true;
229  }
230}
231