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