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