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