xref: /expo/packages/expo-updates/src/Updates.ts (revision 2de97e27)
1import {
2  RCTDeviceEventEmitter,
3  CodedError,
4  NativeModulesProxy,
5  UnavailabilityError,
6} from 'expo-modules-core';
7import { EventEmitter, EventSubscription } from 'fbemitter';
8
9import ExpoUpdates from './ExpoUpdates';
10import {
11  LocalAssets,
12  Manifest,
13  UpdateCheckResult,
14  UpdateEvent,
15  UpdateFetchResult,
16  UpdatesLogEntry,
17} from './Updates.types';
18
19export * from './Updates.types';
20
21/**
22 * The UUID that uniquely identifies the currently running update if `expo-updates` is enabled. The
23 * UUID is represented in its canonical string form (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) and
24 * will always use lowercase letters. In development mode, or any other environment in which
25 * `expo-updates` is disabled, this value is `null`.
26 */
27export const updateId: string | null =
28  ExpoUpdates.updateId && typeof ExpoUpdates.updateId === 'string'
29    ? ExpoUpdates.updateId.toLowerCase()
30    : null;
31
32/**
33 * The name of the release channel currently configured in this standalone or bare app when using
34 * classic updates. When using Expo Updates, the value of this field is always `"default"`.
35 */
36export const releaseChannel: string = ExpoUpdates.releaseChannel ?? 'default';
37
38/**
39 * The channel name of the current build, if configured for use with EAS Update. Null otherwise.
40 */
41export const channel: string | null = ExpoUpdates.channel ?? null;
42
43/**
44 * The runtime version of the current build.
45 */
46export const runtimeVersion: string | null = ExpoUpdates.runtimeVersion ?? null;
47
48// @docsMissing
49/**
50 * @hidden
51 */
52export const localAssets: LocalAssets = ExpoUpdates.localAssets ?? {};
53
54/**
55 * `expo-updates` does its very best to always launch monotonically newer versions of your app so
56 * you don't need to worry about backwards compatibility when you put out an update. In very rare
57 * cases, it's possible that `expo-updates` may need to fall back to the update that's embedded in
58 * the app binary, even after newer updates have been downloaded and run (an "emergency launch").
59 * This boolean will be `true` if the app is launching under this fallback mechanism and `false`
60 * otherwise. If you are concerned about backwards compatibility of future updates to your app, you
61 * can use this constant to provide special behavior for this rare case.
62 */
63export const isEmergencyLaunch: boolean = ExpoUpdates.isEmergencyLaunch || false;
64
65// @docsMissing
66/**
67 * @hidden
68 */
69export const isUsingEmbeddedAssets: boolean = ExpoUpdates.isUsingEmbeddedAssets || false;
70
71/**
72 * If `expo-updates` is enabled, this is the
73 * [manifest](/guides/how-expo-works#expo-development-server) object for the update that's currently
74 * running.
75 *
76 * In development mode, or any other environment in which `expo-updates` is disabled, this object is
77 * empty.
78 */
79export const manifest: Partial<Manifest> =
80  (ExpoUpdates.manifestString ? JSON.parse(ExpoUpdates.manifestString) : ExpoUpdates.manifest) ??
81  {};
82
83/**
84 * If `expo-updates` is enabled, this is a `Date` object representing the creation time of the update that's currently running (whether it was embedded or downloaded at runtime).
85 *
86 * In development mode, or any other environment in which `expo-updates` is disabled, this value is
87 * null.
88 */
89export const createdAt: Date | null = ExpoUpdates.commitTime
90  ? new Date(ExpoUpdates.commitTime)
91  : null;
92
93const isUsingDeveloperTool = !!(manifest as any).developer?.tool;
94const isUsingExpoDevelopmentClient = NativeModulesProxy.ExponentConstants?.appOwnership === 'expo';
95const manualUpdatesInstructions = isUsingExpoDevelopmentClient
96  ? 'To test manual updates, publish your project using `expo publish` and open the published ' +
97    'version in this development client.'
98  : 'To test manual updates, make a release build with `npm run ios --configuration Release` or ' +
99    '`npm run android --variant Release`.';
100
101/**
102 * Instructs the app to reload using the most recently downloaded version. This is useful for
103 * triggering a newly downloaded update to launch without the user needing to manually restart the
104 * app.
105 *
106 * It is not recommended to place any meaningful logic after a call to `await
107 * Updates.reloadAsync()`. This is because the promise is resolved after verifying that the app can
108 * be reloaded, and immediately before posting an asynchronous task to the main thread to actually
109 * reload the app. It is unsafe to make any assumptions about whether any more JS code will be
110 * executed after the `Updates.reloadAsync` method call resolves, since that depends on the OS and
111 * the state of the native module and main threads.
112 *
113 * This method cannot be used in development mode, and the returned promise will be rejected if you
114 * try to do so.
115 *
116 * @return A promise that fulfills right before the reload instruction is sent to the JS runtime, or
117 * rejects if it cannot find a reference to the JS runtime. If the promise is rejected in production
118 * mode, it most likely means you have installed the module incorrectly. Double check you've
119 * followed the installation instructions. In particular, on iOS ensure that you set the `bridge`
120 * property on `EXUpdatesAppController` with a pointer to the `RCTBridge` you want to reload, and on
121 * Android ensure you either call `UpdatesController.initialize` with the instance of
122 * `ReactApplication` you want to reload, or call `UpdatesController.setReactNativeHost` with the
123 * proper instance of `ReactNativeHost`.
124 */
125export async function reloadAsync(): Promise<void> {
126  if (!ExpoUpdates.reload) {
127    throw new UnavailabilityError('Updates', 'reloadAsync');
128  }
129  if (__DEV__ && !isUsingExpoDevelopmentClient) {
130    throw new CodedError(
131      'ERR_UPDATES_DISABLED',
132      `You cannot use the Updates module in development mode in a production app. ${manualUpdatesInstructions}`
133    );
134  }
135  await ExpoUpdates.reload();
136}
137
138/**
139 * Checks the server to see if a newly deployed update to your project is available. Does not
140 * actually download the update. This method cannot be used in development mode, and the returned
141 * promise will be rejected if you try to do so.
142 *
143 * Checking for an update uses a device's bandwidth and battery life like any network call.
144 * Additionally, updates served by Expo may be rate limited. A good rule of thumb to check for
145 * updates judiciously is to check when the user launches or foregrounds the app. Avoid polling for
146 * updates in a frequent loop.
147 *
148 * @return A promise that fulfills with an [`UpdateCheckResult`](#updatecheckresult) object.
149 *
150 * The promise rejects if the app is in development mode, or if there is an unexpected error or
151 * timeout communicating with the server.
152 */
153export async function checkForUpdateAsync(): Promise<UpdateCheckResult> {
154  if (!ExpoUpdates.checkForUpdateAsync) {
155    throw new UnavailabilityError('Updates', 'checkForUpdateAsync');
156  }
157  if (__DEV__ || isUsingDeveloperTool) {
158    throw new CodedError(
159      'ERR_UPDATES_DISABLED',
160      `You cannot check for updates in development mode. ${manualUpdatesInstructions}`
161    );
162  }
163
164  const result = await ExpoUpdates.checkForUpdateAsync();
165  if (result.manifestString) {
166    result.manifest = JSON.parse(result.manifestString);
167    delete result.manifestString;
168  }
169
170  return result;
171}
172
173/**
174 * Retrieves the most recent expo-updates log entries.
175 *
176 * @param maxAge Sets the max age of retrieved log entries in milliseconds. Default to 3600000 ms (1 hour).
177 *
178 * @return A promise that fulfills with an array of [`UpdatesLogEntry`](#updateslogentry) objects;
179 *
180 * The promise rejects if there is an unexpected error in retrieving the logs.
181 */
182export async function readLogEntriesAsync(maxAge: number = 3600000): Promise<UpdatesLogEntry[]> {
183  if (!ExpoUpdates.readLogEntriesAsync) {
184    throw new UnavailabilityError('Updates', 'readLogEntriesAsync');
185  }
186  return await ExpoUpdates.readLogEntriesAsync(maxAge);
187}
188
189/**
190 * Clears existing expo-updates log entries.
191 *
192 * > For now, this operation does nothing on the client.  Once log persistence has been
193 * > implemented, this operation will actually remove existing logs.
194 *
195 * @return A promise that fulfills if the clear operation was successful.
196 *
197 * The promise rejects if there is an unexpected error in clearing the logs.
198 *
199 */
200export async function clearLogEntriesAsync(): Promise<void> {
201  if (!ExpoUpdates.clearLogEntriesAsync) {
202    throw new UnavailabilityError('Updates', 'clearLogEntriesAsync');
203  }
204  await ExpoUpdates.clearLogEntriesAsync();
205}
206
207/**
208 * Downloads the most recently deployed update to your project from server to the device's local
209 * storage. This method cannot be used in development mode, and the returned promise will be
210 * rejected if you try to do so.
211 *
212 * @return A promise that fulfills with an [`UpdateFetchResult`](#updatefetchresult) object.
213 *
214 * The promise rejects if the app is in development mode, or if there is an unexpected error or
215 * timeout communicating with the server.
216 */
217export async function fetchUpdateAsync(): Promise<UpdateFetchResult> {
218  if (!ExpoUpdates.fetchUpdateAsync) {
219    throw new UnavailabilityError('Updates', 'fetchUpdateAsync');
220  }
221  if (__DEV__ || isUsingDeveloperTool) {
222    throw new CodedError(
223      'ERR_UPDATES_DISABLED',
224      `You cannot fetch updates in development mode. ${manualUpdatesInstructions}`
225    );
226  }
227
228  const result = await ExpoUpdates.fetchUpdateAsync();
229  if (result.manifestString) {
230    result.manifest = JSON.parse(result.manifestString);
231    delete result.manifestString;
232  }
233
234  return result;
235}
236
237/**
238 * @hidden
239 */
240export function clearUpdateCacheExperimentalAsync(_sdkVersion?: string) {
241  console.warn(
242    "This method is no longer necessary. `expo-updates` now automatically deletes your app's old bundle files!"
243  );
244}
245
246let _emitter: EventEmitter | null;
247
248function _getEmitter(): EventEmitter {
249  if (!_emitter) {
250    _emitter = new EventEmitter();
251    RCTDeviceEventEmitter.addListener('Expo.nativeUpdatesEvent', _emitEvent);
252  }
253  return _emitter;
254}
255
256function _emitEvent(params): void {
257  let newParams = params;
258  if (typeof params === 'string') {
259    newParams = JSON.parse(params);
260  }
261  if (newParams.manifestString) {
262    newParams.manifest = JSON.parse(newParams.manifestString);
263    delete newParams.manifestString;
264  }
265
266  if (!_emitter) {
267    throw new Error(`EventEmitter must be initialized to use from its listener`);
268  }
269  _emitter.emit('Expo.updatesEvent', newParams);
270}
271
272/**
273 * Adds a callback to be invoked when updates-related events occur (such as upon the initial app
274 * load) due to auto-update settings chosen at build-time.
275 *
276 * @param listener A function that will be invoked with an [`UpdateEvent`](#updateevent) instance
277 * and should not return any value.
278 * @return An `EventSubscription` object on which you can call `remove()` to unsubscribe the
279 * listener.
280 */
281export function addListener(listener: (event: UpdateEvent) => void): EventSubscription {
282  const emitter = _getEmitter();
283  return emitter.addListener('Expo.updatesEvent', listener);
284}
285