1082815dcSEvan Baconimport * as path from 'path';
2082815dcSEvan Baconimport resolveFrom from 'resolve-from';
3082815dcSEvan Baconimport xcode from 'xcode';
4082815dcSEvan Bacon
58a424bebSJames Ideimport { ExpoPlist } from './IosConfig.types';
6082815dcSEvan Baconimport { ConfigPlugin } from '../Plugin.types';
7082815dcSEvan Baconimport { withExpoPlist } from '../plugins/ios-plugins';
8082815dcSEvan Baconimport {
9082815dcSEvan Bacon  ExpoConfigUpdates,
10082815dcSEvan Bacon  getExpoUpdatesPackageVersion,
11*f0d67e12SMateus Craveiro  getRuntimeVersionNullableAsync,
12082815dcSEvan Bacon  getSDKVersion,
13082815dcSEvan Bacon  getUpdatesCheckOnLaunch,
14082815dcSEvan Bacon  getUpdatesCodeSigningCertificate,
15082815dcSEvan Bacon  getUpdatesCodeSigningMetadata,
1609bd1012SUmberto Ghio  getUpdatesRequestHeaders,
17082815dcSEvan Bacon  getUpdatesEnabled,
18082815dcSEvan Bacon  getUpdatesTimeout,
19082815dcSEvan Bacon  getUpdateUrl,
20082815dcSEvan Bacon} from '../utils/Updates';
21082815dcSEvan Bacon
22082815dcSEvan Baconconst CREATE_MANIFEST_IOS_PATH = 'expo-updates/scripts/create-manifest-ios.sh';
23082815dcSEvan Bacon
24082815dcSEvan Baconexport enum Config {
25082815dcSEvan Bacon  ENABLED = 'EXUpdatesEnabled',
26082815dcSEvan Bacon  CHECK_ON_LAUNCH = 'EXUpdatesCheckOnLaunch',
27082815dcSEvan Bacon  LAUNCH_WAIT_MS = 'EXUpdatesLaunchWaitMs',
28082815dcSEvan Bacon  RUNTIME_VERSION = 'EXUpdatesRuntimeVersion',
29082815dcSEvan Bacon  SDK_VERSION = 'EXUpdatesSDKVersion',
30082815dcSEvan Bacon  UPDATE_URL = 'EXUpdatesURL',
31082815dcSEvan Bacon  RELEASE_CHANNEL = 'EXUpdatesReleaseChannel',
32082815dcSEvan Bacon  UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY = 'EXUpdatesRequestHeaders',
33082815dcSEvan Bacon  CODE_SIGNING_CERTIFICATE = 'EXUpdatesCodeSigningCertificate',
34082815dcSEvan Bacon  CODE_SIGNING_METADATA = 'EXUpdatesCodeSigningMetadata',
35082815dcSEvan Bacon}
36082815dcSEvan Bacon
37ae9cd511SWill Schurman// when making changes to this config plugin, ensure the same changes are also made in eas-cli and build-tools
38da5c7fcdSQuinlan Jung// Also ensure the docs are up-to-date: https://docs.expo.dev/bare/installing-updates/
39ae9cd511SWill Schurman
4082ade864SWill Schurmanexport const withUpdates: ConfigPlugin = (config) => {
41*f0d67e12SMateus Craveiro  return withExpoPlist(config, async (config) => {
42082815dcSEvan Bacon    const projectRoot = config.modRequest.projectRoot;
43082815dcSEvan Bacon    const expoUpdatesPackageVersion = getExpoUpdatesPackageVersion(projectRoot);
44*f0d67e12SMateus Craveiro    config.modResults = await setUpdatesConfigAsync(
45082815dcSEvan Bacon      projectRoot,
46082815dcSEvan Bacon      config,
47082815dcSEvan Bacon      config.modResults,
48082815dcSEvan Bacon      expoUpdatesPackageVersion
49082815dcSEvan Bacon    );
50082815dcSEvan Bacon    return config;
51082815dcSEvan Bacon  });
52082815dcSEvan Bacon};
53082815dcSEvan Bacon
54*f0d67e12SMateus Craveiroexport async function setUpdatesConfigAsync(
55082815dcSEvan Bacon  projectRoot: string,
56082815dcSEvan Bacon  config: ExpoConfigUpdates,
57082815dcSEvan Bacon  expoPlist: ExpoPlist,
58082815dcSEvan Bacon  expoUpdatesPackageVersion?: string | null
59*f0d67e12SMateus Craveiro): Promise<ExpoPlist> {
60082815dcSEvan Bacon  const newExpoPlist = {
61082815dcSEvan Bacon    ...expoPlist,
6282ade864SWill Schurman    [Config.ENABLED]: getUpdatesEnabled(config),
63082815dcSEvan Bacon    [Config.CHECK_ON_LAUNCH]: getUpdatesCheckOnLaunch(config, expoUpdatesPackageVersion),
64082815dcSEvan Bacon    [Config.LAUNCH_WAIT_MS]: getUpdatesTimeout(config),
65082815dcSEvan Bacon  };
66082815dcSEvan Bacon
6782ade864SWill Schurman  const updateUrl = getUpdateUrl(config);
68082815dcSEvan Bacon  if (updateUrl) {
69082815dcSEvan Bacon    newExpoPlist[Config.UPDATE_URL] = updateUrl;
70082815dcSEvan Bacon  } else {
71082815dcSEvan Bacon    delete newExpoPlist[Config.UPDATE_URL];
72082815dcSEvan Bacon  }
73082815dcSEvan Bacon
74082815dcSEvan Bacon  const codeSigningCertificate = getUpdatesCodeSigningCertificate(projectRoot, config);
75082815dcSEvan Bacon  if (codeSigningCertificate) {
76082815dcSEvan Bacon    newExpoPlist[Config.CODE_SIGNING_CERTIFICATE] = codeSigningCertificate;
77082815dcSEvan Bacon  } else {
78082815dcSEvan Bacon    delete newExpoPlist[Config.CODE_SIGNING_CERTIFICATE];
79082815dcSEvan Bacon  }
80082815dcSEvan Bacon
81082815dcSEvan Bacon  const codeSigningMetadata = getUpdatesCodeSigningMetadata(config);
82082815dcSEvan Bacon  if (codeSigningMetadata) {
83082815dcSEvan Bacon    newExpoPlist[Config.CODE_SIGNING_METADATA] = codeSigningMetadata;
84082815dcSEvan Bacon  } else {
85082815dcSEvan Bacon    delete newExpoPlist[Config.CODE_SIGNING_METADATA];
86082815dcSEvan Bacon  }
87082815dcSEvan Bacon
8809bd1012SUmberto Ghio  const requestHeaders = getUpdatesRequestHeaders(config);
8909bd1012SUmberto Ghio  if (requestHeaders) {
9009bd1012SUmberto Ghio    newExpoPlist[Config.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY] = requestHeaders;
9109bd1012SUmberto Ghio  } else {
9209bd1012SUmberto Ghio    delete newExpoPlist[Config.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY];
9309bd1012SUmberto Ghio  }
9409bd1012SUmberto Ghio
95*f0d67e12SMateus Craveiro  return await setVersionsConfigAsync(projectRoot, config, newExpoPlist);
96082815dcSEvan Bacon}
97082815dcSEvan Bacon
98*f0d67e12SMateus Craveiroexport async function setVersionsConfigAsync(
99*f0d67e12SMateus Craveiro  projectRoot: string,
100*f0d67e12SMateus Craveiro  config: ExpoConfigUpdates,
101*f0d67e12SMateus Craveiro  expoPlist: ExpoPlist
102*f0d67e12SMateus Craveiro): Promise<ExpoPlist> {
103082815dcSEvan Bacon  const newExpoPlist = { ...expoPlist };
104082815dcSEvan Bacon
105*f0d67e12SMateus Craveiro  const runtimeVersion = await getRuntimeVersionNullableAsync(projectRoot, config, 'ios');
106082815dcSEvan Bacon  if (!runtimeVersion && expoPlist[Config.RUNTIME_VERSION]) {
107082815dcSEvan Bacon    throw new Error(
108082815dcSEvan Bacon      '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.'
109082815dcSEvan Bacon    );
110082815dcSEvan Bacon  }
111082815dcSEvan Bacon  const sdkVersion = getSDKVersion(config);
112082815dcSEvan Bacon  if (runtimeVersion) {
113082815dcSEvan Bacon    delete newExpoPlist[Config.SDK_VERSION];
114082815dcSEvan Bacon    newExpoPlist[Config.RUNTIME_VERSION] = runtimeVersion;
115082815dcSEvan Bacon  } else if (sdkVersion) {
116082815dcSEvan Bacon    /**
117082815dcSEvan Bacon     * runtime version maybe null in projects using classic updates. In that
118082815dcSEvan Bacon     * case we use SDK version
119082815dcSEvan Bacon     */
120082815dcSEvan Bacon    delete newExpoPlist[Config.RUNTIME_VERSION];
121082815dcSEvan Bacon    newExpoPlist[Config.SDK_VERSION] = sdkVersion;
122082815dcSEvan Bacon  } else {
123082815dcSEvan Bacon    delete newExpoPlist[Config.SDK_VERSION];
124082815dcSEvan Bacon    delete newExpoPlist[Config.RUNTIME_VERSION];
125082815dcSEvan Bacon  }
126082815dcSEvan Bacon
127082815dcSEvan Bacon  return newExpoPlist;
128082815dcSEvan Bacon}
129082815dcSEvan Bacon
130082815dcSEvan Baconfunction formatConfigurationScriptPath(projectRoot: string): string {
131082815dcSEvan Bacon  const buildScriptPath = resolveFrom.silent(projectRoot, CREATE_MANIFEST_IOS_PATH);
132082815dcSEvan Bacon
133082815dcSEvan Bacon  if (!buildScriptPath) {
134082815dcSEvan Bacon    throw new Error(
135082815dcSEvan Bacon      "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."
136082815dcSEvan Bacon    );
137082815dcSEvan Bacon  }
138082815dcSEvan Bacon
139082815dcSEvan Bacon  const relativePath = path.relative(path.join(projectRoot, 'ios'), buildScriptPath);
140082815dcSEvan Bacon  return process.platform === 'win32' ? relativePath.replace(/\\/g, '/') : relativePath;
141082815dcSEvan Bacon}
142082815dcSEvan Bacon
143082815dcSEvan Baconinterface ShellScriptBuildPhase {
144082815dcSEvan Bacon  isa: 'PBXShellScriptBuildPhase';
145082815dcSEvan Bacon  name: string;
146082815dcSEvan Bacon  shellScript: string;
147082815dcSEvan Bacon  [key: string]: any;
148082815dcSEvan Bacon}
149082815dcSEvan Bacon
150082815dcSEvan Baconexport function getBundleReactNativePhase(project: xcode.XcodeProject): ShellScriptBuildPhase {
151082815dcSEvan Bacon  const shellScriptBuildPhase = project.hash.project.objects.PBXShellScriptBuildPhase as Record<
152082815dcSEvan Bacon    string,
153082815dcSEvan Bacon    ShellScriptBuildPhase
154082815dcSEvan Bacon  >;
155082815dcSEvan Bacon  const bundleReactNative = Object.values(shellScriptBuildPhase).find(
156082815dcSEvan Bacon    (buildPhase) => buildPhase.name === '"Bundle React Native code and images"'
157082815dcSEvan Bacon  );
158082815dcSEvan Bacon
159082815dcSEvan Bacon  if (!bundleReactNative) {
160082815dcSEvan Bacon    throw new Error(`Couldn't find a build phase "Bundle React Native code and images"`);
161082815dcSEvan Bacon  }
162082815dcSEvan Bacon
163082815dcSEvan Bacon  return bundleReactNative;
164082815dcSEvan Bacon}
165082815dcSEvan Bacon
166082815dcSEvan Baconexport function ensureBundleReactNativePhaseContainsConfigurationScript(
167082815dcSEvan Bacon  projectRoot: string,
168082815dcSEvan Bacon  project: xcode.XcodeProject
169082815dcSEvan Bacon): xcode.XcodeProject {
170082815dcSEvan Bacon  const bundleReactNative = getBundleReactNativePhase(project);
171082815dcSEvan Bacon  const buildPhaseShellScriptPath = formatConfigurationScriptPath(projectRoot);
172082815dcSEvan Bacon
173082815dcSEvan Bacon  if (!isShellScriptBuildPhaseConfigured(projectRoot, project)) {
174082815dcSEvan Bacon    // check if there's already another path to create-manifest-ios.sh
175082815dcSEvan Bacon    // this might be the case for monorepos
176082815dcSEvan Bacon    if (bundleReactNative.shellScript.includes(CREATE_MANIFEST_IOS_PATH)) {
177082815dcSEvan Bacon      bundleReactNative.shellScript = bundleReactNative.shellScript.replace(
178082815dcSEvan Bacon        new RegExp(`(\\\\n)(\\.\\.)+/node_modules/${CREATE_MANIFEST_IOS_PATH}`),
179082815dcSEvan Bacon        ''
180082815dcSEvan Bacon      );
181082815dcSEvan Bacon    }
182082815dcSEvan Bacon    bundleReactNative.shellScript = `${bundleReactNative.shellScript.replace(
183082815dcSEvan Bacon      /"$/,
184082815dcSEvan Bacon      ''
185082815dcSEvan Bacon    )}${buildPhaseShellScriptPath}\\n"`;
186082815dcSEvan Bacon  }
187082815dcSEvan Bacon  return project;
188082815dcSEvan Bacon}
189082815dcSEvan Bacon
190082815dcSEvan Baconexport function isShellScriptBuildPhaseConfigured(
191082815dcSEvan Bacon  projectRoot: string,
192082815dcSEvan Bacon  project: xcode.XcodeProject
193082815dcSEvan Bacon): boolean {
194082815dcSEvan Bacon  const bundleReactNative = getBundleReactNativePhase(project);
195082815dcSEvan Bacon  const buildPhaseShellScriptPath = formatConfigurationScriptPath(projectRoot);
196082815dcSEvan Bacon  return bundleReactNative.shellScript.includes(buildPhaseShellScriptPath);
197082815dcSEvan Bacon}
198082815dcSEvan Bacon
199082815dcSEvan Baconexport function isPlistConfigurationSet(expoPlist: ExpoPlist): boolean {
200082815dcSEvan Bacon  return Boolean(
201082815dcSEvan Bacon    expoPlist.EXUpdatesURL && (expoPlist.EXUpdatesSDKVersion || expoPlist.EXUpdatesRuntimeVersion)
202082815dcSEvan Bacon  );
203082815dcSEvan Bacon}
204082815dcSEvan Bacon
205*f0d67e12SMateus Craveiroexport async function isPlistConfigurationSyncedAsync(
206082815dcSEvan Bacon  projectRoot: string,
207082815dcSEvan Bacon  config: ExpoConfigUpdates,
20882ade864SWill Schurman  expoPlist: ExpoPlist
209*f0d67e12SMateus Craveiro): Promise<boolean> {
210082815dcSEvan Bacon  return (
21182ade864SWill Schurman    getUpdateUrl(config) === expoPlist.EXUpdatesURL &&
21282ade864SWill Schurman    getUpdatesEnabled(config) === expoPlist.EXUpdatesEnabled &&
213082815dcSEvan Bacon    getUpdatesTimeout(config) === expoPlist.EXUpdatesLaunchWaitMs &&
214082815dcSEvan Bacon    getUpdatesCheckOnLaunch(config) === expoPlist.EXUpdatesCheckOnLaunch &&
215082815dcSEvan Bacon    getUpdatesCodeSigningCertificate(projectRoot, config) ===
216082815dcSEvan Bacon      expoPlist.EXUpdatesCodeSigningCertificate &&
217082815dcSEvan Bacon    getUpdatesCodeSigningMetadata(config) === expoPlist.EXUpdatesCodeSigningMetadata &&
218*f0d67e12SMateus Craveiro    (await isPlistVersionConfigurationSyncedAsync(projectRoot, config, expoPlist))
219082815dcSEvan Bacon  );
220082815dcSEvan Bacon}
221082815dcSEvan Bacon
222*f0d67e12SMateus Craveiroexport async function isPlistVersionConfigurationSyncedAsync(
223*f0d67e12SMateus Craveiro  projectRoot: string,
224082815dcSEvan Bacon  config: Pick<ExpoConfigUpdates, 'sdkVersion' | 'runtimeVersion'>,
225082815dcSEvan Bacon  expoPlist: ExpoPlist
226*f0d67e12SMateus Craveiro): Promise<boolean> {
227*f0d67e12SMateus Craveiro  const expectedRuntimeVersion = await getRuntimeVersionNullableAsync(projectRoot, config, 'ios');
228082815dcSEvan Bacon  const expectedSdkVersion = getSDKVersion(config);
229082815dcSEvan Bacon
230082815dcSEvan Bacon  const currentRuntimeVersion = expoPlist.EXUpdatesRuntimeVersion ?? null;
231082815dcSEvan Bacon  const currentSdkVersion = expoPlist.EXUpdatesSDKVersion ?? null;
232082815dcSEvan Bacon
233082815dcSEvan Bacon  if (expectedRuntimeVersion !== null) {
234082815dcSEvan Bacon    return currentRuntimeVersion === expectedRuntimeVersion && currentSdkVersion === null;
235082815dcSEvan Bacon  } else if (expectedSdkVersion !== null) {
236082815dcSEvan Bacon    return currentSdkVersion === expectedSdkVersion && currentRuntimeVersion === null;
237082815dcSEvan Bacon  } else {
238082815dcSEvan Bacon    return true;
239082815dcSEvan Bacon  }
240082815dcSEvan Bacon}
241