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