react-native-bspatch 1.1.4 → 2.0.1

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 (89) hide show
  1. package/README.md +22 -32
  2. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +15 -102
  3. package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +21 -1
  4. package/cpp/bsdiff/bspatch.c +1 -0
  5. package/ios/OtaBundleResolver.swift +16 -1
  6. package/ios/RNBsPatch.mm +61 -154
  7. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  8. package/lib/commonjs/index.js +4 -4
  9. package/lib/commonjs/index.js.map +1 -1
  10. package/lib/commonjs/ota/OtaProvider.js +26 -22
  11. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  12. package/lib/commonjs/ota/otaAssetResolver.js +8 -2
  13. package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
  14. package/lib/commonjs/ota/otaCore.js +89 -141
  15. package/lib/commonjs/ota/otaCore.js.map +1 -1
  16. package/lib/commonjs/ota/otaLogger.js +0 -2
  17. package/lib/commonjs/ota/otaLogger.js.map +1 -1
  18. package/lib/commonjs/ota/otaMetadata.js +18 -7
  19. package/lib/commonjs/ota/otaMetadata.js.map +1 -1
  20. package/lib/commonjs/ota/storage/GitHubRawStorage.js +3 -1
  21. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
  22. package/lib/commonjs/ota/types.js +1 -10
  23. package/lib/commonjs/ota/types.js.map +1 -1
  24. package/lib/module/NativeBsPatch.js.map +1 -1
  25. package/lib/module/index.js +1 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/ota/OtaProvider.js +26 -22
  28. package/lib/module/ota/OtaProvider.js.map +1 -1
  29. package/lib/module/ota/otaAssetResolver.js +8 -2
  30. package/lib/module/ota/otaAssetResolver.js.map +1 -1
  31. package/lib/module/ota/otaCore.js +88 -139
  32. package/lib/module/ota/otaCore.js.map +1 -1
  33. package/lib/module/ota/otaLogger.js +0 -2
  34. package/lib/module/ota/otaLogger.js.map +1 -1
  35. package/lib/module/ota/otaMetadata.js +18 -7
  36. package/lib/module/ota/otaMetadata.js.map +1 -1
  37. package/lib/module/ota/storage/GitHubRawStorage.js +3 -1
  38. package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
  39. package/lib/module/ota/types.js +0 -9
  40. package/lib/module/ota/types.js.map +1 -1
  41. package/lib/typescript/NativeBsPatch.d.ts +3 -15
  42. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  43. package/lib/typescript/index.d.ts +1 -1
  44. package/lib/typescript/index.d.ts.map +1 -1
  45. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  46. package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -1
  47. package/lib/typescript/ota/otaCore.d.ts +5 -42
  48. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  49. package/lib/typescript/ota/otaLogger.d.ts +1 -3
  50. package/lib/typescript/ota/otaLogger.d.ts.map +1 -1
  51. package/lib/typescript/ota/otaMetadata.d.ts +2 -2
  52. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
  53. package/lib/typescript/ota/types.d.ts +18 -54
  54. package/lib/typescript/ota/types.d.ts.map +1 -1
  55. package/package.json +3 -6
  56. package/react-native-bspatch.podspec +0 -1
  57. package/src/NativeBsPatch.ts +3 -16
  58. package/src/index.ts +1 -1
  59. package/src/ota/OtaProvider.tsx +24 -23
  60. package/src/ota/otaAssetResolver.ts +5 -2
  61. package/src/ota/otaCore.ts +118 -176
  62. package/src/ota/otaLogger.ts +0 -2
  63. package/src/ota/otaMetadata.ts +18 -8
  64. package/src/ota/storage/GitHubRawStorage.ts +1 -1
  65. package/src/ota/types.ts +18 -57
  66. package/tools/build_ota.sh +117 -183
  67. package/tools/setup_project.js +33 -14
  68. package/tools/update_registry.js +24 -28
  69. package/tools/{upload_ota_custom.sh → upload_ota.sh} +33 -114
  70. package/lib/commonjs/crypto-js.d.js +0 -2
  71. package/lib/commonjs/crypto-js.d.js.map +0 -1
  72. package/lib/commonjs/ota/storage/CustomStorage.js +0 -160
  73. package/lib/commonjs/ota/storage/CustomStorage.js.map +0 -1
  74. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js +0 -99
  75. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js.map +0 -1
  76. package/lib/module/crypto-js.d.js +0 -2
  77. package/lib/module/crypto-js.d.js.map +0 -1
  78. package/lib/module/ota/storage/CustomStorage.js +0 -154
  79. package/lib/module/ota/storage/CustomStorage.js.map +0 -1
  80. package/lib/module/ota/storage/GitHubReleaseStorage.js +0 -93
  81. package/lib/module/ota/storage/GitHubReleaseStorage.js.map +0 -1
  82. package/lib/typescript/ota/storage/CustomStorage.d.ts +0 -12
  83. package/lib/typescript/ota/storage/CustomStorage.d.ts.map +0 -1
  84. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts +0 -20
  85. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts.map +0 -1
  86. package/src/crypto-js.d.ts +0 -1
  87. package/src/ota/storage/CustomStorage.ts +0 -166
  88. package/src/ota/storage/GitHubReleaseStorage.ts +0 -103
  89. package/tools/bsdiff +0 -0
@@ -4,8 +4,7 @@
4
4
  * Responsibilities:
5
5
  * - Download patches with retry
6
6
  * - Verify integrity (SHA-256 hash)
7
- * - Verify security (HMAC signature)
8
- * - Handle gzip decompression
7
+ * - Verify security (RSA/ECDSA signature)
9
8
  * - Apply binary patch (bsdiff)
10
9
  * - Atomic bundle swap
11
10
  * - Full bundle fallback on failure
@@ -14,7 +13,6 @@
14
13
 
15
14
  import { Platform } from 'react-native';
16
15
  import ReactNativeBlobUtil from 'react-native-blob-util';
17
- import CryptoJS from 'crypto-js';
18
16
  import NativeBsPatch from '../NativeBsPatch';
19
17
  import { createOtaLogger, OtaEvent } from './otaLogger';
20
18
  import {
@@ -24,12 +22,9 @@ import {
24
22
  registerNewVersion,
25
23
  saveMetadata,
26
24
  } from './otaMetadata';
27
- import { CustomStorageProvider } from './storage/CustomStorage';
28
- import { GitHubReleaseStorageProvider } from './storage/GitHubReleaseStorage';
29
25
  import { GitHubRawStorageProvider } from './storage/GitHubRawStorage';
30
26
  import {
31
27
  OtaStatus,
32
- OtaStorageType,
33
28
  type OtaAsset,
34
29
  type OtaConfig,
35
30
  type OtaMetrics,
@@ -41,20 +36,11 @@ const OTA_DIR = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota`;
41
36
 
42
37
  // ─── Storage Provider Factory ──────────────────────────────────────────
43
38
 
44
- const DEFAULT_STORAGE = new CustomStorageProvider();
45
- const GITHUB_STORAGE = new GitHubReleaseStorageProvider();
46
- const GITHUB_RAW_STORAGE = new GitHubRawStorageProvider();
39
+ const DEFAULT_STORAGE = new GitHubRawStorageProvider();
47
40
 
48
- /**
49
- * Get the storage provider for the given configuration.
50
- * Priority: explicit storage instance > storageType > default (CustomStorage)
51
- */
52
- const getStorage = (config: OtaConfig): OtaStorageProvider => {
53
- if (config.storage) return config.storage;
54
- if (config.storageType === OtaStorageType.GITHUB) return GITHUB_STORAGE;
55
- if (config.storageType === OtaStorageType.GITHUB_RAW) return GITHUB_RAW_STORAGE;
56
- return DEFAULT_STORAGE;
57
- };
41
+ /** Explicit `config.storage` (e.g. a future Cloudflare provider) or GitHub raw. */
42
+ const getStorage = (config: OtaConfig): OtaStorageProvider =>
43
+ config.storage ?? DEFAULT_STORAGE;
58
44
 
59
45
  // ─── Helpers ──────────────────────────────────────────────────────────
60
46
 
@@ -103,93 +89,22 @@ export const verifyHash = async (
103
89
  };
104
90
 
105
91
  /**
106
- * Verify HMAC-SHA256 signature of a file.
107
- *
108
- * Uses CryptoJS to verify that the signature matches the file content
109
- * using the provided HMAC key.
110
- *
111
- * @param filePath - Path to the file to verify
112
- * @param signature - Base64-encoded HMAC signature to verify against
113
- * @param publicKey - Base64-encoded HMAC key
114
- * @returns true if signature is valid, false otherwise
115
- *
116
- * @example
117
- * // Server-side (Node.js) - generate signature:
118
- * const crypto = require('crypto');
119
- * const key = crypto.randomBytes(32).toString('base64');
120
- * const fileBuffer = fs.readFileSync('bundle.patch');
121
- * const signature = crypto.createHmac('sha256', Buffer.from(key, 'base64'))
122
- * .update(fileBuffer)
123
- * .digest('base64');
92
+ * When `config.publicKey` is set, an RSA/ECDSA SHA-256 signature is mandatory
93
+ * and must verify (fail closed). Without a public key, signing is not enforced.
124
94
  */
125
- export const verifySignature = async (
95
+ const assertSigned = async (
126
96
  filePath: string,
127
- signature?: string,
128
- publicKey?: string,
129
- ): Promise<boolean> => {
130
- if (!signature || !publicKey) return true; // Skip if not configured
131
- try {
132
- // Fast-path: use Native streaming HMAC-SHA256 (avoids Base64 conversion and JS thread blocking)
133
- if (typeof NativeBsPatch.verifySignature === 'function') {
134
- const isValid = await NativeBsPatch.verifySignature(filePath, signature, publicKey);
135
- if (!isValid && __DEV__) {
136
- console.warn('[otaCore] Signature mismatch');
137
- }
138
- return isValid;
139
- }
140
-
141
- // Fallback to JS CryptoJS if native module is running an older binary
142
- const fileData = await ReactNativeBlobUtil.fs.readFile(filePath, 'base64');
143
- const fileWords = CryptoJS.enc.Base64.parse(fileData);
144
- const keyWords = CryptoJS.enc.Base64.parse(publicKey);
145
- const signatureWords = CryptoJS.enc.Base64.parse(signature);
146
- const hmac = CryptoJS.HmacSHA256(fileWords, keyWords);
147
- const isValid = hmac.toString(CryptoJS.enc.Base64) === signatureWords.toString(CryptoJS.enc.Base64);
148
-
149
- if (!isValid && __DEV__) {
150
- console.warn('[otaCore] Signature mismatch');
151
- }
152
-
153
- return isValid;
154
- } catch (error) {
155
- if (__DEV__) {
156
- console.error('[otaCore] Signature verification failed:', error);
157
- }
158
- return false;
159
- }
160
- };
161
-
162
- /**
163
- * Decompress a .gz file in-place using native helpers.
164
- * Falls back to skipping decompression if no native helper is available,
165
- * but enforces that the patch file must NOT be gzip-compressed in that case.
166
- */
167
- const decompressGzip = async (filePath: string): Promise<string> => {
168
- if (!filePath.endsWith('.gz')) {
169
- return filePath;
170
- }
171
-
172
- const decompressedPath = filePath.replace(/\.gz$/, '');
173
- try {
174
- const success = await NativeBsPatch.decompressGzip(
175
- filePath,
176
- decompressedPath,
177
- );
178
- if (success) {
179
- return decompressedPath;
180
- }
181
- } catch {
182
- // Native decompress not available — fall through
97
+ signature: string | undefined,
98
+ config: OtaConfig,
99
+ what: string,
100
+ ): Promise<void> => {
101
+ if (!config.publicKey) return;
102
+ const valid =
103
+ !!signature &&
104
+ (await NativeBsPatch.verifySignature(filePath, signature, config.publicKey).catch(() => false));
105
+ if (!valid) {
106
+ throw new Error(`${what} signature missing or invalid`);
183
107
  }
184
-
185
- // No native gzip support: abort if file is actually compressed.
186
- // Do NOT silently rename a .gz file to .patch — bspatch will read gzip
187
- // magic bytes and fail with a confusing error.
188
- console.error(
189
- '[otaCore] .gz decompression is not supported on this platform. ' +
190
- 'Ensure the OTA server sends uncompressed patches (compressed: false).',
191
- );
192
- throw new Error('Gzip decompression is not supported on this platform.');
193
108
  };
194
109
 
195
110
  /**
@@ -208,10 +123,13 @@ const downloadWithRetry = async (
208
123
 
209
124
  for (let i = 0; i < retryLimit; i++) {
210
125
  let taskRef: { cancel: () => void } | null = null;
126
+ // Download to .part and rename only on a 2xx response, so a cut-off
127
+ // download or an error page never sits at destPath looking complete.
128
+ const partPath = `${destPath}.part`;
211
129
  try {
212
130
  const task = ReactNativeBlobUtil.config({
213
131
  fileCache: true,
214
- path: destPath,
132
+ path: partPath,
215
133
  followRedirect: true,
216
134
  timeout,
217
135
  })
@@ -226,11 +144,20 @@ const downloadWithRetry = async (
226
144
  onProgress(percent);
227
145
  });
228
146
  taskRef = { cancel: () => task.cancel() };
229
- await task;
147
+ const resp = await task;
148
+ const status = resp.respInfo.status;
149
+ if (status < 200 || status >= 300) {
150
+ throw new Error(`HTTP ${status} for ${url}`);
151
+ }
152
+ if (await ReactNativeBlobUtil.fs.exists(destPath)) {
153
+ await ReactNativeBlobUtil.fs.unlink(destPath);
154
+ }
155
+ await ReactNativeBlobUtil.fs.mv(partPath, destPath);
230
156
  return;
231
157
  } catch (e) {
232
158
  // Cancel any lingering request before retrying
233
159
  taskRef?.cancel();
160
+ await ReactNativeBlobUtil.fs.unlink(partPath).catch(() => {});
234
161
  if (i === retryLimit - 1) throw e;
235
162
  const delay = Math.pow(2, i) * 1000; // Exponential backoff: 1s, 2s, 4s...
236
163
  if (__DEV__) {
@@ -347,12 +274,16 @@ const downloadAndSyncAssets = async (
347
274
 
348
275
  const manifest = JSON.parse(manifestData);
349
276
  // Filter out base assets, we don't need to download them
350
- assets = (manifest.assets || []).filter((a: any) => !a.isBase);
277
+ // Hash/ext become file names: reject anything that isn't a plain sha256 + extension
278
+ assets = (manifest.assets || []).filter(
279
+ (a: any) =>
280
+ !a.isBase &&
281
+ /^[a-f0-9]{64}$/i.test(a.hash) &&
282
+ /^[a-z0-9]+$/i.test(a.ext),
283
+ );
351
284
  } catch (e) {
352
- if (__DEV__) {
353
- console.warn('[otaCore] Failed to fetch asset manifest:', e);
354
- }
355
- return;
285
+ // Fatal: activating the bundle without its manifest leaves images unresolved
286
+ throw new Error(`Failed to fetch asset manifest: ${(e as Error).message}`);
356
287
  }
357
288
 
358
289
  if (assets.length === 0) {
@@ -368,6 +299,7 @@ const downloadAndSyncAssets = async (
368
299
  let processed = 0;
369
300
  let downloaded = 0;
370
301
  let cached = 0;
302
+ let failed = 0;
371
303
 
372
304
  const updateProgress = () => {
373
305
  processed++;
@@ -413,12 +345,11 @@ const downloadAndSyncAssets = async (
413
345
  );
414
346
  }
415
347
  } else {
416
- await ReactNativeBlobUtil.config({
417
- fileCache: true,
418
- path: localPath,
419
- followRedirect: true,
420
- timeout: config.network?.timeoutMs ?? 30000,
421
- }).fetch('GET', assetUrl);
348
+ await downloadWithRetry(assetUrl, localPath, config, () => {});
349
+ if (!(await verifyHash(localPath, asset.hash))) {
350
+ await ReactNativeBlobUtil.fs.unlink(localPath).catch(() => {});
351
+ throw new Error('hash mismatch');
352
+ }
422
353
  downloaded++;
423
354
  log.emit(OtaEvent.DOWNLOAD_SUCCESS, {
424
355
  message: `Asset downloaded: ${filename}`,
@@ -431,7 +362,7 @@ const downloadAndSyncAssets = async (
431
362
  e,
432
363
  );
433
364
  }
434
- // Non-fatal: continue with other assets
365
+ failed++; // keep going, fail the sync at the end
435
366
  } finally {
436
367
  updateProgress();
437
368
  await downloadNext(); // Start next download
@@ -445,6 +376,10 @@ const downloadAndSyncAssets = async (
445
376
  .map(() => downloadNext());
446
377
  await Promise.all(workers);
447
378
 
379
+ if (failed > 0) {
380
+ throw new Error(`${failed} asset(s) failed to download`);
381
+ }
382
+
448
383
  onStatus(
449
384
  OtaStatus.SYNCING_ASSETS,
450
385
  `Assets synced (${downloaded} new, ${cached} cached)`,
@@ -479,7 +414,7 @@ export const getLocalAssetPath = (hash: string, ext: string): string =>
479
414
  * @returns Absolute path to the patch file
480
415
  */
481
416
  export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
482
- `${docDir}/ota/patch-${patch.id}.patch`;
417
+ `${docDir}/ota/patch-${patch.appVersion}-${patch.id}.patch`;
483
418
 
484
419
  // ─── Main Update Pipeline ─────────────────────────────────────────────
485
420
 
@@ -489,7 +424,6 @@ export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
489
424
  * Pipeline:
490
425
  * 1. Version compatibility check
491
426
  * 2. Download patch (with retry)
492
- * 3. Decompress (if .gz)
493
427
  * 4. Security signature check
494
428
  * 5. Resolve source bundle
495
429
  * 6. Verify source hash
@@ -501,7 +435,22 @@ export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
501
435
  *
502
436
  * On failure → fallback to full bundle download if URL available.
503
437
  */
504
- export const applyOtaPatchInternal = async (
438
+ let updateInFlight = false;
439
+
440
+ /** Runs the pipeline below; rejects if another update is already running. */
441
+ export const applyOtaPatchInternal: typeof runUpdatePipeline = async (...args) => {
442
+ if (updateInFlight) {
443
+ throw new Error('Another OTA update is already in progress');
444
+ }
445
+ updateInFlight = true;
446
+ try {
447
+ return await runUpdatePipeline(...args);
448
+ } finally {
449
+ updateInFlight = false;
450
+ }
451
+ };
452
+
453
+ const runUpdatePipeline = async (
505
454
  patch: OtaPatch,
506
455
  config: OtaConfig,
507
456
  onStatus: (
@@ -532,6 +481,23 @@ export const applyOtaPatchInternal = async (
532
481
  ReactNativeBlobUtil.fs.dirs.DocumentDir,
533
482
  );
534
483
 
484
+ // Cached per asset hash, so calling it again in the fallback is cheap
485
+ const syncAssets = async () => {
486
+ if (!patch.assetManifestUrl) return;
487
+ const manifestUrl = await getStorage(config).getUpdateFileUrl(
488
+ patch.assetManifestUrl,
489
+ config,
490
+ );
491
+ await downloadAndSyncAssets(
492
+ manifestUrl,
493
+ patch.toVersion,
494
+ config,
495
+ log,
496
+ onStatus,
497
+ onProgress,
498
+ );
499
+ };
500
+
535
501
  try {
536
502
  // ── Step 1: Version Compatibility ──
537
503
  if (patch.minAppVersion) {
@@ -607,49 +573,20 @@ export const applyOtaPatchInternal = async (
607
573
  }
608
574
  }
609
575
 
610
- // ── Step 3: Decompress if needed ──
611
- let actualPatchPath = localPatchPath;
612
- if (patch.compressed) {
613
- log.emit(OtaEvent.DECOMPRESS_START);
614
- onStatus(OtaStatus.APPLYING, 'Decompressing...');
615
- actualPatchPath = await decompressGzip(localPatchPath);
616
- log.emit(OtaEvent.DECOMPRESS_SUCCESS);
617
- }
618
-
619
576
  // ── Step 4: Signature Verification ──
620
- if (patch.signature && config.publicKey) {
577
+ // Verify before feeding the patch to native bspatch
578
+ if (config.publicKey) {
621
579
  onStatus(OtaStatus.APPLYING, 'Verifying signature...');
622
- const sigValid = await verifySignature(
623
- actualPatchPath,
624
- patch.signature,
625
- config.publicKey,
626
- );
627
- if (!sigValid) {
628
- log.emit(OtaEvent.SIGNATURE_INVALID, {
629
- version: patch.toVersion,
630
- });
631
- throw new Error(
632
- 'Patch signature verification failed. Possible MITM.',
633
- );
580
+ try {
581
+ await assertSigned(localPatchPath, patch.signature, config, 'Patch');
582
+ } catch (sigError) {
583
+ log.emit(OtaEvent.SIGNATURE_INVALID, { version: patch.toVersion });
584
+ throw sigError;
634
585
  }
635
586
  }
636
587
 
637
588
  // ── Step 4.2: Download & Sync Assets ──
638
- if (patch.assetManifestUrl) {
639
- const storage = getStorage(config);
640
- const manifestUrl = await storage.getUpdateFileUrl(
641
- patch.assetManifestUrl,
642
- config,
643
- );
644
- await downloadAndSyncAssets(
645
- manifestUrl,
646
- patch.toVersion,
647
- config,
648
- log,
649
- onStatus,
650
- onProgress,
651
- );
652
- }
589
+ await syncAssets();
653
590
 
654
591
  // ── Step 5: Resolve Source Bundle ──
655
592
  onStatus(OtaStatus.APPLYING, 'Preparing source...');
@@ -692,22 +629,13 @@ export const applyOtaPatchInternal = async (
692
629
  // True base: extract byte-for-byte from APK assets.
693
630
  // RNFS.copyFileAssets is known to produce inconsistent bytes in some
694
631
  // APK configurations (e.g., release signing + resource shrinking).
632
+ // Re-extract every time: a cached copy can be partial or left over
633
+ // from the previous store build. It's one local copy per update.
695
634
  actualOldPath = `${OTA_DIR}/base.hbc`;
696
- if (!(await ReactNativeBlobUtil.fs.exists(actualOldPath))) {
697
- if (__DEV__) {
698
- console.log(
699
- `[otaCore] Source: extracting bundle from APK assets → ${actualOldPath}`,
700
- );
701
- }
702
- await NativeBsPatch.extractBundleFromAssets(
703
- bundleFilename,
704
- actualOldPath,
705
- );
706
- } else if (__DEV__) {
707
- console.log(
708
- `[otaCore] Source: cached APK-extracted bundle → ${actualOldPath}`,
709
- );
710
- }
635
+ await NativeBsPatch.extractBundleFromAssets(
636
+ bundleFilename,
637
+ actualOldPath,
638
+ );
711
639
  }
712
640
  } else {
713
641
  actualOldPath = oldBundlePath;
@@ -717,14 +645,14 @@ export const applyOtaPatchInternal = async (
717
645
  try {
718
646
  const oldStat = await ReactNativeBlobUtil.fs.stat(actualOldPath);
719
647
  const patchStat = await ReactNativeBlobUtil.fs.stat(
720
- actualPatchPath,
648
+ localPatchPath,
721
649
  );
722
650
  if (__DEV__) {
723
651
  console.log(
724
652
  `[otaCore] DEBUG old bundle size=${oldStat.size} path=${actualOldPath}`,
725
653
  );
726
654
  console.log(
727
- `[otaCore] DEBUG patch size=${patchStat.size} path=${actualPatchPath}`,
655
+ `[otaCore] DEBUG patch size=${patchStat.size} path=${localPatchPath}`,
728
656
  );
729
657
  console.log(
730
658
  `[otaCore] DEBUG baseHash expected=${patch.baseHash}`,
@@ -797,7 +725,7 @@ export const applyOtaPatchInternal = async (
797
725
  const success = await NativeBsPatch.applyPatch(
798
726
  actualOldPath,
799
727
  tempBundle,
800
- actualPatchPath,
728
+ localPatchPath,
801
729
  );
802
730
  const applyMs = Date.now() - applyStart;
803
731
 
@@ -839,14 +767,18 @@ export const applyOtaPatchInternal = async (
839
767
  });
840
768
  }
841
769
 
770
+ await assertSigned(tempBundle, patch.bundleSignature, config, 'Bundle');
771
+
842
772
  // ── Step 9: Atomic Commit ──
843
773
  if (await ReactNativeBlobUtil.fs.exists(finalBundlePath)) {
844
774
  await ReactNativeBlobUtil.fs.unlink(finalBundlePath);
845
775
  }
846
776
  await ReactNativeBlobUtil.fs.mv(tempBundle, finalBundlePath);
847
777
 
778
+ await ReactNativeBlobUtil.fs.unlink(localPatchPath).catch(() => {});
779
+
848
780
  // ── Step 10: Update Metadata ──
849
- const newMetadata = registerNewVersion(metadata, patch.toVersion);
781
+ const newMetadata = registerNewVersion(metadata, patch.toVersion, config.appVersion);
850
782
  await saveMetadata(newMetadata);
851
783
 
852
784
  const totalMs = Date.now() - totalStart;
@@ -886,6 +818,8 @@ export const applyOtaPatchInternal = async (
886
818
  if (__DEV__) {
887
819
  console.error('[otaCore] Pipeline failed:', error);
888
820
  }
821
+ await ReactNativeBlobUtil.fs.unlink(localPatchPath).catch(() => {});
822
+ await ReactNativeBlobUtil.fs.unlink(tempBundle).catch(() => {});
889
823
 
890
824
  // ── Fallback: Full Bundle Download ──
891
825
  if (patch.fullBundleUrl) {
@@ -900,6 +834,10 @@ export const applyOtaPatchInternal = async (
900
834
  config,
901
835
  );
902
836
 
837
+ const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
838
+ if (!(await ReactNativeBlobUtil.fs.exists(versionDir))) {
839
+ await ReactNativeBlobUtil.fs.mkdir(versionDir);
840
+ }
903
841
  await downloadWithRetry(
904
842
  url,
905
843
  tempBundle,
@@ -951,6 +889,9 @@ export const applyOtaPatchInternal = async (
951
889
  }
952
890
  }
953
891
 
892
+ await assertSigned(tempBundle, patch.bundleSignature, config, 'Full bundle');
893
+ await syncAssets();
894
+
954
895
  // Atomic swap
955
896
  if (await ReactNativeBlobUtil.fs.exists(finalBundlePath)) {
956
897
  await ReactNativeBlobUtil.fs.unlink(finalBundlePath);
@@ -961,6 +902,7 @@ export const applyOtaPatchInternal = async (
961
902
  const newMetadata = registerNewVersion(
962
903
  metadata,
963
904
  patch.toVersion,
905
+ config.appVersion,
964
906
  );
965
907
  await saveMetadata(newMetadata);
966
908
 
@@ -24,8 +24,6 @@ export enum OtaEvent {
24
24
  RESET_FACTORY = 'reset_factory',
25
25
  VERSION_INCOMPATIBLE = 'version_incompatible',
26
26
  SIGNATURE_INVALID = 'signature_invalid',
27
- DECOMPRESS_START = 'decompress_start',
28
- DECOMPRESS_SUCCESS = 'decompress_success',
29
27
  }
30
28
 
31
29
  export type OtaLogEntry = {
@@ -4,6 +4,7 @@ import { OtaMetadata } from './types';
4
4
 
5
5
  const OTA_DIR = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota`;
6
6
  const METADATA_PATH = `${OTA_DIR}/metadata.json`;
7
+ const METADATA_TMP_PATH = `${METADATA_PATH}.tmp`;
7
8
 
8
9
  /** Promise-based lock to prevent concurrent metadata writes (double-tap guard). */
9
10
  let saveLock: Promise<void> = Promise.resolve();
@@ -27,12 +28,11 @@ export const loadMetadata = async (
27
28
  defaultVersion: number,
28
29
  ): Promise<OtaMetadata> => {
29
30
  try {
30
- if (await ReactNativeBlobUtil.fs.exists(METADATA_PATH)) {
31
- const content = await ReactNativeBlobUtil.fs.readFile(
32
- METADATA_PATH,
33
- 'utf8',
34
- );
35
- return JSON.parse(content);
31
+ // A crash between unlink and rename in saveMetadata leaves only the .tmp copy
32
+ 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'));
35
+ }
36
36
  }
37
37
  } catch (e) {
38
38
  console.warn('[otaMetadata] Failed to load metadata:', e);
@@ -60,11 +60,16 @@ export const saveMetadata = async (metadata: OtaMetadata): Promise<void> => {
60
60
  try {
61
61
  await prevLock;
62
62
  await ensureOtaDir();
63
+ // Write-then-rename so a kill mid-write never leaves truncated JSON
63
64
  await ReactNativeBlobUtil.fs.writeFile(
64
- METADATA_PATH,
65
+ METADATA_TMP_PATH,
65
66
  JSON.stringify(metadata, null, 2),
66
67
  'utf8',
67
68
  );
69
+ if (await ReactNativeBlobUtil.fs.exists(METADATA_PATH)) {
70
+ await ReactNativeBlobUtil.fs.unlink(METADATA_PATH);
71
+ }
72
+ await ReactNativeBlobUtil.fs.mv(METADATA_TMP_PATH, METADATA_PATH);
68
73
  } catch (e) {
69
74
  console.error('[otaMetadata] Failed to save metadata:', e);
70
75
  throw new Error(`Failed to save metadata: ${(e as Error).message}`);
@@ -87,6 +92,7 @@ export const MAX_VERSION_HISTORY = 3;
87
92
  export const registerNewVersion = (
88
93
  metadata: OtaMetadata,
89
94
  newVersion: number,
95
+ appVersion?: string,
90
96
  ): OtaMetadata => {
91
97
  const versions = [
92
98
  metadata.currentVersion,
@@ -99,6 +105,7 @@ export const registerNewVersion = (
99
105
 
100
106
  return {
101
107
  ...metadata,
108
+ appVersion: appVersion ?? metadata.appVersion,
102
109
  currentVersion: newVersion,
103
110
  previousVersions: uniquePrevious,
104
111
  rollbackCount: 0,
@@ -138,7 +145,7 @@ export const cleanupOldVersions = async (
138
145
  }
139
146
  }
140
147
  // Clean up leftover temporary bundle files (.tmp)
141
- if (item.endsWith('.tmp')) {
148
+ if (item.endsWith('.tmp') && item !== 'metadata.json.tmp') {
142
149
  await ReactNativeBlobUtil.fs.unlink(`${OTA_DIR}/${item}`).catch(() => {});
143
150
  }
144
151
  }
@@ -157,10 +164,13 @@ export const cleanupOldVersions = async (
157
164
  */
158
165
  export const markHealthy = async (
159
166
  defaultVersion: number,
167
+ appVersion?: string,
160
168
  ): Promise<OtaMetadata> => {
161
169
  const metadata = await loadMetadata(defaultVersion);
162
170
  const updated: OtaMetadata = {
163
171
  ...metadata,
172
+ // Stamp legacy metadata so the native resolver can detect a store update later
173
+ appVersion: metadata.appVersion ?? appVersion,
164
174
  lastLaunchSuccessful: true,
165
175
  rollbackCount: 0,
166
176
  };
@@ -45,7 +45,7 @@ export class GitHubRawStorageProvider implements OtaStorageProvider {
45
45
  );
46
46
 
47
47
  try {
48
- const res = await ReactNativeBlobUtil.fetch(
48
+ const res = await ReactNativeBlobUtil.config({ timeout: 15000 }).fetch(
49
49
  'GET',
50
50
  registryUrl,
51
51
  NO_CACHE_HEADERS,