@otaupdate/react-native 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +321 -0
  2. package/android/build.gradle +70 -0
  3. package/android/src/expo/java/com/otaupdate/OtaUpdateExpoPackage.kt +37 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/com/otaupdate/OtaUpdate.kt +98 -0
  6. package/android/src/main/java/com/otaupdate/OtaUpdateInstaller.kt +211 -0
  7. package/android/src/main/java/com/otaupdate/OtaUpdateModule.kt +278 -0
  8. package/android/src/main/java/com/otaupdate/OtaUpdatePackage.kt +15 -0
  9. package/android/src/main/java/com/otaupdate/OtaUpdateStore.kt +268 -0
  10. package/app.plugin.js +3 -0
  11. package/expo-module.config.json +6 -0
  12. package/ios/OtaUpdate.h +46 -0
  13. package/ios/OtaUpdate.m +302 -0
  14. package/ios/OtaUpdateInstaller.h +25 -0
  15. package/ios/OtaUpdateInstaller.m +278 -0
  16. package/ios/OtaUpdateStore.h +69 -0
  17. package/ios/OtaUpdateStore.m +283 -0
  18. package/lib/OtaUpdate.d.ts +28 -0
  19. package/lib/OtaUpdate.d.ts.map +1 -0
  20. package/lib/OtaUpdate.js +254 -0
  21. package/lib/OtaUpdate.js.map +1 -0
  22. package/lib/api.d.ts +36 -0
  23. package/lib/api.d.ts.map +1 -0
  24. package/lib/api.js +89 -0
  25. package/lib/api.js.map +1 -0
  26. package/lib/index.d.ts +24 -0
  27. package/lib/index.d.ts.map +1 -0
  28. package/lib/index.js +37 -0
  29. package/lib/index.js.map +1 -0
  30. package/lib/native.d.ts +34 -0
  31. package/lib/native.d.ts.map +1 -0
  32. package/lib/native.js +34 -0
  33. package/lib/native.js.map +1 -0
  34. package/lib/types.d.ts +112 -0
  35. package/lib/types.d.ts.map +1 -0
  36. package/lib/types.js +27 -0
  37. package/lib/types.js.map +1 -0
  38. package/lib/useOtaUpdate.d.ts +17 -0
  39. package/lib/useOtaUpdate.d.ts.map +1 -0
  40. package/lib/useOtaUpdate.js +81 -0
  41. package/lib/useOtaUpdate.js.map +1 -0
  42. package/lib/withOtaUpdate.d.ts +10 -0
  43. package/lib/withOtaUpdate.d.ts.map +1 -0
  44. package/lib/withOtaUpdate.js +21 -0
  45. package/lib/withOtaUpdate.js.map +1 -0
  46. package/package.json +54 -0
  47. package/plugin/build/index.d.ts +11 -0
  48. package/plugin/build/index.js +97 -0
  49. package/react-native-ota-update.podspec +43 -0
  50. package/react-native.config.js +21 -0
  51. package/src/OtaUpdate.ts +293 -0
  52. package/src/api.ts +122 -0
  53. package/src/index.ts +55 -0
  54. package/src/native.ts +64 -0
  55. package/src/types.ts +125 -0
  56. package/src/useOtaUpdate.ts +96 -0
  57. package/src/withOtaUpdate.tsx +22 -0
@@ -0,0 +1,293 @@
1
+ import { AppState, type AppStateStatus } from 'react-native';
2
+ import { fetchUpdate, OtaApiError, reportStatus } from './api';
3
+ import { NativeOtaUpdate, isNativeModuleAvailable, onDownloadProgress } from './native';
4
+ import {
5
+ InstallMode,
6
+ SyncStatus,
7
+ type CheckResult,
8
+ type CurrentPackage,
9
+ type DownloadProgress,
10
+ type LocalPackage,
11
+ type OtaConfiguration,
12
+ type OtaOptions,
13
+ type RemotePackage,
14
+ type SyncOptions,
15
+ } from './types';
16
+
17
+ let cachedConfig: OtaConfiguration | null = null;
18
+ let syncInFlight: Promise<SyncStatus> | null = null;
19
+ let lastSyncAt = 0;
20
+ let appReadyNotified = false;
21
+
22
+ async function getConfiguration(overrideDeploymentKey?: string): Promise<OtaConfiguration> {
23
+ if (!cachedConfig) {
24
+ cachedConfig = await NativeOtaUpdate.getConfiguration();
25
+ }
26
+ // Re-read the running package on every call: it changes after an install.
27
+ const current = await NativeOtaUpdate.getCurrentPackage().catch(() => null);
28
+ return {
29
+ ...cachedConfig,
30
+ deploymentKey: overrideDeploymentKey ?? cachedConfig.deploymentKey,
31
+ packageHash: (current?.packageHash as string | undefined) ?? null,
32
+ label: (current?.label as string | undefined) ?? null,
33
+ };
34
+ }
35
+
36
+ function assertConfigured(config: OtaConfiguration): void {
37
+ if (!config.deploymentKey) {
38
+ throw new Error(
39
+ 'No deployment key configured. Set OtaDeploymentKey in Info.plist (iOS) / ' +
40
+ 'ota_deployment_key in strings.xml (Android), or pass `deploymentKey` to sync(). ' +
41
+ 'With Expo, set it in the config plugin options.',
42
+ );
43
+ }
44
+ if (!config.serverUrl) {
45
+ throw new Error(
46
+ 'No OTA server URL configured. Set OtaServerUrl (iOS) / ota_server_url (Android), ' +
47
+ 'or the `serverUrl` config plugin option.',
48
+ );
49
+ }
50
+ }
51
+
52
+ function toRemotePackage(
53
+ info: Record<string, any>,
54
+ config: OtaConfiguration,
55
+ ): RemotePackage {
56
+ const remote: RemotePackage = {
57
+ label: info.label,
58
+ packageHash: info.packageHash,
59
+ downloadUrl: info.downloadUrl ?? info.downloadURL,
60
+ size: info.size ?? 0,
61
+ isMandatory: Boolean(info.isMandatory),
62
+ description: info.description ?? null,
63
+ targetBinaryVersion: info.targetBinaryVersion ?? '',
64
+ rollout: info.rollout ?? 100,
65
+ isRollback: Boolean(info.isRollback),
66
+ deployment: info.deployment ?? '',
67
+ async download(onProgress?: (progress: DownloadProgress) => void): Promise<LocalPackage> {
68
+ const subscription = onProgress ? onDownloadProgress(onProgress) : null;
69
+ try {
70
+ // The native side verifies the SHA-256 before unzipping, so a corrupt
71
+ // or tampered download rejects here rather than bricking the app.
72
+ const result = await NativeOtaUpdate.downloadUpdate({
73
+ label: remote.label,
74
+ packageHash: remote.packageHash,
75
+ downloadUrl: remote.downloadUrl,
76
+ size: remote.size,
77
+ isMandatory: remote.isMandatory,
78
+ description: remote.description,
79
+ });
80
+ void reportStatus(config, 'downloaded', { label: remote.label });
81
+ return toLocalPackage(result, remote);
82
+ } finally {
83
+ subscription?.remove();
84
+ }
85
+ },
86
+ };
87
+ return remote;
88
+ }
89
+
90
+ function toLocalPackage(
91
+ result: { bundlePath: string; packageHash: string; label: string; size: number },
92
+ remote: RemotePackage,
93
+ ): LocalPackage {
94
+ return {
95
+ label: result.label,
96
+ packageHash: result.packageHash,
97
+ isMandatory: remote.isMandatory,
98
+ description: remote.description,
99
+ bundlePath: result.bundlePath,
100
+ size: result.size,
101
+ async install(
102
+ mode: InstallMode = InstallMode.ON_NEXT_RESTART,
103
+ minimumBackgroundDuration = 0,
104
+ ): Promise<void> {
105
+ await NativeOtaUpdate.installUpdate(result.packageHash, mode, minimumBackgroundDuration);
106
+ // No status report here on purpose. `download()` already recorded
107
+ // 'downloaded', and reporting again would double-count the metric; the
108
+ // next signal is 'success' from notifyAppReady() once the new bundle
109
+ // actually boots.
110
+ },
111
+ };
112
+ }
113
+
114
+ /** Asks the server whether a newer bundle applies to this device. */
115
+ export async function checkForUpdate(deploymentKey?: string): Promise<CheckResult> {
116
+ const config = await getConfiguration(deploymentKey);
117
+ assertConfigured(config);
118
+
119
+ const { raw, reason, updateAppVersion } = await fetchUpdate(config);
120
+ if (!raw.isAvailable) return { update: null, reason, updateAppVersion };
121
+ return { update: toRemotePackage(raw, config) };
122
+ }
123
+
124
+ /**
125
+ * Check → download → install, in one call. Safe to call repeatedly; concurrent
126
+ * calls share the in-flight run rather than downloading twice.
127
+ */
128
+ export async function sync(options: SyncOptions = {}): Promise<SyncStatus> {
129
+ if (syncInFlight) {
130
+ options.onSyncStatusChange?.(SyncStatus.SYNC_IN_PROGRESS);
131
+ return syncInFlight;
132
+ }
133
+ syncInFlight = runSync(options).finally(() => {
134
+ syncInFlight = null;
135
+ lastSyncAt = Date.now();
136
+ });
137
+ return syncInFlight;
138
+ }
139
+
140
+ async function runSync(options: SyncOptions): Promise<SyncStatus> {
141
+ const notify = (status: SyncStatus) => options.onSyncStatusChange?.(status);
142
+
143
+ try {
144
+ notify(SyncStatus.CHECKING_FOR_UPDATE);
145
+ const config = await getConfiguration(options.deploymentKey);
146
+ assertConfigured(config);
147
+
148
+ const { update } = await checkForUpdate(options.deploymentKey);
149
+ if (!update) {
150
+ notify(SyncStatus.UP_TO_DATE);
151
+ return SyncStatus.UP_TO_DATE;
152
+ }
153
+
154
+ if (!update.isMandatory && options.shouldInstall) {
155
+ notify(SyncStatus.AWAITING_USER_ACTION);
156
+ const proceed = await options.shouldInstall(update);
157
+ if (!proceed) {
158
+ notify(SyncStatus.UPDATE_IGNORED);
159
+ return SyncStatus.UPDATE_IGNORED;
160
+ }
161
+ }
162
+
163
+ notify(SyncStatus.DOWNLOADING_PACKAGE);
164
+ const local = await update.download(options.onDownloadProgress);
165
+
166
+ notify(SyncStatus.INSTALLING_UPDATE);
167
+ const mode = update.isMandatory
168
+ ? (options.mandatoryInstallMode ?? InstallMode.IMMEDIATE)
169
+ : (options.installMode ?? InstallMode.ON_NEXT_RESTART);
170
+ await local.install(mode, options.minimumBackgroundDuration ?? 0);
171
+
172
+ notify(SyncStatus.UPDATE_INSTALLED);
173
+ return SyncStatus.UPDATE_INSTALLED;
174
+ } catch (err) {
175
+ const message = err instanceof Error ? err.message : String(err);
176
+ // A sync failure must never take the app down — surface it and move on.
177
+ if (!(err instanceof OtaApiError)) {
178
+ console.warn(`[ota-update] sync failed: ${message}`);
179
+ }
180
+ try {
181
+ const config = await getConfiguration(options.deploymentKey);
182
+ void reportStatus(config, 'failed', { error: message });
183
+ } catch {
184
+ /* configuration itself was unavailable */
185
+ }
186
+ notify(SyncStatus.UNKNOWN_ERROR);
187
+ return SyncStatus.UNKNOWN_ERROR;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Confirms the running bundle works. Until this is called, the next app start
193
+ * treats the update as broken and rolls back to the previous bundle.
194
+ *
195
+ * Call it once your app has rendered and its critical startup path has run.
196
+ */
197
+ export async function notifyAppReady(): Promise<void> {
198
+ if (appReadyNotified) return;
199
+ appReadyNotified = true;
200
+ await NativeOtaUpdate.notifyApplicationReady();
201
+
202
+ try {
203
+ const config = await getConfiguration();
204
+ const current = await NativeOtaUpdate.getCurrentPackage();
205
+ if (current?.label) {
206
+ void reportStatus(config, 'success', { label: current.label as string });
207
+ }
208
+ } catch {
209
+ /* reporting is best-effort */
210
+ }
211
+ }
212
+
213
+ export async function restartApp(onlyIfUpdateIsPending = false): Promise<void> {
214
+ await NativeOtaUpdate.restartApp(onlyIfUpdateIsPending);
215
+ }
216
+
217
+ export async function getCurrentPackage(): Promise<CurrentPackage | null> {
218
+ const raw = await NativeOtaUpdate.getCurrentPackage();
219
+ if (!raw) return null;
220
+ return {
221
+ label: (raw.label as string) ?? null,
222
+ packageHash: (raw.packageHash as string) ?? null,
223
+ description: (raw.description as string) ?? null,
224
+ isMandatory: Boolean(raw.isMandatory),
225
+ bundlePath: (raw.bundlePath as string) ?? null,
226
+ appVersion: (raw.appVersion as string) ?? '',
227
+ isPending: Boolean(raw.isPending),
228
+ isFirstRun: Boolean(raw.isFirstRun),
229
+ };
230
+ }
231
+
232
+ /** Wipes every downloaded package and reverts to the bundle inside the binary. */
233
+ export async function clearUpdates(): Promise<void> {
234
+ await NativeOtaUpdate.clearUpdates();
235
+ }
236
+
237
+ export async function getConfig(): Promise<OtaConfiguration> {
238
+ return getConfiguration();
239
+ }
240
+
241
+ // --- Automatic lifecycle wiring ---------------------------------------------
242
+
243
+ let lifecycleSubscription: { remove: () => void } | null = null;
244
+
245
+ /**
246
+ * Starts the app-start / app-resume sync loop. `withOtaUpdate` calls this for
247
+ * you; call it directly if you are not using the HOC.
248
+ */
249
+ export function startAutoSync(options: OtaOptions = {}): () => void {
250
+ const {
251
+ checkOnAppStart = true,
252
+ checkOnResume = true,
253
+ minimumSyncInterval = 60,
254
+ sync: syncOptions = {},
255
+ } = options;
256
+
257
+ if (!isNativeModuleAvailable) {
258
+ console.warn(
259
+ '[ota-update] native module unavailable — auto-sync disabled. ' +
260
+ 'This is expected in Expo Go and on web.',
261
+ );
262
+ return () => undefined;
263
+ }
264
+
265
+ // Confirm the running bundle immediately: if it was a pending update, this
266
+ // is what stops the next launch from rolling it back.
267
+ void notifyAppReady().catch(() => undefined);
268
+
269
+ if (checkOnAppStart) void sync(syncOptions);
270
+
271
+ if (checkOnResume) {
272
+ const handler = (state: AppStateStatus) => {
273
+ if (state !== 'active') return;
274
+ if (Date.now() - lastSyncAt < minimumSyncInterval * 1000) return;
275
+ void sync(syncOptions);
276
+ };
277
+ const subscription = AppState.addEventListener('change', handler);
278
+ lifecycleSubscription = subscription;
279
+ }
280
+
281
+ return () => {
282
+ lifecycleSubscription?.remove();
283
+ lifecycleSubscription = null;
284
+ };
285
+ }
286
+
287
+ /** Test seam — resets memoised configuration and sync state. */
288
+ export function __resetForTests(): void {
289
+ cachedConfig = null;
290
+ syncInFlight = null;
291
+ lastSyncAt = 0;
292
+ appReadyNotified = false;
293
+ }
package/src/api.ts ADDED
@@ -0,0 +1,122 @@
1
+ import type { CheckResult, NoUpdateReason, OtaConfiguration } from './types';
2
+
3
+ export interface UpdateCheckResponse {
4
+ updateInfo: {
5
+ isAvailable: boolean;
6
+ label?: string;
7
+ packageHash?: string;
8
+ downloadUrl?: string;
9
+ size?: number;
10
+ isMandatory?: boolean;
11
+ description?: string | null;
12
+ targetBinaryVersion?: string;
13
+ rollout?: number;
14
+ isRollback?: boolean;
15
+ deployment?: string;
16
+ reason?: NoUpdateReason;
17
+ updateAppVersion?: boolean;
18
+ };
19
+ }
20
+
21
+ export class OtaApiError extends Error {
22
+ constructor(
23
+ message: string,
24
+ readonly status?: number,
25
+ ) {
26
+ super(message);
27
+ this.name = 'OtaApiError';
28
+ }
29
+ }
30
+
31
+ const DEFAULT_TIMEOUT_MS = 10_000;
32
+
33
+ async function request<T>(url: string, init: RequestInit, timeoutMs: number): Promise<T> {
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
36
+ try {
37
+ const response = await fetch(url, { ...init, signal: controller.signal });
38
+ const text = await response.text();
39
+ if (!response.ok) {
40
+ let message = `${response.status} ${response.statusText}`;
41
+ try {
42
+ message = JSON.parse(text)?.error?.message ?? message;
43
+ } catch {
44
+ /* keep the status line */
45
+ }
46
+ throw new OtaApiError(message, response.status);
47
+ }
48
+ return (text ? JSON.parse(text) : {}) as T;
49
+ } catch (err) {
50
+ if (err instanceof OtaApiError) throw err;
51
+ if ((err as Error).name === 'AbortError') {
52
+ throw new OtaApiError(`Update check timed out after ${timeoutMs}ms`);
53
+ }
54
+ throw new OtaApiError((err as Error).message);
55
+ } finally {
56
+ clearTimeout(timer);
57
+ }
58
+ }
59
+
60
+ export async function fetchUpdate(
61
+ config: OtaConfiguration,
62
+ timeoutMs = DEFAULT_TIMEOUT_MS,
63
+ ): Promise<Omit<CheckResult, 'update'> & { raw: UpdateCheckResponse['updateInfo'] }> {
64
+ const body = {
65
+ deploymentKey: config.deploymentKey,
66
+ appVersion: config.appVersion,
67
+ clientUniqueId: config.clientUniqueId,
68
+ label: config.label ?? undefined,
69
+ packageHash: config.packageHash ?? undefined,
70
+ bundleIdentifier: config.bundleIdentifier || undefined,
71
+ };
72
+
73
+ const response = await request<UpdateCheckResponse>(
74
+ `${config.serverUrl.replace(/\/+$/, '')}/updateCheck`,
75
+ {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: JSON.stringify(body),
79
+ },
80
+ timeoutMs,
81
+ );
82
+
83
+ return {
84
+ raw: response.updateInfo,
85
+ reason: response.updateInfo.reason,
86
+ updateAppVersion: response.updateInfo.updateAppVersion,
87
+ };
88
+ }
89
+
90
+ export type InstallStatus = 'downloaded' | 'success' | 'failed' | 'rolled_back';
91
+
92
+ /**
93
+ * Best-effort telemetry. A failed report must never break the app, so callers
94
+ * are not expected to handle rejections — this never throws.
95
+ */
96
+ export async function reportStatus(
97
+ config: OtaConfiguration,
98
+ status: InstallStatus,
99
+ extra: { label?: string | null; previousLabel?: string | null; error?: string } = {},
100
+ ): Promise<void> {
101
+ try {
102
+ await request(
103
+ `${config.serverUrl.replace(/\/+$/, '')}/reportStatus`,
104
+ {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({
108
+ deploymentKey: config.deploymentKey,
109
+ clientUniqueId: config.clientUniqueId,
110
+ appVersion: config.appVersion,
111
+ status,
112
+ label: extra.label ?? config.label ?? undefined,
113
+ previousLabel: extra.previousLabel ?? undefined,
114
+ error: extra.error,
115
+ }),
116
+ },
117
+ DEFAULT_TIMEOUT_MS,
118
+ );
119
+ } catch {
120
+ // Swallowed on purpose: metrics are not worth a crash.
121
+ }
122
+ }
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ import {
2
+ checkForUpdate,
3
+ clearUpdates,
4
+ getConfig,
5
+ getCurrentPackage,
6
+ notifyAppReady,
7
+ restartApp,
8
+ startAutoSync,
9
+ sync,
10
+ } from './OtaUpdate';
11
+ import { isNativeModuleAvailable } from './native';
12
+
13
+ export { InstallMode, SyncStatus } from './types';
14
+ export type {
15
+ CheckResult,
16
+ CurrentPackage,
17
+ DownloadProgress,
18
+ LocalPackage,
19
+ NoUpdateReason,
20
+ OtaConfiguration,
21
+ OtaOptions,
22
+ RemotePackage,
23
+ SyncOptions,
24
+ } from './types';
25
+
26
+ export {
27
+ checkForUpdate,
28
+ clearUpdates,
29
+ getConfig,
30
+ getCurrentPackage,
31
+ notifyAppReady,
32
+ restartApp,
33
+ startAutoSync,
34
+ sync,
35
+ };
36
+ export { withOtaUpdate } from './withOtaUpdate';
37
+ export { useOtaUpdate } from './useOtaUpdate';
38
+ export type { UseOtaUpdateState } from './useOtaUpdate';
39
+ export { OtaApiError } from './api';
40
+ export { isNativeModuleAvailable };
41
+
42
+ /** Default export, so `import OtaUpdate from '@otaupdate/react-native'` works. */
43
+ const OtaUpdate = {
44
+ sync,
45
+ checkForUpdate,
46
+ notifyAppReady,
47
+ restartApp,
48
+ getCurrentPackage,
49
+ getConfig,
50
+ clearUpdates,
51
+ startAutoSync,
52
+ isNativeModuleAvailable,
53
+ };
54
+
55
+ export default OtaUpdate;
package/src/native.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
2
+ import type { DownloadProgress, OtaConfiguration } from './types';
3
+
4
+ const LINKING_ERROR =
5
+ `The native module for '@otaupdate/react-native' is not available.\n\n` +
6
+ Platform.select({
7
+ ios: '• Run `cd ios && pod install`\n',
8
+ android: '• Rebuild the Android app (Gradle autolinking picks the module up)\n',
9
+ default: '',
10
+ }) +
11
+ '• If you use Expo, add the config plugin to app.json and run `npx expo prebuild`\n' +
12
+ '• Reload the app after a native rebuild — a Metro-only reload is not enough\n' +
13
+ '• The module does not exist in Expo Go; use a development build.';
14
+
15
+ export interface NativeOta {
16
+ getConfiguration(): Promise<OtaConfiguration>;
17
+ /** Downloads, verifies the SHA-256, and unzips. Resolves with the local path. */
18
+ downloadUpdate(update: {
19
+ label: string;
20
+ packageHash: string;
21
+ downloadUrl: string;
22
+ size: number;
23
+ isMandatory: boolean;
24
+ description: string | null;
25
+ }): Promise<{ bundlePath: string; packageHash: string; label: string; size: number }>;
26
+ installUpdate(
27
+ packageHash: string,
28
+ installMode: number,
29
+ minimumBackgroundDuration: number,
30
+ ): Promise<void>;
31
+ notifyApplicationReady(): Promise<void>;
32
+ restartApp(onlyIfUpdateIsPending: boolean): Promise<void>;
33
+ getCurrentPackage(): Promise<Record<string, unknown> | null>;
34
+ /** Removes every downloaded package and reverts to the bundle in the binary. */
35
+ clearUpdates(): Promise<void>;
36
+ /** True the first time the app runs after `packageHash` was applied. */
37
+ isFirstRun(packageHash: string): Promise<boolean>;
38
+ getConstants?(): Record<string, unknown>;
39
+ }
40
+
41
+ const nativeModule = NativeModules.OtaUpdate as NativeOta | undefined;
42
+
43
+ export const NativeOtaUpdate: NativeOta = nativeModule
44
+ ? nativeModule
45
+ : (new Proxy({} as NativeOta, {
46
+ get() {
47
+ throw new Error(LINKING_ERROR);
48
+ },
49
+ }) as NativeOta);
50
+
51
+ export const isNativeModuleAvailable = Boolean(nativeModule);
52
+
53
+ export const DOWNLOAD_PROGRESS_EVENT = 'OtaUpdateDownloadProgress';
54
+
55
+ let emitter: NativeEventEmitter | null = null;
56
+
57
+ export function onDownloadProgress(
58
+ listener: (progress: DownloadProgress) => void,
59
+ ): { remove: () => void } {
60
+ if (!nativeModule) return { remove: () => undefined };
61
+ if (!emitter) emitter = new NativeEventEmitter(nativeModule as never);
62
+ const subscription = emitter.addListener(DOWNLOAD_PROGRESS_EVENT, listener);
63
+ return { remove: () => subscription.remove() };
64
+ }
package/src/types.ts ADDED
@@ -0,0 +1,125 @@
1
+ /** When a downloaded update actually replaces the running JS bundle. */
2
+ export enum InstallMode {
3
+ /** Swap in on the next natural app start. Safest; the default. */
4
+ ON_NEXT_RESTART = 0,
5
+ /** Swap in the next time the app returns from the background. */
6
+ ON_NEXT_RESUME = 1,
7
+ /** Reload the JS bundle right now. Interrupts the user — use for mandatory fixes. */
8
+ IMMEDIATE = 2,
9
+ }
10
+
11
+ /** Progress of a `sync()` call, reported through `onSyncStatusChange`. */
12
+ export enum SyncStatus {
13
+ UP_TO_DATE = 0,
14
+ UPDATE_INSTALLED = 1,
15
+ UPDATE_IGNORED = 2,
16
+ UNKNOWN_ERROR = 3,
17
+ SYNC_IN_PROGRESS = 4,
18
+ CHECKING_FOR_UPDATE = 5,
19
+ AWAITING_USER_ACTION = 6,
20
+ DOWNLOADING_PACKAGE = 7,
21
+ INSTALLING_UPDATE = 8,
22
+ }
23
+
24
+ export interface DownloadProgress {
25
+ totalBytes: number;
26
+ receivedBytes: number;
27
+ }
28
+
29
+ /** Metadata about an update that exists on the server but not yet on device. */
30
+ export interface RemotePackage {
31
+ label: string;
32
+ packageHash: string;
33
+ downloadUrl: string;
34
+ size: number;
35
+ isMandatory: boolean;
36
+ description: string | null;
37
+ targetBinaryVersion: string;
38
+ rollout: number;
39
+ isRollback: boolean;
40
+ deployment: string;
41
+ /** Fetches and verifies the bundle, returning the on-device package. */
42
+ download(onProgress?: (progress: DownloadProgress) => void): Promise<LocalPackage>;
43
+ }
44
+
45
+ /** An update that has been downloaded and verified onto the device. */
46
+ export interface LocalPackage {
47
+ label: string;
48
+ packageHash: string;
49
+ isMandatory: boolean;
50
+ description: string | null;
51
+ bundlePath: string;
52
+ size: number;
53
+ /** Applies the update according to `mode`. */
54
+ install(mode?: InstallMode, minimumBackgroundDuration?: number): Promise<void>;
55
+ }
56
+
57
+ /** The bundle currently running, if it came from an OTA update. */
58
+ export interface CurrentPackage {
59
+ label: string | null;
60
+ packageHash: string | null;
61
+ description: string | null;
62
+ isMandatory: boolean;
63
+ bundlePath: string | null;
64
+ appVersion: string;
65
+ /** True until `notifyAppReady()` confirms this update booted successfully. */
66
+ isPending: boolean;
67
+ /** True on the first run after this update was applied. */
68
+ isFirstRun: boolean;
69
+ }
70
+
71
+ export type NoUpdateReason =
72
+ | 'up_to_date'
73
+ | 'not_in_rollout'
74
+ | 'no_matching_binary_version'
75
+ | 'update_app_version';
76
+
77
+ export interface CheckResult {
78
+ update: RemotePackage | null;
79
+ reason?: NoUpdateReason;
80
+ /** Set when a newer *binary* is required — the fix is a store update, not OTA. */
81
+ updateAppVersion?: boolean;
82
+ }
83
+
84
+ export interface OtaConfiguration {
85
+ deploymentKey: string;
86
+ serverUrl: string;
87
+ appVersion: string;
88
+ clientUniqueId: string;
89
+ /** Hash/label of the bundle currently running, if it is an OTA package. */
90
+ packageHash: string | null;
91
+ label: string | null;
92
+ /**
93
+ * The app's real native bundle identifier (iOS `CFBundleIdentifier` /
94
+ * Android package name), read from the native runtime — not settable from
95
+ * JS. Sent on every update-check so the server can reject a deployment key
96
+ * used against an app it was not issued for.
97
+ */
98
+ bundleIdentifier: string;
99
+ }
100
+
101
+ export interface SyncOptions {
102
+ installMode?: InstallMode;
103
+ /** Install mode used when the release is flagged mandatory. */
104
+ mandatoryInstallMode?: InstallMode;
105
+ /** Seconds the app must stay backgrounded before an ON_NEXT_RESUME install applies. */
106
+ minimumBackgroundDuration?: number;
107
+ deploymentKey?: string;
108
+ onSyncStatusChange?: (status: SyncStatus) => void;
109
+ onDownloadProgress?: (progress: DownloadProgress) => void;
110
+ /**
111
+ * Return false to skip an update (e.g. after asking the user).
112
+ * Mandatory updates ignore the result.
113
+ */
114
+ shouldInstall?: (update: RemotePackage) => boolean | Promise<boolean>;
115
+ }
116
+
117
+ export interface OtaOptions {
118
+ /** Run a sync when the app starts. Default: true. */
119
+ checkOnAppStart?: boolean;
120
+ /** Run a sync each time the app returns to the foreground. Default: true. */
121
+ checkOnResume?: boolean;
122
+ /** Minimum seconds between automatic syncs. Default: 60. */
123
+ minimumSyncInterval?: number;
124
+ sync?: SyncOptions;
125
+ }