react-native-bspatch 2.0.1 → 3.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 +126 -151
  2. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +0 -25
  3. package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +14 -6
  4. package/ios/OtaBundleResolver.swift +14 -6
  5. package/ios/RNBsPatch.mm +1 -28
  6. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  7. package/lib/commonjs/ota/OtaProvider.js +29 -16
  8. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  9. package/lib/commonjs/ota/otaAssetResolver.js +0 -20
  10. package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
  11. package/lib/commonjs/ota/otaCore.js +200 -330
  12. package/lib/commonjs/ota/otaCore.js.map +1 -1
  13. package/lib/commonjs/ota/otaMetadata.js +21 -21
  14. package/lib/commonjs/ota/otaMetadata.js.map +1 -1
  15. package/lib/commonjs/ota/storage/GitHubRawStorage.js +13 -15
  16. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
  17. package/lib/commonjs/ota/types.js.map +1 -1
  18. package/lib/module/NativeBsPatch.js.map +1 -1
  19. package/lib/module/ota/OtaProvider.js +30 -17
  20. package/lib/module/ota/OtaProvider.js.map +1 -1
  21. package/lib/module/ota/otaAssetResolver.js +1 -21
  22. package/lib/module/ota/otaAssetResolver.js.map +1 -1
  23. package/lib/module/ota/otaCore.js +197 -329
  24. package/lib/module/ota/otaCore.js.map +1 -1
  25. package/lib/module/ota/otaMetadata.js +19 -19
  26. package/lib/module/ota/otaMetadata.js.map +1 -1
  27. package/lib/module/ota/storage/GitHubRawStorage.js +13 -15
  28. package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
  29. package/lib/module/ota/types.js.map +1 -1
  30. package/lib/typescript/NativeBsPatch.d.ts +0 -1
  31. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  32. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  33. package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -1
  34. package/lib/typescript/ota/otaCore.d.ts +13 -3
  35. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  36. package/lib/typescript/ota/otaMetadata.d.ts +9 -7
  37. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
  38. package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
  39. package/lib/typescript/ota/types.d.ts +5 -19
  40. package/lib/typescript/ota/types.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/NativeBsPatch.ts +0 -5
  43. package/src/ota/OtaProvider.tsx +30 -23
  44. package/src/ota/otaAssetResolver.ts +1 -17
  45. package/src/ota/otaCore.ts +203 -457
  46. package/src/ota/otaMetadata.ts +30 -20
  47. package/src/ota/storage/GitHubRawStorage.ts +13 -21
  48. package/src/ota/types.ts +5 -20
  49. package/tools/build_ota.sh +206 -516
  50. package/tools/setup_project.js +17 -115
  51. package/tools/update_registry.js +31 -32
  52. package/tools/upload_ota.sh +61 -14
@@ -5,9 +5,9 @@
5
5
  * - Download patches with retry
6
6
  * - Verify integrity (SHA-256 hash)
7
7
  * - Verify security (RSA/ECDSA signature)
8
- * - Apply binary patch (bsdiff)
8
+ * - Apply binary patch (bsdiff) from the installed OTA bundle
9
+ * - Full bundle download (first OTA after a store install, or patch failure)
9
10
  * - Atomic bundle swap
10
- * - Full bundle fallback on failure
11
11
  * - Structured telemetry logging
12
12
  */
13
13
 
@@ -20,7 +20,7 @@ import {
20
20
  getBundlePath,
21
21
  loadMetadata,
22
22
  registerNewVersion,
23
- saveMetadata,
23
+ updateMetadata,
24
24
  } from './otaMetadata';
25
25
  import { GitHubRawStorageProvider } from './storage/GitHubRawStorage';
26
26
  import {
@@ -38,10 +38,26 @@ const OTA_DIR = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota`;
38
38
 
39
39
  const DEFAULT_STORAGE = new GitHubRawStorageProvider();
40
40
 
41
- /** Explicit `config.storage` (e.g. a future Cloudflare provider) or GitHub raw. */
41
+ /** Explicit `config.storage` or the default static-host provider (GitHub raw, R2, CDN...). */
42
42
  const getStorage = (config: OtaConfig): OtaStorageProvider =>
43
43
  config.storage ?? DEFAULT_STORAGE;
44
44
 
45
+ /**
46
+ * Returns a message for config the default provider can't work with, else null.
47
+ * No fallbacks: a missing env value must surface, not silently point somewhere else.
48
+ */
49
+ export const validateOtaConfig = (config: OtaConfig): string | null => {
50
+ if (config.storage) return null;
51
+ const { baseUrl, channel } = config.customServer ?? {};
52
+ if (!baseUrl || !/^https?:\/\//.test(baseUrl)) {
53
+ return `customServer.baseUrl must be an http(s) URL, got ${JSON.stringify(baseUrl)}`;
54
+ }
55
+ if (!channel) {
56
+ return `customServer.channel is required, got ${JSON.stringify(channel)}`;
57
+ }
58
+ return null;
59
+ };
60
+
45
61
  // ─── Helpers ──────────────────────────────────────────────────────────
46
62
 
47
63
  /**
@@ -181,22 +197,10 @@ const downloadWithRetry = async (
181
197
  * the app can still apply updates that were bundled at build time.
182
198
  *
183
199
  * @param config - OTA configuration containing storage provider and fallback patches
184
- * @returns Array of available patches, or bundled patches if remote fetch fails
200
+ * @returns Patches for this app version + channel; rejects if the registry can't be read
185
201
  */
186
- export const fetchOtaManifest = async (
187
- config: OtaConfig,
188
- ): Promise<OtaPatch[]> => {
189
- try {
190
- const storage = getStorage(config);
191
- return await storage.fetchManifest(config);
192
- } catch (error) {
193
- console.warn(
194
- '[otaCore] Manifest fetch error:',
195
- (error as Error).message,
196
- );
197
- return config.bundledPatches || [];
198
- }
199
- };
202
+ export const fetchOtaManifest = (config: OtaConfig): Promise<OtaPatch[]> =>
203
+ getStorage(config).fetchManifest(config);
200
204
 
201
205
  // ─── Hashed Asset Store ───────────────────────────────────────────────
202
206
 
@@ -273,11 +277,9 @@ const downloadAndSyncAssets = async (
273
277
  }
274
278
 
275
279
  const manifest = JSON.parse(manifestData);
276
- // Filter out base assets, we don't need to download them
277
280
  // Hash/ext become file names: reject anything that isn't a plain sha256 + extension
278
281
  assets = (manifest.assets || []).filter(
279
282
  (a: any) =>
280
- !a.isBase &&
281
283
  /^[a-f0-9]{64}$/i.test(a.hash) &&
282
284
  /^[a-z0-9]+$/i.test(a.ext),
283
285
  );
@@ -416,24 +418,37 @@ export const getLocalAssetPath = (hash: string, ext: string): string =>
416
418
  export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
417
419
  `${docDir}/ota/patch-${patch.appVersion}-${patch.id}.patch`;
418
420
 
421
+ /**
422
+ * Picks the registry entry for `localV → targetV`: a patch from the OTA bundle
423
+ * already on disk, else the full-bundle entry (store build, or outside the patch window).
424
+ */
425
+ export const selectUpdate = (
426
+ patchList: OtaPatch[],
427
+ localV: number,
428
+ targetV: number,
429
+ ): OtaPatch | undefined => {
430
+ const forTarget = patchList.filter(
431
+ p => p.toVersion === targetV && (!p.platform || p.platform === Platform.OS),
432
+ );
433
+ return (
434
+ forTarget.find(p => p.filename && p.fromVersion === localV) ??
435
+ forTarget.find(p => !p.filename)
436
+ );
437
+ };
438
+
419
439
  // ─── Main Update Pipeline ─────────────────────────────────────────────
420
440
 
421
441
  /**
422
- * Applies a single patch and reloads the bundle.
442
+ * Installs one OTA version.
423
443
  *
424
- * Pipeline:
425
- * 1. Version compatibility check
426
- * 2. Download patch (with retry)
427
- * 4. Security signature check
428
- * 5. Resolve source bundle
429
- * 6. Verify source hash
430
- * 7. Apply binary patch
431
- * 8. Verify result hash
432
- * 9. Atomic commit (mv)
433
- * 10. Update metadata
434
- * 11. Reload bundle
444
+ * Patch path (an OTA bundle for `patch.fromVersion` is on disk):
445
+ * download patch → signature → source hash → bspatch → result hash/signature
446
+ * Full path (store build, no patch, or patch path failed):
447
+ * download full bundle → hash → signature
448
+ * Both then: sync assets → atomic commit → metadata → mark healthy → reload.
435
449
  *
436
- * On failure → fallback to full bundle download if URL available.
450
+ * The store build's bundle is never used as a patch source: it can't be
451
+ * reproduced byte-for-byte at build time, so the first OTA is always a full download.
437
452
  */
438
453
  let updateInFlight = false;
439
454
 
@@ -472,263 +487,146 @@ const runUpdatePipeline = async (
472
487
  const log = createOtaLogger(config.onEvent);
473
488
  await ensureOtaDir();
474
489
  const totalStart = Date.now();
490
+ const fs = ReactNativeBlobUtil.fs;
475
491
 
476
492
  const finalBundlePath =
477
493
  getBundlePath(patch.toVersion, config.baseAppVersion) || '';
478
494
  const tempBundle = `${finalBundlePath}.tmp`;
479
- const localPatchPath = getLocalPatchPath(
480
- patch,
481
- ReactNativeBlobUtil.fs.dirs.DocumentDir,
482
- );
495
+ const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
496
+ const localPatchPath = getLocalPatchPath(patch, fs.dirs.DocumentDir);
497
+
498
+ // ── Version Compatibility ──
499
+ // Checked before anything else so an incompatible update can't reach the full-bundle path
500
+ if (patch.minAppVersion) {
501
+ let currentAppVer = config.appVersion;
502
+ if (!currentAppVer) {
503
+ currentAppVer = await NativeBsPatch.getAppVersion().catch(() => undefined);
504
+ }
505
+ if (!currentAppVer) {
506
+ throw new Error(
507
+ '[otaCore] Cannot verify minAppVersion: Native App Version could not be determined.',
508
+ );
509
+ }
510
+ if (!semverGte(currentAppVer, patch.minAppVersion)) {
511
+ log.emit(OtaEvent.VERSION_INCOMPATIBLE, {
512
+ message: `App ${currentAppVer} < required ${patch.minAppVersion}`,
513
+ version: patch.toVersion,
514
+ });
515
+ throw new Error(
516
+ `Native app ${currentAppVer} is too old. Update to ${patch.minAppVersion}+ first.`,
517
+ );
518
+ }
519
+ }
483
520
 
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
- };
521
+ if (!(await fs.exists(versionDir))) {
522
+ await fs.mkdir(versionDir);
523
+ }
500
524
 
501
- try {
502
- // ── Step 1: Version Compatibility ──
503
- if (patch.minAppVersion) {
504
- let currentAppVer = config.appVersion;
505
- if (!currentAppVer && typeof NativeBsPatch.getAppVersion === 'function') {
506
- try {
507
- currentAppVer = await NativeBsPatch.getAppVersion();
508
- } catch {
509
- currentAppVer = undefined;
510
- }
511
- }
512
- if (!currentAppVer) {
513
- throw new Error(
514
- `[otaCore] Cannot verify minAppVersion: Native App Version could not be determined.`,
515
- );
516
- }
517
- if (!semverGte(currentAppVer, patch.minAppVersion)) {
518
- log.emit(OtaEvent.VERSION_INCOMPATIBLE, {
519
- message: `App ${currentAppVer} < required ${patch.minAppVersion}`,
520
- version: patch.toVersion,
521
- });
522
- throw new Error(
523
- `Native app ${currentAppVer} is too old. Update to ${patch.minAppVersion}+ first.`,
524
- );
525
- }
525
+ const download = async (remoteName: string, dest: string) => {
526
+ log.emit(OtaEvent.DOWNLOAD_START, { version: patch.toVersion });
527
+ const dlStart = Date.now();
528
+ try {
529
+ const url = await getStorage(config).getUpdateFileUrl(remoteName, config);
530
+ await downloadWithRetry(url, dest, config, onProgress);
531
+ } catch (dlError) {
532
+ log.emit(OtaEvent.DOWNLOAD_FAIL, {
533
+ error: (dlError as Error).message,
534
+ version: patch.toVersion,
535
+ });
536
+ throw dlError;
526
537
  }
538
+ const downloadMs = Date.now() - dlStart;
539
+ const sizeBytes = Number((await fs.stat(dest)).size);
540
+ log.emit(OtaEvent.DOWNLOAD_SUCCESS, {
541
+ version: patch.toVersion,
542
+ durationMs: downloadMs,
543
+ sizeKB: sizeBytes / 1024,
544
+ });
545
+ return { downloadMs, sizeBytes };
546
+ };
527
547
 
528
- // ── Step 2: Download ──
529
- let downloadMs = 0;
530
- let patchSizeBytes = 0;
531
- let fromCache = false;
532
-
533
- if (await ReactNativeBlobUtil.fs.exists(localPatchPath)) {
534
- const stat = await ReactNativeBlobUtil.fs.stat(localPatchPath);
535
- patchSizeBytes = Number(stat.size);
536
- fromCache = true;
537
- onProgress(100);
538
- } else {
539
- log.emit(OtaEvent.DOWNLOAD_START, { version: patch.toVersion });
540
- onStatus(
541
- OtaStatus.DOWNLOADING,
542
- `Downloading V${patch.toVersion}...`,
543
- );
544
- const dlStart = Date.now();
545
- try {
546
- const storage = getStorage(config);
547
- const url = await storage.getUpdateFileUrl(
548
- patch.filename,
549
- config,
550
- );
548
+ const assertHash = async (path: string, expected: string | undefined, what: string) => {
549
+ if (!expected || (await verifyHash(path, expected))) return;
550
+ const actual = await fs.hash(path, 'sha256').catch(() => '');
551
+ log.emit(OtaEvent.HASH_MISMATCH, {
552
+ message: `${what} hash mismatch`,
553
+ version: patch.toVersion,
554
+ metadata: { expected, actual, path },
555
+ });
556
+ throw new Error(`${what} hash mismatch`);
557
+ };
551
558
 
552
- await downloadWithRetry(
553
- url,
554
- localPatchPath,
555
- config,
556
- onProgress,
557
- {},
558
- );
559
- downloadMs = Date.now() - dlStart;
560
- const stat = await ReactNativeBlobUtil.fs.stat(localPatchPath);
561
- patchSizeBytes = Number(stat.size);
562
- log.emit(OtaEvent.DOWNLOAD_SUCCESS, {
563
- version: patch.toVersion,
564
- durationMs: downloadMs,
565
- sizeKB: patchSizeBytes / 1024,
566
- });
567
- } catch (dlError) {
568
- log.emit(OtaEvent.DOWNLOAD_FAIL, {
569
- error: (dlError as Error).message,
570
- version: patch.toVersion,
571
- });
572
- throw dlError;
573
- }
559
+ /** tempBundle is verified: sync assets, swap it in, record the version, reload. */
560
+ const commit = async (metrics: Omit<OtaMetrics, 'totalMs' | 'fromCache'>) => {
561
+ if (patch.assetManifestUrl) {
562
+ const manifestUrl = await getStorage(config).getUpdateFileUrl(
563
+ patch.assetManifestUrl,
564
+ config,
565
+ );
566
+ await downloadAndSyncAssets(
567
+ manifestUrl,
568
+ patch.toVersion,
569
+ config,
570
+ log,
571
+ onStatus,
572
+ onProgress,
573
+ );
574
574
  }
575
575
 
576
- // ── Step 4: Signature Verification ──
577
- // Verify before feeding the patch to native bspatch
578
- if (config.publicKey) {
579
- onStatus(OtaStatus.APPLYING, 'Verifying signature...');
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;
585
- }
576
+ if (await fs.exists(finalBundlePath)) {
577
+ await fs.unlink(finalBundlePath);
586
578
  }
579
+ await fs.mv(tempBundle, finalBundlePath);
587
580
 
588
- // ── Step 4.2: Download & Sync Assets ──
589
- await syncAssets();
590
-
591
- // ── Step 5: Resolve Source Bundle ──
592
- onStatus(OtaStatus.APPLYING, 'Preparing source...');
593
- const metadata = await loadMetadata(config.baseAppVersion);
594
- const oldBundlePath = getBundlePath(
595
- metadata.currentVersion,
596
- config.baseAppVersion,
581
+ // Native version, not config.appVersion: the native resolver compares against it on boot
582
+ const nativeAppVersion = await NativeBsPatch.getAppVersion().catch(() => config.appVersion);
583
+ await updateMetadata(config.baseAppVersion, metadata =>
584
+ registerNewVersion(metadata, patch.toVersion, nativeAppVersion),
597
585
  );
598
586
 
599
- // ── Resolve the actual old bundle path ──────────────────────────────
600
- // getBundlePath returns null when currentVersion == baseAppVersion, meaning
601
- // "use the APK's baked-in bundle". However, if BASE_APP_VERSION was bumped
602
- // to match a version that was applied via OTA (not actually baked into the
603
- // installed APK), direct APK extraction would produce a DIFFERENT file than
604
- // what the build script recorded as the baseHash for the next patch.
605
- //
606
- // Strategy (in order of precedence):
607
- // 1. Use the explicit OTA bundle path from getBundlePath (version > base) ✓
608
- // 2. If null (version == base), check for a previously OTA-stored bundle
609
- // at ota/v{currentVersion}/ — its hash matches the server registry.
610
- // 3. Fall back to APK extraction only if no OTA bundle exists on disk.
611
- const bundleFilename =
612
- Platform.OS === 'android'
613
- ? 'index.android.bundle'
614
- : 'main.jsbundle';
615
-
616
- let actualOldPath = '';
617
- if (!oldBundlePath) {
618
- // currentVersion == baseAppVersion: prefer OTA-stored bundle for this
619
- // version over APK extraction (handles BASE_APP_VERSION bump scenarios).
620
- const otaStoredPath = `${OTA_DIR}/v${metadata.currentVersion}/${bundleFilename}`;
621
- if (await ReactNativeBlobUtil.fs.exists(otaStoredPath)) {
622
- if (__DEV__) {
623
- console.log(
624
- `[otaCore] Source: OTA-stored bundle for V${metadata.currentVersion} (BASE_APP_VERSION match)`,
625
- );
626
- }
627
- actualOldPath = otaStoredPath;
628
- } else {
629
- // True base: extract byte-for-byte from APK assets.
630
- // RNFS.copyFileAssets is known to produce inconsistent bytes in some
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.
634
- actualOldPath = `${OTA_DIR}/base.hbc`;
635
- await NativeBsPatch.extractBundleFromAssets(
636
- bundleFilename,
637
- actualOldPath,
638
- );
639
- }
640
- } else {
641
- actualOldPath = oldBundlePath;
642
- }
587
+ const totalMs = Date.now() - totalStart;
588
+ log.emit(OtaEvent.PATCH_SUCCESS, {
589
+ version: patch.toVersion,
590
+ durationMs: totalMs,
591
+ sizeKB: metrics.patchSizeKB,
592
+ });
593
+ onStatus(OtaStatus.SUCCESS, `Update V${patch.toVersion} Successful!`, {
594
+ ...metrics,
595
+ totalMs,
596
+ fromCache: false,
597
+ });
643
598
 
644
- // ── Debug: log file sizes for diagnostic ──
645
- try {
646
- const oldStat = await ReactNativeBlobUtil.fs.stat(actualOldPath);
647
- const patchStat = await ReactNativeBlobUtil.fs.stat(
648
- localPatchPath,
599
+ // registerNewVersion left lastLaunchSuccessful=true: the native resolver marks the
600
+ // first launch of the new version pending, and JS marks it healthy once it renders.
601
+ if (options.autoReload !== false) {
602
+ NativeBsPatch.reloadBundle();
603
+ } else if (__DEV__) {
604
+ console.log(
605
+ `[otaCore] V${patch.toVersion} prepared in background — applies on next launch.`,
649
606
  );
650
- if (__DEV__) {
651
- console.log(
652
- `[otaCore] DEBUG old bundle size=${oldStat.size} path=${actualOldPath}`,
653
- );
654
- console.log(
655
- `[otaCore] DEBUG patch size=${patchStat.size} path=${localPatchPath}`,
656
- );
657
- console.log(
658
- `[otaCore] DEBUG baseHash expected=${patch.baseHash}`,
659
- );
660
- }
661
- } catch {
662
- // Non-fatal: stat is for diagnostic only
663
607
  }
608
+ };
664
609
 
665
- // ── Step 6: Verify Source Integrity ──
666
- if (patch.baseHash) {
667
- log.emit(OtaEvent.HASH_VERIFY_START, {
668
- message: 'Verifying source bundle',
669
- });
670
- onStatus(OtaStatus.APPLYING, 'Verifying source integrity...');
671
- const isValid = await verifyHash(actualOldPath, patch.baseHash);
672
- if (!isValid) {
673
- // Re-hash only for the diagnostic log (verifyHash doesn't return the actual hash)
674
- let actualSourceHash = '';
675
- let srcSize = 'unknown';
676
- try {
677
- actualSourceHash = await ReactNativeBlobUtil.fs.hash(
678
- actualOldPath,
679
- 'sha256',
680
- );
681
- const srcStat = await ReactNativeBlobUtil.fs.stat(
682
- actualOldPath,
683
- );
684
- srcSize = `${srcStat.size} bytes`;
685
- } catch {
686
- // stat failure is non-fatal for the error message
687
- }
688
- if (__DEV__) {
689
- console.error(
690
- '[otaCore] SOURCE HASH MISMATCH\n' +
691
- ` path: ${actualOldPath}\n` +
692
- ` size: ${srcSize}\n` +
693
- ` expected: ${patch.baseHash}\n` +
694
- ` actual: ${actualSourceHash}\n` +
695
- ` patch.id: ${patch.id} (v${patch.fromVersion}→v${patch.toVersion})`,
696
- );
697
- }
698
- log.emit(OtaEvent.HASH_MISMATCH, {
699
- version: metadata.currentVersion,
700
- message: 'Source bundle hash mismatch',
701
- metadata: {
702
- expected: patch.baseHash,
703
- actual: actualSourceHash,
704
- path: actualOldPath,
705
- },
706
- });
707
- throw new Error(
708
- 'Source bundle hash mismatch. Fallback required.',
709
- );
710
- }
711
- log.emit(OtaEvent.HASH_VERIFY_SUCCESS, {
712
- message: 'Source bundle verified',
713
- });
714
- }
610
+ const applyPatchFile = async (sourcePath: string) => {
611
+ onStatus(OtaStatus.DOWNLOADING, `Downloading V${patch.toVersion}...`);
612
+ const { downloadMs, sizeBytes } = await download(patch.filename!, localPatchPath);
715
613
 
716
- // ── Step 7: Apply Patch ──
717
- const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
718
- if (!(await ReactNativeBlobUtil.fs.exists(versionDir))) {
719
- await ReactNativeBlobUtil.fs.mkdir(versionDir);
614
+ // Verify before feeding the patch to native bspatch
615
+ onStatus(OtaStatus.APPLYING, 'Verifying...');
616
+ try {
617
+ await assertSigned(localPatchPath, patch.signature, config, 'Patch');
618
+ } catch (sigError) {
619
+ log.emit(OtaEvent.SIGNATURE_INVALID, { version: patch.toVersion });
620
+ throw sigError;
720
621
  }
622
+ await assertHash(sourcePath, patch.baseHash, 'Source bundle');
721
623
 
722
624
  log.emit(OtaEvent.PATCH_START, { version: patch.toVersion });
723
625
  onStatus(OtaStatus.APPLYING, 'Applying binary patch...');
724
626
  const applyStart = Date.now();
725
- const success = await NativeBsPatch.applyPatch(
726
- actualOldPath,
727
- tempBundle,
728
- localPatchPath,
729
- );
627
+ const success = await NativeBsPatch.applyPatch(sourcePath, tempBundle, localPatchPath);
730
628
  const applyMs = Date.now() - applyStart;
731
-
629
+ await fs.unlink(localPatchPath).catch(() => {});
732
630
  if (!success) {
733
631
  log.emit(OtaEvent.PATCH_FAIL, {
734
632
  message: 'BsPatch returned false',
@@ -737,209 +635,57 @@ const runUpdatePipeline = async (
737
635
  throw new Error('BsPatch application failed');
738
636
  }
739
637
 
740
- // ── Step 8: Verify Result Integrity ──
741
- if (patch.bundleHash) {
742
- log.emit(OtaEvent.HASH_VERIFY_START, {
743
- message: 'Verifying result bundle',
744
- });
745
- onStatus(OtaStatus.APPLYING, 'Verifying integrity...');
746
- const isValid = await verifyHash(tempBundle, patch.bundleHash);
747
-
748
- if (!isValid) {
749
- console.error(
750
- '[otaCore] Result bundle hash mismatch | expected:',
751
- patch.bundleHash,
752
- 'path:',
753
- tempBundle,
754
- );
755
- log.emit(OtaEvent.HASH_MISMATCH, {
756
- message: 'Result bundle hash mismatch',
757
- version: patch.toVersion,
758
- metadata: {
759
- expected: patch.bundleHash,
760
- path: tempBundle,
761
- },
762
- });
763
- throw new Error('Result bundle hash mismatch');
764
- }
765
- log.emit(OtaEvent.HASH_VERIFY_SUCCESS, {
766
- message: 'Result bundle verified',
767
- });
768
- }
769
-
638
+ await assertHash(tempBundle, patch.bundleHash, 'Result bundle');
770
639
  await assertSigned(tempBundle, patch.bundleSignature, config, 'Bundle');
640
+ await commit({ patchSizeKB: sizeBytes / 1024, downloadMs, applyMs });
641
+ };
771
642
 
772
- // ── Step 9: Atomic Commit ──
773
- if (await ReactNativeBlobUtil.fs.exists(finalBundlePath)) {
774
- await ReactNativeBlobUtil.fs.unlink(finalBundlePath);
643
+ const installFullBundle = async () => {
644
+ if (!patch.fullBundleUrl) {
645
+ throw new Error(`No full bundle for V${patch.toVersion}`);
775
646
  }
776
- await ReactNativeBlobUtil.fs.mv(tempBundle, finalBundlePath);
777
-
778
- await ReactNativeBlobUtil.fs.unlink(localPatchPath).catch(() => {});
779
-
780
- // ── Step 10: Update Metadata ──
781
- const newMetadata = registerNewVersion(metadata, patch.toVersion, config.appVersion);
782
- await saveMetadata(newMetadata);
783
-
784
- const totalMs = Date.now() - totalStart;
785
- log.emit(OtaEvent.PATCH_SUCCESS, {
786
- version: patch.toVersion,
787
- durationMs: totalMs,
788
- sizeKB: patchSizeBytes / 1024,
789
- });
647
+ log.emit(OtaEvent.FALLBACK_FULL_BUNDLE, { version: patch.toVersion });
648
+ onStatus(OtaStatus.DOWNLOADING, `Downloading V${patch.toVersion} (full bundle)...`);
649
+ const { downloadMs, sizeBytes } = await download(patch.fullBundleUrl, tempBundle);
650
+ await assertHash(tempBundle, patch.bundleHash, 'Full bundle');
651
+ await assertSigned(tempBundle, patch.bundleSignature, config, 'Full bundle');
652
+ await commit({ patchSizeKB: sizeBytes / 1024, downloadMs, applyMs: 0 });
653
+ };
790
654
 
791
- onStatus(OtaStatus.SUCCESS, `Update V${patch.toVersion} Successful!`, {
792
- patchSizeKB: patchSizeBytes / 1024,
793
- downloadMs,
794
- applyMs,
795
- totalMs,
796
- fromCache,
797
- });
655
+ const cleanup = async () => {
656
+ await fs.unlink(localPatchPath).catch(() => {});
657
+ await fs.unlink(tempBundle).catch(() => {});
658
+ };
798
659
 
799
- // ── Step 11: Reload ──
800
- // Mark healthy BEFORE reload — reloadBundle() calls Runtime.exit(0)
801
- NativeBsPatch.markAsHealthy();
660
+ // Patch only from an OTA bundle we installed ourselves (byte-identical to the build output)
661
+ const metadata = await loadMetadata(config.baseAppVersion);
662
+ const sourcePath = getBundlePath(metadata.currentVersion, config.baseAppVersion);
663
+ const canPatch =
664
+ !!patch.filename &&
665
+ patch.fromVersion === metadata.currentVersion &&
666
+ !!sourcePath &&
667
+ (await fs.exists(sourcePath));
802
668
 
803
- if (options.autoReload !== false) {
669
+ if (canPatch) {
670
+ try {
671
+ await applyPatchFile(sourcePath!);
672
+ return;
673
+ } catch (error) {
674
+ await cleanup();
804
675
  if (__DEV__) {
805
- console.log(
806
- `[otaCore] V${patch.toVersion} marked healthy — reloading.`,
807
- );
676
+ console.warn('[otaCore] Patch failed, falling back to full bundle:', error);
808
677
  }
809
- NativeBsPatch.reloadBundle();
810
- } else {
811
- if (__DEV__) {
812
- console.log(
813
- `[otaCore] V${patch.toVersion} prepared silently in background — will apply on next launch.`,
814
- );
815
- }
816
- }
817
- } catch (error) {
818
- if (__DEV__) {
819
- console.error('[otaCore] Pipeline failed:', error);
820
- }
821
- await ReactNativeBlobUtil.fs.unlink(localPatchPath).catch(() => {});
822
- await ReactNativeBlobUtil.fs.unlink(tempBundle).catch(() => {});
823
-
824
- // ── Fallback: Full Bundle Download ──
825
- if (patch.fullBundleUrl) {
826
- try {
827
- onStatus(
828
- OtaStatus.DOWNLOADING,
829
- 'Fallback: Downloading full bundle...',
830
- );
831
- const storage = getStorage(config);
832
- const url = await storage.getUpdateFileUrl(
833
- patch.fullBundleUrl || patch.filename,
834
- config,
835
- );
836
-
837
- const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
838
- if (!(await ReactNativeBlobUtil.fs.exists(versionDir))) {
839
- await ReactNativeBlobUtil.fs.mkdir(versionDir);
840
- }
841
- await downloadWithRetry(
842
- url,
843
- tempBundle,
844
- config,
845
- onProgress,
846
- {},
847
- );
848
-
849
- // Verify full bundle hash
850
- if (patch.bundleHash) {
851
- const isValid = await verifyHash(
852
- tempBundle,
853
- patch.bundleHash,
854
- );
855
- if (!isValid) {
856
- let actualHash = '';
857
- let size: number | undefined;
858
- try {
859
- actualHash = await ReactNativeBlobUtil.fs.hash(
860
- tempBundle,
861
- 'sha256',
862
- );
863
- const stat = await ReactNativeBlobUtil.fs.stat(
864
- tempBundle,
865
- );
866
- size = stat.size;
867
- } catch {
868
- // stat failure is non-fatal
869
- }
870
- if (__DEV__) {
871
- console.error(
872
- '[otaCore] Full bundle hash mismatch | expected:',
873
- patch.bundleHash,
874
- 'actual:',
875
- actualHash,
876
- );
877
- }
878
- log.emit(OtaEvent.HASH_MISMATCH, {
879
- message: 'Full bundle hash mismatch',
880
- metadata: {
881
- expected: patch.bundleHash,
882
- actual: actualHash,
883
- size,
884
- },
885
- });
886
- throw new Error(
887
- 'Full bundle hash mismatch after fallback',
888
- );
889
- }
890
- }
891
-
892
- await assertSigned(tempBundle, patch.bundleSignature, config, 'Full bundle');
893
- await syncAssets();
894
-
895
- // Atomic swap
896
- if (await ReactNativeBlobUtil.fs.exists(finalBundlePath)) {
897
- await ReactNativeBlobUtil.fs.unlink(finalBundlePath);
898
- }
899
- await ReactNativeBlobUtil.fs.mv(tempBundle, finalBundlePath);
900
-
901
- const metadata = await loadMetadata(config.baseAppVersion);
902
- const newMetadata = registerNewVersion(
903
- metadata,
904
- patch.toVersion,
905
- config.appVersion,
906
- );
907
- await saveMetadata(newMetadata);
908
-
909
- // ── CRITICAL: Mark healthy BEFORE reload so native OtaBundleResolver
910
- // does NOT see lastLaunchSuccessful=false on the very next boot and
911
- // immediately rollback the version we just successfully downloaded.
912
- // (reloadBundle() calls Runtime.exit(0) — JS won't run again before restart)
913
- NativeBsPatch.markAsHealthy();
914
- if (__DEV__) {
915
- console.log(
916
- `[otaCore] Fallback V${patch.toVersion} marked healthy — reloading.`,
917
- );
918
- }
919
-
920
- if (options.autoReload !== false) {
921
- onStatus(
922
- OtaStatus.SUCCESS,
923
- `Fallback successful! Reloading...`,
924
- );
925
- NativeBsPatch.reloadBundle();
926
- } else {
927
- onStatus(
928
- OtaStatus.SUCCESS,
929
- `Fallback bundle prepared silently in background.`,
930
- );
931
- }
932
- return;
933
- } catch (fallbackError) {
934
- if (__DEV__) {
935
- console.error(
936
- '[otaCore] Full bundle fallback also failed:',
937
- fallbackError,
938
- );
939
- }
678
+ if (!patch.fullBundleUrl) {
679
+ onStatus(OtaStatus.ERROR, `Error: ${(error as Error).message}`);
680
+ throw error;
940
681
  }
941
682
  }
683
+ }
942
684
 
685
+ try {
686
+ await installFullBundle();
687
+ } catch (error) {
688
+ await cleanup();
943
689
  onStatus(OtaStatus.ERROR, `Error: ${(error as Error).message}`);
944
690
  throw error;
945
691
  }