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