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