react-native-bspatch 3.1.2 → 4.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 (52) hide show
  1. package/README.md +3 -3
  2. package/android/build.gradle +18 -40
  3. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +148 -13
  4. package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +21 -3
  5. package/cpp/CMakeLists.txt +0 -3
  6. package/cpp/bsdiff/bspatch_wrapper.c +3 -2
  7. package/ios/OtaBundleResolver.swift +22 -5
  8. package/ios/RNBsPatch.mm +220 -32
  9. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  10. package/lib/commonjs/index.js +1 -13
  11. package/lib/commonjs/index.js.map +1 -1
  12. package/lib/commonjs/ota/OtaProvider.js +21 -21
  13. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  14. package/lib/commonjs/ota/otaCore.js +50 -88
  15. package/lib/commonjs/ota/otaCore.js.map +1 -1
  16. package/lib/commonjs/ota/otaMetadata.js +46 -23
  17. package/lib/commonjs/ota/otaMetadata.js.map +1 -1
  18. package/lib/commonjs/ota/storage/GitHubRawStorage.js +22 -12
  19. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
  20. package/lib/module/NativeBsPatch.js.map +1 -1
  21. package/lib/module/index.js +0 -8
  22. package/lib/module/index.js.map +1 -1
  23. package/lib/module/ota/OtaProvider.js +22 -22
  24. package/lib/module/ota/OtaProvider.js.map +1 -1
  25. package/lib/module/ota/otaCore.js +50 -87
  26. package/lib/module/ota/otaCore.js.map +1 -1
  27. package/lib/module/ota/otaMetadata.js +44 -21
  28. package/lib/module/ota/otaMetadata.js.map +1 -1
  29. package/lib/module/ota/storage/GitHubRawStorage.js +20 -10
  30. package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
  31. package/lib/typescript/NativeBsPatch.d.ts +36 -1
  32. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  33. package/lib/typescript/index.d.ts +0 -7
  34. package/lib/typescript/index.d.ts.map +1 -1
  35. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  36. package/lib/typescript/ota/otaCore.d.ts +2 -1
  37. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  38. package/lib/typescript/ota/otaMetadata.d.ts +5 -0
  39. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
  40. package/lib/typescript/ota/storage/GitHubRawStorage.d.ts +2 -0
  41. package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
  42. package/package.json +2 -3
  43. package/react-native-bspatch.podspec +3 -25
  44. package/src/NativeBsPatch.ts +44 -1
  45. package/src/index.ts +0 -7
  46. package/src/ota/OtaProvider.tsx +37 -23
  47. package/src/ota/otaCore.ts +55 -94
  48. package/src/ota/otaMetadata.ts +44 -25
  49. package/src/ota/storage/GitHubRawStorage.ts +17 -22
  50. package/tools/build_ota.sh +1 -1
  51. package/tools/upload_ota.sh +3 -2
  52. package/ios/RNBsPatch.h +0 -8
package/src/index.ts CHANGED
@@ -20,13 +20,6 @@ export * from './ota/otaLogger';
20
20
  export * from './ota/OtaProvider';
21
21
  export * from './ota/types';
22
22
 
23
- /**
24
- * @deprecated No-ops since 3.1: OTA assets are placed next to the bundle, where React
25
- * Native resolves them by default, so no source transformer is needed.
26
- */
27
- export const initOtaAssetResolver = async (_version: number): Promise<void> => {};
28
- /** @deprecated No-op since 3.1, see initOtaAssetResolver. */
29
- export const resetOtaAssetResolver = (): void => {};
30
23
  export { GitHubRawStorageProvider } from './ota/storage/GitHubRawStorage';
31
24
 
32
25
  export default {
@@ -16,11 +16,10 @@ import {
16
16
  Text,
17
17
  View,
18
18
  } from 'react-native';
19
- import ReactNativeBlobUtil from 'react-native-blob-util';
20
19
  import NativeBsPatch from '../NativeBsPatch';
21
20
  import { applyOtaPatchInternal, fetchOtaManifest, selectUpdate, validateOtaConfig } from './otaCore';
22
21
  import { createOtaLogger, OtaEvent } from './otaLogger';
23
- import { loadMetadata, markHealthy } from './otaMetadata';
22
+ import { DOCUMENT_DIR, loadMetadata, markHealthy, OTA_DIR } from './otaMetadata';
24
23
  import {
25
24
  OtaConfig,
26
25
  OtaMetrics,
@@ -109,11 +108,12 @@ export function OtaProvider({
109
108
  */
110
109
  const executeApplyUpdate = useCallback(
111
110
  async (patchList: OtaPatch[], currentVer: number, targetVer: number) => {
112
- console.log('[OtaProvider] executeApplyUpdate | targetV:', targetVer, 'currentV:', currentVer);
113
111
  const directPatch = selectUpdate(patchList, currentVer, targetVer);
114
112
 
115
113
  if (!directPatch) {
116
- console.warn('[OtaProvider] No update entry found for', currentVer, '->', targetVer);
114
+ if (__DEV__) {
115
+ console.warn('[OtaProvider] No update entry found for', currentVer, '->', targetVer);
116
+ }
117
117
  setStatus(OtaStatus.ERROR);
118
118
  setMessage('No upgrade path found.');
119
119
  return;
@@ -137,7 +137,9 @@ export function OtaProvider({
137
137
  },
138
138
  );
139
139
  } catch (error) {
140
- console.error('[OtaProvider] Update error:', error);
140
+ if (__DEV__) {
141
+ console.error('[OtaProvider] Update error:', error);
142
+ }
141
143
  setStatus(OtaStatus.ERROR);
142
144
  setMessage(`Update failed: ${(error as Error).message}`);
143
145
  }
@@ -223,21 +225,20 @@ export function OtaProvider({
223
225
  const remotePatches = await fetchOtaManifest(effectiveConfig);
224
226
  const metadata = await loadMetadata(config.baseAppVersion);
225
227
  const localV = metadata.currentVersion;
226
- console.log('[OtaProvider] checkUpdates | localV:', localV, 'remotePatches count:', remotePatches.length);
227
228
 
228
- // Skip versions that crashed on boot and were rolled back by the native resolver
229
+ // The one platform filter (storage providers return every platform).
230
+ // Also skips versions that crashed on boot and were rolled back natively.
229
231
  const failed = metadata.failedVersions ?? [];
230
232
  const platformPatches = remotePatches.filter(
231
- p => (!p.platform || p.platform === Platform.OS) && !failed.includes(p.toVersion),
233
+ p =>
234
+ (!p.platform || p.platform.toLowerCase() === Platform.OS) &&
235
+ !failed.includes(p.toVersion),
232
236
  );
233
- console.log('[OtaProvider] platformPatches count:', platformPatches.length);
234
237
 
235
238
  const maxV =
236
239
  platformPatches.length > 0
237
240
  ? Math.max(...platformPatches.map(p => p.toVersion))
238
241
  : localV;
239
-
240
- console.log('[OtaProvider] latestV determined:', maxV);
241
242
 
242
243
  setPatches(platformPatches);
243
244
  setCurrentVersion(localV);
@@ -286,10 +287,10 @@ export function OtaProvider({
286
287
  useEffect(() => {
287
288
  const bootstrap = async () => {
288
289
  // Clean up any pending trash directories from previous resets (non-blocking)
289
- ReactNativeBlobUtil.fs.ls(ReactNativeBlobUtil.fs.dirs.DocumentDir).then(dirs => {
290
+ NativeBsPatch.readDir(DOCUMENT_DIR).then(dirs => {
290
291
  for (const dir of dirs) {
291
292
  if (dir.startsWith('ota_trash_')) {
292
- ReactNativeBlobUtil.fs.unlink(`${ReactNativeBlobUtil.fs.dirs.DocumentDir}/${dir}`).catch(() => {});
293
+ NativeBsPatch.removePath(`${DOCUMENT_DIR}/${dir}`).catch(() => {});
293
294
  }
294
295
  }
295
296
  }).catch(() => {});
@@ -367,24 +368,22 @@ export function OtaProvider({
367
368
  };
368
369
  }, [config.updateMode, checkUpdates]);
369
370
 
370
- const markAsHealthy = async () => {
371
+ const markAsHealthy = useCallback(async () => {
371
372
  const log = createOtaLogger(config.onEvent);
372
373
  log.emit(OtaEvent.MARK_HEALTHY);
373
374
  const appVersion = await NativeBsPatch.getAppVersion().catch(() => config.appVersion);
374
375
  await markHealthy(config.baseAppVersion, runningVersionRef.current, appVersion);
375
- };
376
+ }, [config]);
376
377
 
377
- const resetToFactory = async () => {
378
+ const resetToFactory = useCallback(async () => {
378
379
  const log = createOtaLogger(config.onEvent);
379
380
  try {
380
381
  setStatus(OtaStatus.APPLYING);
381
382
  setMessage('Executing system wipe...');
382
383
  log.emit(OtaEvent.RESET_FACTORY);
383
- const otaDir = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota`;
384
- if (await ReactNativeBlobUtil.fs.exists(otaDir)) {
384
+ if (await NativeBsPatch.exists(OTA_DIR)) {
385
385
  // Fast atomic rename to prevent blocking the JS thread during deletion of potentially 1000s of files
386
- const trashDir = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota_trash_${Date.now()}`;
387
- await ReactNativeBlobUtil.fs.mv(otaDir, trashDir);
386
+ await NativeBsPatch.moveFile(OTA_DIR, `${DOCUMENT_DIR}/ota_trash_${Date.now()}`);
388
387
  // Deletion is deferred to the next app bootstrap to prevent blocking the bridge before reload
389
388
  }
390
389
  setMessage('Reset complete. Core reload initiated...');
@@ -393,9 +392,10 @@ export function OtaProvider({
393
392
  setStatus(OtaStatus.ERROR);
394
393
  setMessage(`Reset failed: ${(error as Error).message}`);
395
394
  }
396
- };
395
+ }, [config]);
397
396
 
398
- const value: OtaState = {
397
+ // Memoized: consumers re-render only when something they can see changes
398
+ const value = useMemo<OtaState>(() => ({
399
399
  status,
400
400
  message,
401
401
  progress,
@@ -410,7 +410,21 @@ export function OtaProvider({
410
410
  markAsHealthy,
411
411
  checkUpdate: checkUpdates,
412
412
  reloadBundle: () => NativeBsPatch.reloadBundle(),
413
- };
413
+ }), [
414
+ status,
415
+ message,
416
+ progress,
417
+ currentVersion,
418
+ latestVersion,
419
+ pendingCount,
420
+ isChecking,
421
+ metrics,
422
+ isUpdateReady,
423
+ applyUpdate,
424
+ resetToFactory,
425
+ markAsHealthy,
426
+ checkUpdates,
427
+ ]);
414
428
 
415
429
  // Logic to determine if we should block the UI
416
430
  const isBlocking = useMemo(() => {
@@ -11,18 +11,19 @@
11
11
  * - Structured telemetry logging
12
12
  */
13
13
 
14
- import { Platform } from 'react-native';
15
- import ReactNativeBlobUtil from 'react-native-blob-util';
16
14
  import NativeBsPatch from '../NativeBsPatch';
17
15
  import { createOtaLogger, OtaEvent } from './otaLogger';
18
16
  import {
17
+ ASSET_LIST_FILE,
18
+ DOCUMENT_DIR,
19
19
  ensureOtaDir,
20
+ OTA_DIR,
20
21
  getBundlePath,
21
22
  loadMetadata,
22
23
  registerNewVersion,
23
24
  updateMetadata,
24
25
  } from './otaMetadata';
25
- import { GitHubRawStorageProvider } from './storage/GitHubRawStorage';
26
+ import { fetchJson, GitHubRawStorageProvider } from './storage/GitHubRawStorage';
26
27
  import {
27
28
  OtaStatus,
28
29
  type OtaAsset,
@@ -32,8 +33,6 @@ import {
32
33
  type OtaStorageProvider,
33
34
  } from './types';
34
35
 
35
- const OTA_DIR = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota`;
36
-
37
36
  // ─── Storage Provider Factory ──────────────────────────────────────────
38
37
 
39
38
  const DEFAULT_STORAGE = new GitHubRawStorageProvider();
@@ -97,7 +96,7 @@ export const verifyHash = async (
97
96
  ): Promise<boolean> => {
98
97
  if (!expectedHash) return true;
99
98
  try {
100
- const hash = await ReactNativeBlobUtil.fs.hash(path, 'sha256');
99
+ const hash = await NativeBsPatch.hashFile(path);
101
100
  return hash.toLowerCase() === expectedHash.toLowerCase();
102
101
  } catch {
103
102
  return false;
@@ -125,66 +124,40 @@ const assertSigned = async (
125
124
 
126
125
  /**
127
126
  * Download a file with retry logic using exponential backoff.
128
- * Cancels any in-flight request before retrying to avoid resource leaks.
127
+ * Native streams to `${destPath}.part` and renames only on a 2xx response, so a
128
+ * cut-off download or an error page never sits at destPath looking complete.
129
+ * @returns Size of the downloaded file in bytes
129
130
  */
130
131
  const downloadWithRetry = async (
131
132
  url: string,
132
133
  destPath: string,
133
134
  config: OtaConfig,
134
135
  onProgress: (percent: number) => void,
135
- headers: Record<string, string> = {},
136
- ): Promise<void> => {
137
- const retryLimit = config.network?.retry ?? 3;
136
+ ): Promise<number> => {
137
+ const retryLimit = Math.max(1, config.network?.retry ?? 3);
138
138
  const timeout = config.network?.timeoutMs ?? 30000;
139
+ const subscription = NativeBsPatch.onDownloadProgress(e => {
140
+ // total is -1 without a Content-Length
141
+ if (e.path === destPath && e.total > 0) {
142
+ onProgress(Math.min(100, Math.floor((e.received / e.total) * 100)));
143
+ }
144
+ });
139
145
 
140
- for (let i = 0; i < retryLimit; i++) {
141
- let taskRef: { cancel: () => void } | null = null;
142
- // Download to .part and rename only on a 2xx response, so a cut-off
143
- // download or an error page never sits at destPath looking complete.
144
- const partPath = `${destPath}.part`;
145
- try {
146
- const task = ReactNativeBlobUtil.config({
147
- fileCache: true,
148
- path: partPath,
149
- followRedirect: true,
150
- timeout,
151
- })
152
- .fetch('GET', url, {
153
- 'Cache-Control': 'no-cache',
154
- ...headers,
155
- })
156
- .progress((received: any, total: any) => {
157
- const percent = Math.floor(
158
- (Number(received) / Number(total)) * 100,
159
- );
160
- onProgress(percent);
161
- });
162
- taskRef = { cancel: () => task.cancel() };
163
- const resp = await task;
164
- const status = resp.respInfo.status;
165
- if (status < 200 || status >= 300) {
166
- throw new Error(`HTTP ${status} for ${url}`);
167
- }
168
- if (await ReactNativeBlobUtil.fs.exists(destPath)) {
169
- await ReactNativeBlobUtil.fs.unlink(destPath);
170
- }
171
- await ReactNativeBlobUtil.fs.mv(partPath, destPath);
172
- return;
173
- } catch (e) {
174
- // Cancel any lingering request before retrying
175
- taskRef?.cancel();
176
- await ReactNativeBlobUtil.fs.unlink(partPath).catch(() => {});
177
- if (i === retryLimit - 1) throw e;
178
- const delay = Math.pow(2, i) * 1000; // Exponential backoff: 1s, 2s, 4s...
179
- if (__DEV__) {
180
- console.warn(
181
- `[otaCore] Download attempt ${
182
- i + 1
183
- } failed. Retrying in ${delay}ms...`,
184
- );
146
+ try {
147
+ for (let i = 0; ; i++) {
148
+ try {
149
+ return await NativeBsPatch.downloadFile(url, destPath, timeout);
150
+ } catch (e) {
151
+ if (i >= retryLimit - 1) throw e;
152
+ const delay = Math.pow(2, i) * 1000; // Exponential backoff: 1s, 2s, 4s...
153
+ if (__DEV__) {
154
+ console.warn(`[otaCore] Download attempt ${i + 1} failed. Retrying in ${delay}ms...`);
155
+ }
156
+ await new Promise<void>(resolve => setTimeout(resolve, delay));
185
157
  }
186
- await new Promise<void>(resolve => setTimeout(resolve, delay));
187
158
  }
159
+ } finally {
160
+ subscription.remove();
188
161
  }
189
162
  };
190
163
 
@@ -204,7 +177,7 @@ export const fetchOtaManifest = (config: OtaConfig): Promise<OtaPatch[]> =>
204
177
 
205
178
  // ─── Hashed Asset Store ───────────────────────────────────────────────
206
179
 
207
- const ASSET_STORE_DIR = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota/assets`;
180
+ const ASSET_STORE_DIR = `${OTA_DIR}/assets`;
208
181
 
209
182
  /** Relative path inside the version dir: no absolute paths, no `..` segments. */
210
183
  const isSafeRelativePath = (p: string): boolean =>
@@ -231,20 +204,13 @@ const downloadAndSyncAssets = async (
231
204
  ) => void,
232
205
  onProgress: (percent: number) => void,
233
206
  ): Promise<void> => {
234
- const fs = ReactNativeBlobUtil.fs;
207
+ const fs = NativeBsPatch;
235
208
  onStatus(OtaStatus.SYNCING_ASSETS, 'Syncing assets...');
236
209
  const versionDir = `${OTA_DIR}/v${version}`;
237
210
 
238
211
  let assets: OtaAsset[] = [];
239
212
  try {
240
- const resp = await ReactNativeBlobUtil.config({
241
- followRedirect: true,
242
- timeout: 10000,
243
- }).fetch('GET', assetManifestUrl, { 'Cache-Control': 'no-cache' });
244
- if (resp.respInfo.status !== 200) {
245
- throw new Error(`status ${resp.respInfo.status}`);
246
- }
247
- const manifest = typeof resp.data === 'string' ? JSON.parse(resp.data) : resp.data;
213
+ const manifest = await fetchJson(assetManifestUrl, 10000);
248
214
  // Hash/ext/path become file names: reject anything unexpected
249
215
  assets = (manifest.assets || []).filter(
250
216
  (a: any) =>
@@ -258,13 +224,18 @@ const downloadAndSyncAssets = async (
258
224
  throw new Error(`Failed to fetch asset manifest: ${(e as Error).message}`);
259
225
  }
260
226
  if (assets.length === 0) return;
227
+ // Lets cleanupOldVersions keep the store files that retained versions use
228
+ await fs.writeFile(
229
+ `${versionDir}/${ASSET_LIST_FILE}`,
230
+ JSON.stringify(assets.map(a => `${a.hash}.${a.ext}`)),
231
+ );
261
232
 
262
233
  onStatus(OtaStatus.SYNCING_ASSETS, `Syncing ${assets.length} asset(s)...`);
263
- // One promise per dir: parallel workers would otherwise race mkdir (EEXIST)
234
+ // Native mkdir creates parents and tolerates an existing dir; this just skips repeats
264
235
  const dirs = new Map<string, Promise<void>>();
265
236
  const ensureDir = (dir: string): Promise<void> => {
266
237
  if (!dirs.has(dir)) {
267
- dirs.set(dir, fs.exists(dir).then(ok => (ok ? undefined : fs.mkdir(dir))));
238
+ dirs.set(dir, fs.mkdir(dir));
268
239
  }
269
240
  return dirs.get(dir)!;
270
241
  };
@@ -285,18 +256,14 @@ const downloadAndSyncAssets = async (
285
256
  if (!url) throw new Error('no URL');
286
257
  await downloadWithRetry(url, storePath, config, () => {});
287
258
  if (!(await verifyHash(storePath, asset.hash))) {
288
- await fs.unlink(storePath).catch(() => {});
259
+ await fs.removePath(storePath).catch(() => {});
289
260
  throw new Error('hash mismatch');
290
261
  }
291
262
  downloaded++;
292
263
  }
293
264
  const dest = `${versionDir}/${asset.originalPath}`;
294
- const parts = asset.originalPath.split('/').slice(0, -1);
295
- for (let i = 1; i <= parts.length; i++) {
296
- await ensureDir(`${versionDir}/${parts.slice(0, i).join('/')}`);
297
- }
298
- if (await fs.exists(dest)) await fs.unlink(dest);
299
- await fs.cp(storePath, dest);
265
+ await ensureDir(dest.slice(0, dest.lastIndexOf('/')));
266
+ await fs.copyFile(storePath, dest);
300
267
  } catch (e) {
301
268
  if (__DEV__) {
302
269
  console.warn(`[otaCore] Asset ${asset.originalPath} failed:`, e);
@@ -342,7 +309,7 @@ export const getLocalAssetPath = (hash: string, ext: string): string =>
342
309
  * Gets the local filesystem path for a downloaded patch file.
343
310
  *
344
311
  * @param patch - Patch metadata object
345
- * @param docDir - Document directory path (use `ReactNativeBlobUtil.fs.dirs.DocumentDir`)
312
+ * @param docDir - Document directory path (`DOCUMENT_DIR`)
346
313
  * @returns Absolute path to the patch file
347
314
  */
348
315
  export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
@@ -351,15 +318,14 @@ export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
351
318
  /**
352
319
  * Picks the registry entry for `localV → targetV`: a patch from the OTA bundle
353
320
  * already on disk, else the full-bundle entry (store build, or outside the patch window).
321
+ * `patchList` is already filtered to this platform (checkUpdates).
354
322
  */
355
323
  export const selectUpdate = (
356
324
  patchList: OtaPatch[],
357
325
  localV: number,
358
326
  targetV: number,
359
327
  ): OtaPatch | undefined => {
360
- const forTarget = patchList.filter(
361
- p => p.toVersion === targetV && (!p.platform || p.platform === Platform.OS),
362
- );
328
+ const forTarget = patchList.filter(p => p.toVersion === targetV);
363
329
  return (
364
330
  forTarget.find(p => p.filename && p.fromVersion === localV) ??
365
331
  forTarget.find(p => !p.filename)
@@ -417,13 +383,13 @@ const runUpdatePipeline = async (
417
383
  const log = createOtaLogger(config.onEvent);
418
384
  await ensureOtaDir();
419
385
  const totalStart = Date.now();
420
- const fs = ReactNativeBlobUtil.fs;
386
+ const fs = NativeBsPatch;
421
387
 
422
388
  const finalBundlePath =
423
389
  getBundlePath(patch.toVersion, config.baseAppVersion) || '';
424
390
  const tempBundle = `${finalBundlePath}.tmp`;
425
391
  const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
426
- const localPatchPath = getLocalPatchPath(patch, fs.dirs.DocumentDir);
392
+ const localPatchPath = getLocalPatchPath(patch, DOCUMENT_DIR);
427
393
 
428
394
  // ── Version Compatibility ──
429
395
  // Checked before anything else so an incompatible update can't reach the full-bundle path
@@ -448,16 +414,15 @@ const runUpdatePipeline = async (
448
414
  }
449
415
  }
450
416
 
451
- if (!(await fs.exists(versionDir))) {
452
- await fs.mkdir(versionDir);
453
- }
417
+ await fs.mkdir(versionDir);
454
418
 
455
419
  const download = async (remoteName: string, dest: string) => {
456
420
  log.emit(OtaEvent.DOWNLOAD_START, { version: patch.toVersion });
457
421
  const dlStart = Date.now();
422
+ let sizeBytes: number;
458
423
  try {
459
424
  const url = await getStorage(config).getUpdateFileUrl(remoteName, config);
460
- await downloadWithRetry(url, dest, config, onProgress);
425
+ sizeBytes = await downloadWithRetry(url, dest, config, onProgress);
461
426
  } catch (dlError) {
462
427
  log.emit(OtaEvent.DOWNLOAD_FAIL, {
463
428
  error: (dlError as Error).message,
@@ -466,7 +431,6 @@ const runUpdatePipeline = async (
466
431
  throw dlError;
467
432
  }
468
433
  const downloadMs = Date.now() - dlStart;
469
- const sizeBytes = Number((await fs.stat(dest)).size);
470
434
  log.emit(OtaEvent.DOWNLOAD_SUCCESS, {
471
435
  version: patch.toVersion,
472
436
  durationMs: downloadMs,
@@ -477,7 +441,7 @@ const runUpdatePipeline = async (
477
441
 
478
442
  const assertHash = async (path: string, expected: string | undefined, what: string) => {
479
443
  if (!expected || (await verifyHash(path, expected))) return;
480
- const actual = await fs.hash(path, 'sha256').catch(() => '');
444
+ const actual = await fs.hashFile(path).catch(() => '');
481
445
  log.emit(OtaEvent.HASH_MISMATCH, {
482
446
  message: `${what} hash mismatch`,
483
447
  version: patch.toVersion,
@@ -503,10 +467,7 @@ const runUpdatePipeline = async (
503
467
  );
504
468
  }
505
469
 
506
- if (await fs.exists(finalBundlePath)) {
507
- await fs.unlink(finalBundlePath);
508
- }
509
- await fs.mv(tempBundle, finalBundlePath);
470
+ await fs.moveFile(tempBundle, finalBundlePath);
510
471
 
511
472
  // Native version, not config.appVersion: the native resolver compares against it on boot
512
473
  const nativeAppVersion = await NativeBsPatch.getAppVersion().catch(() => config.appVersion);
@@ -556,7 +517,7 @@ const runUpdatePipeline = async (
556
517
  const applyStart = Date.now();
557
518
  const success = await NativeBsPatch.applyPatch(sourcePath, tempBundle, localPatchPath);
558
519
  const applyMs = Date.now() - applyStart;
559
- await fs.unlink(localPatchPath).catch(() => {});
520
+ await fs.removePath(localPatchPath).catch(() => {});
560
521
  if (!success) {
561
522
  log.emit(OtaEvent.PATCH_FAIL, {
562
523
  message: 'BsPatch returned false',
@@ -583,8 +544,8 @@ const runUpdatePipeline = async (
583
544
  };
584
545
 
585
546
  const cleanup = async () => {
586
- await fs.unlink(localPatchPath).catch(() => {});
587
- await fs.unlink(tempBundle).catch(() => {});
547
+ await fs.removePath(localPatchPath).catch(() => {});
548
+ await fs.removePath(tempBundle).catch(() => {});
588
549
  };
589
550
 
590
551
  // Patch only from an OTA bundle we installed ourselves (byte-identical to the build output)
@@ -1,10 +1,15 @@
1
1
  import { Platform } from 'react-native';
2
- import ReactNativeBlobUtil from 'react-native-blob-util';
2
+ import NativeBsPatch from '../NativeBsPatch';
3
3
  import { OtaMetadata } from './types';
4
4
 
5
- const OTA_DIR = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota`;
5
+ /** Same directory the native resolver reads `ota/` from */
6
+ export const DOCUMENT_DIR = NativeBsPatch.getDocumentDir();
7
+ export const OTA_DIR = `${DOCUMENT_DIR}/ota`;
6
8
  const METADATA_PATH = `${OTA_DIR}/metadata.json`;
7
9
  const METADATA_TMP_PATH = `${METADATA_PATH}.tmp`;
10
+ const ASSET_STORE_DIR = `${OTA_DIR}/assets`;
11
+ /** Per-version list of `hash.ext` store files the version uses (written by otaCore) */
12
+ export const ASSET_LIST_FILE = 'assets.json';
8
13
 
9
14
  /** Serializes metadata updates (see updateMetadata). */
10
15
  let saveLock: Promise<void> = Promise.resolve();
@@ -12,11 +17,7 @@ let saveLock: Promise<void> = Promise.resolve();
12
17
  /**
13
18
  * Ensures the OTA root directory exists.
14
19
  */
15
- export const ensureOtaDir = async (): Promise<void> => {
16
- if (!(await ReactNativeBlobUtil.fs.exists(OTA_DIR))) {
17
- await ReactNativeBlobUtil.fs.mkdir(OTA_DIR);
18
- }
19
- };
20
+ export const ensureOtaDir = (): Promise<void> => NativeBsPatch.mkdir(OTA_DIR);
20
21
 
21
22
  /**
22
23
  * Loads the current OTA metadata from the local filesystem.
@@ -28,10 +29,10 @@ export const loadMetadata = async (
28
29
  defaultVersion: number,
29
30
  ): Promise<OtaMetadata> => {
30
31
  try {
31
- // A crash between unlink and rename in saveMetadata leaves only the .tmp copy
32
+ // Versions before 4.0 could crash between unlink and rename, leaving only the .tmp copy
32
33
  for (const path of [METADATA_PATH, METADATA_TMP_PATH]) {
33
- if (await ReactNativeBlobUtil.fs.exists(path)) {
34
- return JSON.parse(await ReactNativeBlobUtil.fs.readFile(path, 'utf8'));
34
+ if (await NativeBsPatch.exists(path)) {
35
+ return JSON.parse(await NativeBsPatch.readFile(path));
35
36
  }
36
37
  }
37
38
  } catch (e) {
@@ -62,16 +63,8 @@ export const updateMetadata = async (
62
63
  await prevLock;
63
64
  const metadata = fn(await loadMetadata(defaultVersion));
64
65
  await ensureOtaDir();
65
- // Write-then-rename so a kill mid-write never leaves truncated JSON
66
- await ReactNativeBlobUtil.fs.writeFile(
67
- METADATA_TMP_PATH,
68
- JSON.stringify(metadata, null, 2),
69
- 'utf8',
70
- );
71
- if (await ReactNativeBlobUtil.fs.exists(METADATA_PATH)) {
72
- await ReactNativeBlobUtil.fs.unlink(METADATA_PATH);
73
- }
74
- await ReactNativeBlobUtil.fs.mv(METADATA_TMP_PATH, METADATA_PATH);
66
+ // Native writeFile is atomic: a kill mid-write never leaves truncated JSON
67
+ await NativeBsPatch.writeFile(METADATA_PATH, JSON.stringify(metadata, null, 2));
75
68
  return metadata;
76
69
  } catch (e) {
77
70
  console.error('[otaMetadata] Failed to save metadata:', e);
@@ -125,8 +118,8 @@ export const cleanupOldVersions = async (
125
118
  baseVersion: number,
126
119
  ): Promise<void> => {
127
120
  try {
128
- if (!(await ReactNativeBlobUtil.fs.exists(OTA_DIR))) return;
129
- const items = await ReactNativeBlobUtil.fs.ls(OTA_DIR);
121
+ if (!(await NativeBsPatch.exists(OTA_DIR))) return;
122
+ const items = await NativeBsPatch.readDir(OTA_DIR);
130
123
 
131
124
  // Retain current active version and rollback versions
132
125
  const retainedVersions = new Set<number>([
@@ -139,9 +132,10 @@ export const cleanupOldVersions = async (
139
132
  const versionMatch = item.match(/^v(\d+)$/);
140
133
  if (versionMatch) {
141
134
  const ver = parseInt(versionMatch[1], 10);
142
- if (!retainedVersions.has(ver) && ver !== baseVersion) {
135
+ // Higher than current: may be an install in progress, not yet in metadata
136
+ if (!retainedVersions.has(ver) && ver !== baseVersion && ver < metadata.currentVersion) {
143
137
  const dirPath = `${OTA_DIR}/${item}`;
144
- await ReactNativeBlobUtil.fs.unlink(dirPath).catch(() => {});
138
+ await NativeBsPatch.removePath(dirPath).catch(() => {});
145
139
  if (__DEV__) {
146
140
  console.log(`[otaMetadata] Cleaned up obsolete OTA version: ${item}`);
147
141
  }
@@ -149,14 +143,39 @@ export const cleanupOldVersions = async (
149
143
  }
150
144
  // Clean up leftover temporary bundle files (.tmp)
151
145
  if (item.endsWith('.tmp') && item !== 'metadata.json.tmp') {
152
- await ReactNativeBlobUtil.fs.unlink(`${OTA_DIR}/${item}`).catch(() => {});
146
+ await NativeBsPatch.removePath(`${OTA_DIR}/${item}`).catch(() => {});
153
147
  }
154
148
  }
149
+ await cleanupAssetStore();
155
150
  } catch (e) {
156
151
  console.warn('[otaMetadata] Cleanup old versions failed:', e);
157
152
  }
158
153
  };
159
154
 
155
+ /**
156
+ * Deletes store files no remaining version lists. The store is only a download
157
+ * cache (each version holds its own copies), so this never breaks a version.
158
+ * ponytail: an install that reuses a store file between our read of its list and
159
+ * the unlink fails its asset sync; it is retried on the next check.
160
+ */
161
+ const cleanupAssetStore = async (): Promise<void> => {
162
+ const fs = NativeBsPatch;
163
+ if (!(await fs.exists(ASSET_STORE_DIR))) return;
164
+ const keep = new Set<string>();
165
+ for (const item of await fs.readDir(OTA_DIR)) {
166
+ const list = `${OTA_DIR}/${item}/${ASSET_LIST_FILE}`;
167
+ if (/^v\d+$/.test(item) && (await fs.exists(list))) {
168
+ const files: string[] = JSON.parse(await fs.readFile(list));
169
+ files.forEach(f => keep.add(f));
170
+ }
171
+ }
172
+ for (const file of await fs.readDir(ASSET_STORE_DIR)) {
173
+ if (!keep.has(file)) {
174
+ await fs.removePath(`${ASSET_STORE_DIR}/${file}`).catch(() => {});
175
+ }
176
+ }
177
+ };
178
+
160
179
  /**
161
180
  * Marks the running version healthy: the native resolver stops treating its
162
181
  * launches as pending, so it is never rolled back afterwards (crash on first
@@ -1,5 +1,3 @@
1
- import { Platform } from 'react-native';
2
- import ReactNativeBlobUtil from 'react-native-blob-util';
3
1
  import type { OtaConfig, OtaPatch, OtaStorageProvider } from '../types';
4
2
 
5
3
  /**
@@ -19,6 +17,21 @@ import type { OtaConfig, OtaPatch, OtaStorageProvider } from '../types';
19
17
  /** Headers to bypass GitHub CDN cache on mutable files (registry, manifests) */
20
18
  const NO_CACHE_HEADERS = { 'Cache-Control': 'no-cache' };
21
19
 
20
+ /** GET + JSON.parse with a timeout (fetch has none of its own); rejects on non-2xx. */
21
+ export const fetchJson = async (url: string, timeoutMs: number): Promise<any> => {
22
+ const controller = new AbortController();
23
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
24
+ try {
25
+ const res = await fetch(url, { headers: NO_CACHE_HEADERS, signal: controller.signal });
26
+ if (!res.ok) {
27
+ throw new Error(`HTTP ${res.status} for ${url}`);
28
+ }
29
+ return await res.json();
30
+ } finally {
31
+ clearTimeout(timer);
32
+ }
33
+ };
34
+
22
35
  /** Append a timestamp query param to defeat CDN caches on mutable URLs */
23
36
  const bustCache = (url: string): string =>
24
37
  `${url}?cb=${Date.now()}`;
@@ -47,21 +60,7 @@ export class GitHubRawStorageProvider implements OtaStorageProvider {
47
60
  );
48
61
 
49
62
  try {
50
- const res = await ReactNativeBlobUtil.config({ timeout: 15000 }).fetch(
51
- 'GET',
52
- registryUrl,
53
- NO_CACHE_HEADERS,
54
- );
55
- if (res.respInfo.status !== 200) {
56
- throw new Error(
57
- `Registry fetch failed with status ${res.respInfo.status}`,
58
- );
59
- }
60
-
61
- const registry =
62
- typeof res.data === 'string'
63
- ? JSON.parse(res.data)
64
- : res.data;
63
+ const registry = await fetchJson(registryUrl, 15000);
65
64
 
66
65
  if (!config.appVersion) {
67
66
  throw new Error(
@@ -71,11 +70,7 @@ export class GitHubRawStorageProvider implements OtaStorageProvider {
71
70
  const appGroup = registry?.apps?.[config.appVersion];
72
71
  const flavorGroup = appGroup?.flavors?.[channelName];
73
72
 
74
- return (flavorGroup?.patches ?? []).filter(
75
- (p: OtaPatch) =>
76
- !p.platform ||
77
- p.platform.toLowerCase() === Platform.OS.toLowerCase(),
78
- );
73
+ return flavorGroup?.patches ?? [];
79
74
  } catch (error) {
80
75
  // Surface it: "no patches" would report a broken registry as up to date
81
76
  throw new Error(`[GitHubRawStorage] Manifest fetch error: ${(error as Error).message}`);