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  getUpdatesRequestHeaders,
16  getUpdatesEnabled,
17  getUpdatesTimeout,
18  getUpdateUrl,
19} from '../utils/Updates';
20import { ExpoPlist } from './IosConfig.types';
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
39export const withUpdates: ConfigPlugin<{ expoUsername: string | null }> = (
40  config,
41  { expoUsername }
42) => {
43  return withExpoPlist(config, (config) => {
44    const projectRoot = config.modRequest.projectRoot;
45    const expoUpdatesPackageVersion = getExpoUpdatesPackageVersion(projectRoot);
46    config.modResults = setUpdatesConfig(
47      projectRoot,
48      config,
49      config.modResults,
50      expoUsername,
51      expoUpdatesPackageVersion
52    );
53    return config;
54  });
55};
56
57export function setUpdatesConfig(
58  projectRoot: string,
59  config: ExpoConfigUpdates,
60  expoPlist: ExpoPlist,
61  username: string | null,
62  expoUpdatesPackageVersion?: string | null
63): ExpoPlist {
64  const newExpoPlist = {
65    ...expoPlist,
66    [Config.ENABLED]: getUpdatesEnabled(config, username),
67    [Config.CHECK_ON_LAUNCH]: getUpdatesCheckOnLaunch(config, expoUpdatesPackageVersion),
68    [Config.LAUNCH_WAIT_MS]: getUpdatesTimeout(config),
69  };
70
71  const updateUrl = getUpdateUrl(config, username);
72  if (updateUrl) {
73    newExpoPlist[Config.UPDATE_URL] = updateUrl;
74  } else {
75    delete newExpoPlist[Config.UPDATE_URL];
76  }
77
78  const codeSigningCertificate = getUpdatesCodeSigningCertificate(projectRoot, config);
79  if (codeSigningCertificate) {
80    newExpoPlist[Config.CODE_SIGNING_CERTIFICATE] = codeSigningCertificate;
81  } else {
82    delete newExpoPlist[Config.CODE_SIGNING_CERTIFICATE];
83  }
84
85  const codeSigningMetadata = getUpdatesCodeSigningMetadata(config);
86  if (codeSigningMetadata) {
87    newExpoPlist[Config.CODE_SIGNING_METADATA] = codeSigningMetadata;
88  } else {
89    delete newExpoPlist[Config.CODE_SIGNING_METADATA];
90  }
91
92  const requestHeaders = getUpdatesRequestHeaders(config);
93  if (requestHeaders) {
94    newExpoPlist[Config.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY] = requestHeaders;
95  } else {
96    delete newExpoPlist[Config.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY];
97  }
98
99  return setVersionsConfig(config, newExpoPlist);
100}
101
102export function setVersionsConfig(config: ExpoConfigUpdates, expoPlist: ExpoPlist): ExpoPlist {
103  const newExpoPlist = { ...expoPlist };
104
105  const runtimeVersion = getRuntimeVersionNullable(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 function isPlistConfigurationSynced(
206  projectRoot: string,
207  config: ExpoConfigUpdates,
208  expoPlist: ExpoPlist,
209  username: string | null
210): boolean {
211  return (
212    getUpdateUrl(config, username) === expoPlist.EXUpdatesURL &&
213    getUpdatesEnabled(config, username) === expoPlist.EXUpdatesEnabled &&
214    getUpdatesTimeout(config) === expoPlist.EXUpdatesLaunchWaitMs &&
215    getUpdatesCheckOnLaunch(config) === expoPlist.EXUpdatesCheckOnLaunch &&
216    getUpdatesCodeSigningCertificate(projectRoot, config) ===
217      expoPlist.EXUpdatesCodeSigningCertificate &&
218    getUpdatesCodeSigningMetadata(config) === expoPlist.EXUpdatesCodeSigningMetadata &&
219    isPlistVersionConfigurationSynced(config, expoPlist)
220  );
221}
222
223export function isPlistVersionConfigurationSynced(
224  config: Pick<ExpoConfigUpdates, 'sdkVersion' | 'runtimeVersion'>,
225  expoPlist: ExpoPlist
226): boolean {
227  const expectedRuntimeVersion = getRuntimeVersionNullable(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