1import { Android, ExpoConfig, IOS } from '@expo/config-types';
2import { getRuntimeVersionForSDKVersion } from '@expo/sdk-runtime-versions';
3import fs from 'fs';
4import { boolish } from 'getenv';
5import path from 'path';
6import resolveFrom from 'resolve-from';
7import semver from 'semver';
8
9import { AndroidConfig, IOSConfig } from '..';
10
11export type ExpoConfigUpdates = Pick<
12  ExpoConfig,
13  'sdkVersion' | 'owner' | 'runtimeVersion' | 'updates' | 'slug'
14>;
15
16export function getExpoUpdatesPackageVersion(projectRoot: string): string | null {
17  const expoUpdatesPackageJsonPath = resolveFrom.silent(projectRoot, 'expo-updates/package.json');
18  if (!expoUpdatesPackageJsonPath || !fs.existsSync(expoUpdatesPackageJsonPath)) {
19    return null;
20  }
21  const packageJson = JSON.parse(fs.readFileSync(expoUpdatesPackageJsonPath, 'utf8'));
22  return packageJson.version;
23}
24
25export function getUpdateUrl(config: Pick<ExpoConfigUpdates, 'updates'>): string | null {
26  return config.updates?.url ?? null;
27}
28
29export function getAppVersion(config: Pick<ExpoConfig, 'version'>): string {
30  return config.version ?? '1.0.0';
31}
32
33export function getNativeVersion(
34  config: Pick<ExpoConfig, 'version'> & {
35    android?: Pick<Android, 'versionCode'>;
36    ios?: Pick<IOS, 'buildNumber'>;
37  },
38  platform: 'android' | 'ios'
39): string {
40  const version = IOSConfig.Version.getVersion(config);
41  switch (platform) {
42    case 'ios': {
43      const buildNumber = IOSConfig.Version.getBuildNumber(config);
44      return `${version}(${buildNumber})`;
45    }
46    case 'android': {
47      const versionCode = AndroidConfig.Version.getVersionCode(config);
48      return `${version}(${versionCode})`;
49    }
50    default: {
51      throw new Error(
52        `"${platform}" is not a supported platform. Choose either "ios" or "android".`
53      );
54    }
55  }
56}
57
58/**
59 * Compute runtime version policies.
60 * @return an expoConfig with only string valued platform specific runtime versions.
61 */
62export const withRuntimeVersion: (config: ExpoConfig) => ExpoConfig = (config) => {
63  if (config.ios?.runtimeVersion || config.runtimeVersion) {
64    const runtimeVersion = getRuntimeVersion(config, 'ios');
65    if (runtimeVersion) {
66      config.ios = {
67        ...config.ios,
68        runtimeVersion,
69      };
70    }
71  }
72  if (config.android?.runtimeVersion || config.runtimeVersion) {
73    const runtimeVersion = getRuntimeVersion(config, 'android');
74    if (runtimeVersion) {
75      config.android = {
76        ...config.android,
77        runtimeVersion,
78      };
79    }
80  }
81  delete config.runtimeVersion;
82  return config;
83};
84
85export function getRuntimeVersionNullable(
86  ...[config, platform]: Parameters<typeof getRuntimeVersion>
87): string | null {
88  try {
89    return getRuntimeVersion(config, platform);
90  } catch (e) {
91    if (boolish('EXPO_DEBUG', false)) {
92      console.log(e);
93    }
94    return null;
95  }
96}
97
98export function getRuntimeVersion(
99  config: Pick<ExpoConfig, 'version' | 'runtimeVersion' | 'sdkVersion'> & {
100    android?: Pick<Android, 'versionCode' | 'runtimeVersion'>;
101    ios?: Pick<IOS, 'buildNumber' | 'runtimeVersion'>;
102  },
103  platform: 'android' | 'ios'
104): string | null {
105  const runtimeVersion = config[platform]?.runtimeVersion ?? config.runtimeVersion;
106  if (!runtimeVersion) {
107    return null;
108  }
109
110  if (typeof runtimeVersion === 'string') {
111    return runtimeVersion;
112  } else if (runtimeVersion.policy === 'appVersion') {
113    return getAppVersion(config);
114  } else if (runtimeVersion.policy === 'nativeVersion') {
115    return getNativeVersion(config, platform);
116  } else if (runtimeVersion.policy === 'sdkVersion') {
117    if (!config.sdkVersion) {
118      throw new Error("An SDK version must be defined when using the 'sdkVersion' runtime policy.");
119    }
120    return getRuntimeVersionForSDKVersion(config.sdkVersion);
121  }
122
123  throw new Error(
124    `"${
125      typeof runtimeVersion === 'object' ? JSON.stringify(runtimeVersion) : runtimeVersion
126    }" is not a valid runtime version. getRuntimeVersion only supports a string, "sdkVersion", "appVersion", or "nativeVersion" policy.`
127  );
128}
129
130export function getSDKVersion(config: Pick<ExpoConfigUpdates, 'sdkVersion'>): string | null {
131  return typeof config.sdkVersion === 'string' ? config.sdkVersion : null;
132}
133
134export function getUpdatesEnabled(config: Pick<ExpoConfigUpdates, 'updates'>): boolean {
135  // allow override of enabled property
136  if (config.updates?.enabled !== undefined) {
137    return config.updates.enabled;
138  }
139
140  return getUpdateUrl(config) !== null;
141}
142
143export function getUpdatesTimeout(config: Pick<ExpoConfigUpdates, 'updates'>): number {
144  return config.updates?.fallbackToCacheTimeout ?? 0;
145}
146
147export function getUpdatesCheckOnLaunch(
148  config: Pick<ExpoConfigUpdates, 'updates'>,
149  expoUpdatesPackageVersion?: string | null
150): 'NEVER' | 'ERROR_RECOVERY_ONLY' | 'ALWAYS' | 'WIFI_ONLY' {
151  if (config.updates?.checkAutomatically === 'ON_ERROR_RECOVERY') {
152    // native 'ERROR_RECOVERY_ONLY' option was only introduced in 0.11.x
153    if (expoUpdatesPackageVersion && semver.gte(expoUpdatesPackageVersion, '0.11.0')) {
154      return 'ERROR_RECOVERY_ONLY';
155    }
156    return 'NEVER';
157  } else if (config.updates?.checkAutomatically === 'ON_LOAD') {
158    return 'ALWAYS';
159  } else if (config.updates?.checkAutomatically === 'WIFI_ONLY') {
160    return 'WIFI_ONLY';
161  } else if (config.updates?.checkAutomatically === 'NEVER') {
162    return 'NEVER';
163  }
164  return 'ALWAYS';
165}
166
167export function getUpdatesCodeSigningCertificate(
168  projectRoot: string,
169  config: Pick<ExpoConfigUpdates, 'updates'>
170): string | undefined {
171  const codeSigningCertificatePath = config.updates?.codeSigningCertificate;
172  if (!codeSigningCertificatePath) {
173    return undefined;
174  }
175
176  const finalPath = path.join(projectRoot, codeSigningCertificatePath);
177  if (!fs.existsSync(finalPath)) {
178    throw new Error(`File not found at \`updates.codeSigningCertificate\` path: ${finalPath}`);
179  }
180
181  return fs.readFileSync(finalPath, 'utf8');
182}
183
184export function getUpdatesCodeSigningMetadata(
185  config: Pick<ExpoConfigUpdates, 'updates'>
186): NonNullable<ExpoConfigUpdates['updates']>['codeSigningMetadata'] {
187  return config.updates?.codeSigningMetadata;
188}
189
190export function getUpdatesCodeSigningMetadataStringified(
191  config: Pick<ExpoConfigUpdates, 'updates'>
192): string | undefined {
193  const metadata = getUpdatesCodeSigningMetadata(config);
194  if (!metadata) {
195    return undefined;
196  }
197
198  return JSON.stringify(metadata);
199}
200
201export function getUpdatesRequestHeaders(
202  config: Pick<ExpoConfigUpdates, 'updates'>
203): NonNullable<ExpoConfigUpdates['updates']>['requestHeaders'] {
204  return config.updates?.requestHeaders;
205}
206
207export function getUpdatesRequestHeadersStringified(
208  config: Pick<ExpoConfigUpdates, 'updates'>
209): string | undefined {
210  const metadata = getUpdatesRequestHeaders(config);
211  if (!metadata) {
212    return undefined;
213  }
214
215  return JSON.stringify(metadata);
216}
217