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
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.verifyHash = exports.semverGte = exports.getLocalPatchPath = exports.getLocalAssetPath = exports.fetchOtaManifest = exports.applyOtaPatchInternal = void 0;
6
+ exports.verifyHash = exports.validateOtaConfig = exports.semverGte = exports.selectUpdate = exports.getLocalPatchPath = exports.getLocalAssetPath = exports.fetchOtaManifest = exports.applyOtaPatchInternal = void 0;
7
7
  var _reactNative = require("react-native");
8
8
  var _reactNativeBlobUtil = _interopRequireDefault(require("react-native-blob-util"));
9
9
  var _NativeBsPatch = _interopRequireDefault(require("../NativeBsPatch"));
@@ -19,9 +19,9 @@ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e
19
19
  * - Download patches with retry
20
20
  * - Verify integrity (SHA-256 hash)
21
21
  * - Verify security (RSA/ECDSA signature)
22
- * - Apply binary patch (bsdiff)
22
+ * - Apply binary patch (bsdiff) from the installed OTA bundle
23
+ * - Full bundle download (first OTA after a store install, or patch failure)
23
24
  * - Atomic bundle swap
24
- * - Full bundle fallback on failure
25
25
  * - Structured telemetry logging
26
26
  */
27
27
 
@@ -31,9 +31,28 @@ const OTA_DIR = `${_reactNativeBlobUtil.default.fs.dirs.DocumentDir}/ota`;
31
31
 
32
32
  const DEFAULT_STORAGE = new _GitHubRawStorage.GitHubRawStorageProvider();
33
33
 
34
- /** Explicit `config.storage` (e.g. a future Cloudflare provider) or GitHub raw. */
34
+ /** Explicit `config.storage` or the default static-host provider (GitHub raw, R2, CDN...). */
35
35
  const getStorage = config => config.storage ?? DEFAULT_STORAGE;
36
36
 
37
+ /**
38
+ * Returns a message for config the default provider can't work with, else null.
39
+ * No fallbacks: a missing env value must surface, not silently point somewhere else.
40
+ */
41
+ const validateOtaConfig = config => {
42
+ if (config.storage) return null;
43
+ const {
44
+ baseUrl,
45
+ channel
46
+ } = config.customServer ?? {};
47
+ if (!baseUrl || !/^https?:\/\//.test(baseUrl)) {
48
+ return `customServer.baseUrl must be an http(s) URL, got ${JSON.stringify(baseUrl)}`;
49
+ }
50
+ if (!channel) {
51
+ return `customServer.channel is required, got ${JSON.stringify(channel)}`;
52
+ }
53
+ return null;
54
+ };
55
+
37
56
  // ─── Helpers ──────────────────────────────────────────────────────────
38
57
 
39
58
  /**
@@ -48,6 +67,7 @@ const getStorage = config => config.storage ?? DEFAULT_STORAGE;
48
67
  * semverGte("1.2.4", "1.2.3") // true
49
68
  * semverGte("1.2.2", "1.2.3") // false
50
69
  */
70
+ exports.validateOtaConfig = validateOtaConfig;
51
71
  const semverGte = (a, b) => {
52
72
  const pa = a.split('.').map(Number);
53
73
  const pb = b.split('.').map(Number);
@@ -152,17 +172,9 @@ const downloadWithRetry = async (url, destPath, config, onProgress, headers = {}
152
172
  * the app can still apply updates that were bundled at build time.
153
173
  *
154
174
  * @param config - OTA configuration containing storage provider and fallback patches
155
- * @returns Array of available patches, or bundled patches if remote fetch fails
175
+ * @returns Patches for this app version + channel; rejects if the registry can't be read
156
176
  */
157
- const fetchOtaManifest = async config => {
158
- try {
159
- const storage = getStorage(config);
160
- return await storage.fetchManifest(config);
161
- } catch (error) {
162
- console.warn('[otaCore] Manifest fetch error:', error.message);
163
- return config.bundledPatches || [];
164
- }
165
- };
177
+ const fetchOtaManifest = config => getStorage(config).fetchManifest(config);
166
178
 
167
179
  // ─── Hashed Asset Store ───────────────────────────────────────────────
168
180
  exports.fetchOtaManifest = fetchOtaManifest;
@@ -213,9 +225,8 @@ const downloadAndSyncAssets = async (assetManifestUrl, version, config, log, onS
213
225
  console.log(`[otaCore] Saved asset manifest to: ${manifestPath}`);
214
226
  }
215
227
  const manifest = JSON.parse(manifestData);
216
- // Filter out base assets, we don't need to download them
217
228
  // Hash/ext become file names: reject anything that isn't a plain sha256 + extension
218
- assets = (manifest.assets || []).filter(a => !a.isBase && /^[a-f0-9]{64}$/i.test(a.hash) && /^[a-z0-9]+$/i.test(a.ext));
229
+ assets = (manifest.assets || []).filter(a => /^[a-f0-9]{64}$/i.test(a.hash) && /^[a-z0-9]+$/i.test(a.ext));
219
230
  } catch (e) {
220
231
  // Fatal: activating the bundle without its manifest leaves images unresolved
221
232
  throw new Error(`Failed to fetch asset manifest: ${e.message}`);
@@ -324,26 +335,31 @@ const getLocalAssetPath = (hash, ext) => `${ASSET_STORE_DIR}/${hash}.${ext}`;
324
335
  exports.getLocalAssetPath = getLocalAssetPath;
325
336
  const getLocalPatchPath = (patch, docDir) => `${docDir}/ota/patch-${patch.appVersion}-${patch.id}.patch`;
326
337
 
338
+ /**
339
+ * Picks the registry entry for `localV → targetV`: a patch from the OTA bundle
340
+ * already on disk, else the full-bundle entry (store build, or outside the patch window).
341
+ */
342
+ exports.getLocalPatchPath = getLocalPatchPath;
343
+ const selectUpdate = (patchList, localV, targetV) => {
344
+ const forTarget = patchList.filter(p => p.toVersion === targetV && (!p.platform || p.platform === _reactNative.Platform.OS));
345
+ return forTarget.find(p => p.filename && p.fromVersion === localV) ?? forTarget.find(p => !p.filename);
346
+ };
347
+
327
348
  // ─── Main Update Pipeline ─────────────────────────────────────────────
328
349
 
329
350
  /**
330
- * Applies a single patch and reloads the bundle.
351
+ * Installs one OTA version.
331
352
  *
332
- * Pipeline:
333
- * 1. Version compatibility check
334
- * 2. Download patch (with retry)
335
- * 4. Security signature check
336
- * 5. Resolve source bundle
337
- * 6. Verify source hash
338
- * 7. Apply binary patch
339
- * 8. Verify result hash
340
- * 9. Atomic commit (mv)
341
- * 10. Update metadata
342
- * 11. Reload bundle
353
+ * Patch path (an OTA bundle for `patch.fromVersion` is on disk):
354
+ * download patch → signature → source hash → bspatch → result hash/signature
355
+ * Full path (store build, no patch, or patch path failed):
356
+ * download full bundle → hash → signature
357
+ * Both then: sync assets → atomic commit → metadata → mark healthy → reload.
343
358
  *
344
- * On failure → fallback to full bundle download if URL available.
359
+ * The store build's bundle is never used as a patch source: it can't be
360
+ * reproduced byte-for-byte at build time, so the first OTA is always a full download.
345
361
  */
346
- exports.getLocalPatchPath = getLocalPatchPath;
362
+ exports.selectUpdate = selectUpdate;
347
363
  let updateInFlight = false;
348
364
 
349
365
  /** Runs the pipeline below; rejects if another update is already running. */
@@ -369,195 +385,135 @@ const runUpdatePipeline = async (patch, config, onStatus, onProgress, options =
369
385
  const log = (0, _otaLogger.createOtaLogger)(config.onEvent);
370
386
  await (0, _otaMetadata.ensureOtaDir)();
371
387
  const totalStart = Date.now();
388
+ const fs = _reactNativeBlobUtil.default.fs;
372
389
  const finalBundlePath = (0, _otaMetadata.getBundlePath)(patch.toVersion, config.baseAppVersion) || '';
373
390
  const tempBundle = `${finalBundlePath}.tmp`;
374
- const localPatchPath = getLocalPatchPath(patch, _reactNativeBlobUtil.default.fs.dirs.DocumentDir);
375
-
376
- // Cached per asset hash, so calling it again in the fallback is cheap
377
- const syncAssets = async () => {
378
- if (!patch.assetManifestUrl) return;
379
- const manifestUrl = await getStorage(config).getUpdateFileUrl(patch.assetManifestUrl, config);
380
- await downloadAndSyncAssets(manifestUrl, patch.toVersion, config, log, onStatus, onProgress);
381
- };
382
- try {
383
- // ── Step 1: Version Compatibility ──
384
- if (patch.minAppVersion) {
385
- let currentAppVer = config.appVersion;
386
- if (!currentAppVer && typeof _NativeBsPatch.default.getAppVersion === 'function') {
387
- try {
388
- currentAppVer = await _NativeBsPatch.default.getAppVersion();
389
- } catch {
390
- currentAppVer = undefined;
391
- }
392
- }
393
- if (!currentAppVer) {
394
- throw new Error(`[otaCore] Cannot verify minAppVersion: Native App Version could not be determined.`);
395
- }
396
- if (!semverGte(currentAppVer, patch.minAppVersion)) {
397
- log.emit(_otaLogger.OtaEvent.VERSION_INCOMPATIBLE, {
398
- message: `App ${currentAppVer} < required ${patch.minAppVersion}`,
399
- version: patch.toVersion
400
- });
401
- throw new Error(`Native app ${currentAppVer} is too old. Update to ${patch.minAppVersion}+ first.`);
402
- }
391
+ const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
392
+ const localPatchPath = getLocalPatchPath(patch, fs.dirs.DocumentDir);
393
+
394
+ // ── Version Compatibility ──
395
+ // Checked before anything else so an incompatible update can't reach the full-bundle path
396
+ if (patch.minAppVersion) {
397
+ let currentAppVer = config.appVersion;
398
+ if (!currentAppVer) {
399
+ currentAppVer = await _NativeBsPatch.default.getAppVersion().catch(() => undefined);
403
400
  }
404
-
405
- // ── Step 2: Download ──
406
- let downloadMs = 0;
407
- let patchSizeBytes = 0;
408
- let fromCache = false;
409
- if (await _reactNativeBlobUtil.default.fs.exists(localPatchPath)) {
410
- const stat = await _reactNativeBlobUtil.default.fs.stat(localPatchPath);
411
- patchSizeBytes = Number(stat.size);
412
- fromCache = true;
413
- onProgress(100);
414
- } else {
415
- log.emit(_otaLogger.OtaEvent.DOWNLOAD_START, {
401
+ if (!currentAppVer) {
402
+ throw new Error('[otaCore] Cannot verify minAppVersion: Native App Version could not be determined.');
403
+ }
404
+ if (!semverGte(currentAppVer, patch.minAppVersion)) {
405
+ log.emit(_otaLogger.OtaEvent.VERSION_INCOMPATIBLE, {
406
+ message: `App ${currentAppVer} < required ${patch.minAppVersion}`,
416
407
  version: patch.toVersion
417
408
  });
418
- onStatus(_types.OtaStatus.DOWNLOADING, `Downloading V${patch.toVersion}...`);
419
- const dlStart = Date.now();
420
- try {
421
- const storage = getStorage(config);
422
- const url = await storage.getUpdateFileUrl(patch.filename, config);
423
- await downloadWithRetry(url, localPatchPath, config, onProgress, {});
424
- downloadMs = Date.now() - dlStart;
425
- const stat = await _reactNativeBlobUtil.default.fs.stat(localPatchPath);
426
- patchSizeBytes = Number(stat.size);
427
- log.emit(_otaLogger.OtaEvent.DOWNLOAD_SUCCESS, {
428
- version: patch.toVersion,
429
- durationMs: downloadMs,
430
- sizeKB: patchSizeBytes / 1024
431
- });
432
- } catch (dlError) {
433
- log.emit(_otaLogger.OtaEvent.DOWNLOAD_FAIL, {
434
- error: dlError.message,
435
- version: patch.toVersion
436
- });
437
- throw dlError;
438
- }
409
+ throw new Error(`Native app ${currentAppVer} is too old. Update to ${patch.minAppVersion}+ first.`);
439
410
  }
440
-
441
- // ── Step 4: Signature Verification ──
442
- // Verify before feeding the patch to native bspatch
443
- if (config.publicKey) {
444
- onStatus(_types.OtaStatus.APPLYING, 'Verifying signature...');
445
- try {
446
- await assertSigned(localPatchPath, patch.signature, config, 'Patch');
447
- } catch (sigError) {
448
- log.emit(_otaLogger.OtaEvent.SIGNATURE_INVALID, {
449
- version: patch.toVersion
450
- });
451
- throw sigError;
452
- }
411
+ }
412
+ if (!(await fs.exists(versionDir))) {
413
+ await fs.mkdir(versionDir);
414
+ }
415
+ const download = async (remoteName, dest) => {
416
+ log.emit(_otaLogger.OtaEvent.DOWNLOAD_START, {
417
+ version: patch.toVersion
418
+ });
419
+ const dlStart = Date.now();
420
+ try {
421
+ const url = await getStorage(config).getUpdateFileUrl(remoteName, config);
422
+ await downloadWithRetry(url, dest, config, onProgress);
423
+ } catch (dlError) {
424
+ log.emit(_otaLogger.OtaEvent.DOWNLOAD_FAIL, {
425
+ error: dlError.message,
426
+ version: patch.toVersion
427
+ });
428
+ throw dlError;
453
429
  }
454
-
455
- // ── Step 4.2: Download & Sync Assets ──
456
- await syncAssets();
457
-
458
- // ── Step 5: Resolve Source Bundle ──
459
- onStatus(_types.OtaStatus.APPLYING, 'Preparing source...');
460
- const metadata = await (0, _otaMetadata.loadMetadata)(config.baseAppVersion);
461
- const oldBundlePath = (0, _otaMetadata.getBundlePath)(metadata.currentVersion, config.baseAppVersion);
462
-
463
- // ── Resolve the actual old bundle path ──────────────────────────────
464
- // getBundlePath returns null when currentVersion == baseAppVersion, meaning
465
- // "use the APK's baked-in bundle". However, if BASE_APP_VERSION was bumped
466
- // to match a version that was applied via OTA (not actually baked into the
467
- // installed APK), direct APK extraction would produce a DIFFERENT file than
468
- // what the build script recorded as the baseHash for the next patch.
469
- //
470
- // Strategy (in order of precedence):
471
- // 1. Use the explicit OTA bundle path from getBundlePath (version > base) ✓
472
- // 2. If null (version == base), check for a previously OTA-stored bundle
473
- // at ota/v{currentVersion}/ — its hash matches the server registry.
474
- // 3. Fall back to APK extraction only if no OTA bundle exists on disk.
475
- const bundleFilename = _reactNative.Platform.OS === 'android' ? 'index.android.bundle' : 'main.jsbundle';
476
- let actualOldPath = '';
477
- if (!oldBundlePath) {
478
- // currentVersion == baseAppVersion: prefer OTA-stored bundle for this
479
- // version over APK extraction (handles BASE_APP_VERSION bump scenarios).
480
- const otaStoredPath = `${OTA_DIR}/v${metadata.currentVersion}/${bundleFilename}`;
481
- if (await _reactNativeBlobUtil.default.fs.exists(otaStoredPath)) {
482
- if (__DEV__) {
483
- console.log(`[otaCore] Source: OTA-stored bundle for V${metadata.currentVersion} (BASE_APP_VERSION match)`);
484
- }
485
- actualOldPath = otaStoredPath;
486
- } else {
487
- // True base: extract byte-for-byte from APK assets.
488
- // RNFS.copyFileAssets is known to produce inconsistent bytes in some
489
- // APK configurations (e.g., release signing + resource shrinking).
490
- // Re-extract every time: a cached copy can be partial or left over
491
- // from the previous store build. It's one local copy per update.
492
- actualOldPath = `${OTA_DIR}/base.hbc`;
493
- await _NativeBsPatch.default.extractBundleFromAssets(bundleFilename, actualOldPath);
430
+ const downloadMs = Date.now() - dlStart;
431
+ const sizeBytes = Number((await fs.stat(dest)).size);
432
+ log.emit(_otaLogger.OtaEvent.DOWNLOAD_SUCCESS, {
433
+ version: patch.toVersion,
434
+ durationMs: downloadMs,
435
+ sizeKB: sizeBytes / 1024
436
+ });
437
+ return {
438
+ downloadMs,
439
+ sizeBytes
440
+ };
441
+ };
442
+ const assertHash = async (path, expected, what) => {
443
+ if (!expected || (await verifyHash(path, expected))) return;
444
+ const actual = await fs.hash(path, 'sha256').catch(() => '');
445
+ log.emit(_otaLogger.OtaEvent.HASH_MISMATCH, {
446
+ message: `${what} hash mismatch`,
447
+ version: patch.toVersion,
448
+ metadata: {
449
+ expected,
450
+ actual,
451
+ path
494
452
  }
495
- } else {
496
- actualOldPath = oldBundlePath;
497
- }
453
+ });
454
+ throw new Error(`${what} hash mismatch`);
455
+ };
498
456
 
499
- // ── Debug: log file sizes for diagnostic ──
500
- try {
501
- const oldStat = await _reactNativeBlobUtil.default.fs.stat(actualOldPath);
502
- const patchStat = await _reactNativeBlobUtil.default.fs.stat(localPatchPath);
503
- if (__DEV__) {
504
- console.log(`[otaCore] DEBUG old bundle size=${oldStat.size} path=${actualOldPath}`);
505
- console.log(`[otaCore] DEBUG patch size=${patchStat.size} path=${localPatchPath}`);
506
- console.log(`[otaCore] DEBUG baseHash expected=${patch.baseHash}`);
507
- }
508
- } catch {
509
- // Non-fatal: stat is for diagnostic only
457
+ /** tempBundle is verified: sync assets, swap it in, record the version, reload. */
458
+ const commit = async metrics => {
459
+ if (patch.assetManifestUrl) {
460
+ const manifestUrl = await getStorage(config).getUpdateFileUrl(patch.assetManifestUrl, config);
461
+ await downloadAndSyncAssets(manifestUrl, patch.toVersion, config, log, onStatus, onProgress);
462
+ }
463
+ if (await fs.exists(finalBundlePath)) {
464
+ await fs.unlink(finalBundlePath);
510
465
  }
466
+ await fs.mv(tempBundle, finalBundlePath);
511
467
 
512
- // ── Step 6: Verify Source Integrity ──
513
- if (patch.baseHash) {
514
- log.emit(_otaLogger.OtaEvent.HASH_VERIFY_START, {
515
- message: 'Verifying source bundle'
516
- });
517
- onStatus(_types.OtaStatus.APPLYING, 'Verifying source integrity...');
518
- const isValid = await verifyHash(actualOldPath, patch.baseHash);
519
- if (!isValid) {
520
- // Re-hash only for the diagnostic log (verifyHash doesn't return the actual hash)
521
- let actualSourceHash = '';
522
- let srcSize = 'unknown';
523
- try {
524
- actualSourceHash = await _reactNativeBlobUtil.default.fs.hash(actualOldPath, 'sha256');
525
- const srcStat = await _reactNativeBlobUtil.default.fs.stat(actualOldPath);
526
- srcSize = `${srcStat.size} bytes`;
527
- } catch {
528
- // stat failure is non-fatal for the error message
529
- }
530
- if (__DEV__) {
531
- console.error('[otaCore] SOURCE HASH MISMATCH\n' + ` path: ${actualOldPath}\n` + ` size: ${srcSize}\n` + ` expected: ${patch.baseHash}\n` + ` actual: ${actualSourceHash}\n` + ` patch.id: ${patch.id} (v${patch.fromVersion}→v${patch.toVersion})`);
532
- }
533
- log.emit(_otaLogger.OtaEvent.HASH_MISMATCH, {
534
- version: metadata.currentVersion,
535
- message: 'Source bundle hash mismatch',
536
- metadata: {
537
- expected: patch.baseHash,
538
- actual: actualSourceHash,
539
- path: actualOldPath
540
- }
541
- });
542
- throw new Error('Source bundle hash mismatch. Fallback required.');
543
- }
544
- log.emit(_otaLogger.OtaEvent.HASH_VERIFY_SUCCESS, {
545
- message: 'Source bundle verified'
546
- });
468
+ // Native version, not config.appVersion: the native resolver compares against it on boot
469
+ const nativeAppVersion = await _NativeBsPatch.default.getAppVersion().catch(() => config.appVersion);
470
+ await (0, _otaMetadata.updateMetadata)(config.baseAppVersion, metadata => (0, _otaMetadata.registerNewVersion)(metadata, patch.toVersion, nativeAppVersion));
471
+ const totalMs = Date.now() - totalStart;
472
+ log.emit(_otaLogger.OtaEvent.PATCH_SUCCESS, {
473
+ version: patch.toVersion,
474
+ durationMs: totalMs,
475
+ sizeKB: metrics.patchSizeKB
476
+ });
477
+ onStatus(_types.OtaStatus.SUCCESS, `Update V${patch.toVersion} Successful!`, {
478
+ ...metrics,
479
+ totalMs,
480
+ fromCache: false
481
+ });
482
+
483
+ // registerNewVersion left lastLaunchSuccessful=true: the native resolver marks the
484
+ // first launch of the new version pending, and JS marks it healthy once it renders.
485
+ if (options.autoReload !== false) {
486
+ _NativeBsPatch.default.reloadBundle();
487
+ } else if (__DEV__) {
488
+ console.log(`[otaCore] V${patch.toVersion} prepared in background — applies on next launch.`);
547
489
  }
490
+ };
491
+ const applyPatchFile = async sourcePath => {
492
+ onStatus(_types.OtaStatus.DOWNLOADING, `Downloading V${patch.toVersion}...`);
493
+ const {
494
+ downloadMs,
495
+ sizeBytes
496
+ } = await download(patch.filename, localPatchPath);
548
497
 
549
- // ── Step 7: Apply Patch ──
550
- const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
551
- if (!(await _reactNativeBlobUtil.default.fs.exists(versionDir))) {
552
- await _reactNativeBlobUtil.default.fs.mkdir(versionDir);
498
+ // Verify before feeding the patch to native bspatch
499
+ onStatus(_types.OtaStatus.APPLYING, 'Verifying...');
500
+ try {
501
+ await assertSigned(localPatchPath, patch.signature, config, 'Patch');
502
+ } catch (sigError) {
503
+ log.emit(_otaLogger.OtaEvent.SIGNATURE_INVALID, {
504
+ version: patch.toVersion
505
+ });
506
+ throw sigError;
553
507
  }
508
+ await assertHash(sourcePath, patch.baseHash, 'Source bundle');
554
509
  log.emit(_otaLogger.OtaEvent.PATCH_START, {
555
510
  version: patch.toVersion
556
511
  });
557
512
  onStatus(_types.OtaStatus.APPLYING, 'Applying binary patch...');
558
513
  const applyStart = Date.now();
559
- const success = await _NativeBsPatch.default.applyPatch(actualOldPath, tempBundle, localPatchPath);
514
+ const success = await _NativeBsPatch.default.applyPatch(sourcePath, tempBundle, localPatchPath);
560
515
  const applyMs = Date.now() - applyStart;
516
+ await fs.unlink(localPatchPath).catch(() => {});
561
517
  if (!success) {
562
518
  log.emit(_otaLogger.OtaEvent.PATCH_FAIL, {
563
519
  message: 'BsPatch returned false',
@@ -565,148 +521,62 @@ const runUpdatePipeline = async (patch, config, onStatus, onProgress, options =
565
521
  });
566
522
  throw new Error('BsPatch application failed');
567
523
  }
568
-
569
- // ── Step 8: Verify Result Integrity ──
570
- if (patch.bundleHash) {
571
- log.emit(_otaLogger.OtaEvent.HASH_VERIFY_START, {
572
- message: 'Verifying result bundle'
573
- });
574
- onStatus(_types.OtaStatus.APPLYING, 'Verifying integrity...');
575
- const isValid = await verifyHash(tempBundle, patch.bundleHash);
576
- if (!isValid) {
577
- console.error('[otaCore] Result bundle hash mismatch | expected:', patch.bundleHash, 'path:', tempBundle);
578
- log.emit(_otaLogger.OtaEvent.HASH_MISMATCH, {
579
- message: 'Result bundle hash mismatch',
580
- version: patch.toVersion,
581
- metadata: {
582
- expected: patch.bundleHash,
583
- path: tempBundle
584
- }
585
- });
586
- throw new Error('Result bundle hash mismatch');
587
- }
588
- log.emit(_otaLogger.OtaEvent.HASH_VERIFY_SUCCESS, {
589
- message: 'Result bundle verified'
590
- });
591
- }
524
+ await assertHash(tempBundle, patch.bundleHash, 'Result bundle');
592
525
  await assertSigned(tempBundle, patch.bundleSignature, config, 'Bundle');
593
-
594
- // ── Step 9: Atomic Commit ──
595
- if (await _reactNativeBlobUtil.default.fs.exists(finalBundlePath)) {
596
- await _reactNativeBlobUtil.default.fs.unlink(finalBundlePath);
526
+ await commit({
527
+ patchSizeKB: sizeBytes / 1024,
528
+ downloadMs,
529
+ applyMs
530
+ });
531
+ };
532
+ const installFullBundle = async () => {
533
+ if (!patch.fullBundleUrl) {
534
+ throw new Error(`No full bundle for V${patch.toVersion}`);
597
535
  }
598
- await _reactNativeBlobUtil.default.fs.mv(tempBundle, finalBundlePath);
599
- await _reactNativeBlobUtil.default.fs.unlink(localPatchPath).catch(() => {});
600
-
601
- // ── Step 10: Update Metadata ──
602
- const newMetadata = (0, _otaMetadata.registerNewVersion)(metadata, patch.toVersion, config.appVersion);
603
- await (0, _otaMetadata.saveMetadata)(newMetadata);
604
- const totalMs = Date.now() - totalStart;
605
- log.emit(_otaLogger.OtaEvent.PATCH_SUCCESS, {
606
- version: patch.toVersion,
607
- durationMs: totalMs,
608
- sizeKB: patchSizeBytes / 1024
536
+ log.emit(_otaLogger.OtaEvent.FALLBACK_FULL_BUNDLE, {
537
+ version: patch.toVersion
609
538
  });
610
- onStatus(_types.OtaStatus.SUCCESS, `Update V${patch.toVersion} Successful!`, {
611
- patchSizeKB: patchSizeBytes / 1024,
539
+ onStatus(_types.OtaStatus.DOWNLOADING, `Downloading V${patch.toVersion} (full bundle)...`);
540
+ const {
612
541
  downloadMs,
613
- applyMs,
614
- totalMs,
615
- fromCache
542
+ sizeBytes
543
+ } = await download(patch.fullBundleUrl, tempBundle);
544
+ await assertHash(tempBundle, patch.bundleHash, 'Full bundle');
545
+ await assertSigned(tempBundle, patch.bundleSignature, config, 'Full bundle');
546
+ await commit({
547
+ patchSizeKB: sizeBytes / 1024,
548
+ downloadMs,
549
+ applyMs: 0
616
550
  });
551
+ };
552
+ const cleanup = async () => {
553
+ await fs.unlink(localPatchPath).catch(() => {});
554
+ await fs.unlink(tempBundle).catch(() => {});
555
+ };
617
556
 
618
- // ── Step 11: Reload ──
619
- // Mark healthy BEFORE reload — reloadBundle() calls Runtime.exit(0)
620
- _NativeBsPatch.default.markAsHealthy();
621
- if (options.autoReload !== false) {
557
+ // Patch only from an OTA bundle we installed ourselves (byte-identical to the build output)
558
+ const metadata = await (0, _otaMetadata.loadMetadata)(config.baseAppVersion);
559
+ const sourcePath = (0, _otaMetadata.getBundlePath)(metadata.currentVersion, config.baseAppVersion);
560
+ const canPatch = !!patch.filename && patch.fromVersion === metadata.currentVersion && !!sourcePath && (await fs.exists(sourcePath));
561
+ if (canPatch) {
562
+ try {
563
+ await applyPatchFile(sourcePath);
564
+ return;
565
+ } catch (error) {
566
+ await cleanup();
622
567
  if (__DEV__) {
623
- console.log(`[otaCore] V${patch.toVersion} marked healthy — reloading.`);
568
+ console.warn('[otaCore] Patch failed, falling back to full bundle:', error);
624
569
  }
625
- _NativeBsPatch.default.reloadBundle();
626
- } else {
627
- if (__DEV__) {
628
- console.log(`[otaCore] V${patch.toVersion} prepared silently in background — will apply on next launch.`);
570
+ if (!patch.fullBundleUrl) {
571
+ onStatus(_types.OtaStatus.ERROR, `Error: ${error.message}`);
572
+ throw error;
629
573
  }
630
574
  }
575
+ }
576
+ try {
577
+ await installFullBundle();
631
578
  } catch (error) {
632
- if (__DEV__) {
633
- console.error('[otaCore] Pipeline failed:', error);
634
- }
635
- await _reactNativeBlobUtil.default.fs.unlink(localPatchPath).catch(() => {});
636
- await _reactNativeBlobUtil.default.fs.unlink(tempBundle).catch(() => {});
637
-
638
- // ── Fallback: Full Bundle Download ──
639
- if (patch.fullBundleUrl) {
640
- try {
641
- onStatus(_types.OtaStatus.DOWNLOADING, 'Fallback: Downloading full bundle...');
642
- const storage = getStorage(config);
643
- const url = await storage.getUpdateFileUrl(patch.fullBundleUrl || patch.filename, config);
644
- const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
645
- if (!(await _reactNativeBlobUtil.default.fs.exists(versionDir))) {
646
- await _reactNativeBlobUtil.default.fs.mkdir(versionDir);
647
- }
648
- await downloadWithRetry(url, tempBundle, config, onProgress, {});
649
-
650
- // Verify full bundle hash
651
- if (patch.bundleHash) {
652
- const isValid = await verifyHash(tempBundle, patch.bundleHash);
653
- if (!isValid) {
654
- let actualHash = '';
655
- let size;
656
- try {
657
- actualHash = await _reactNativeBlobUtil.default.fs.hash(tempBundle, 'sha256');
658
- const stat = await _reactNativeBlobUtil.default.fs.stat(tempBundle);
659
- size = stat.size;
660
- } catch {
661
- // stat failure is non-fatal
662
- }
663
- if (__DEV__) {
664
- console.error('[otaCore] Full bundle hash mismatch | expected:', patch.bundleHash, 'actual:', actualHash);
665
- }
666
- log.emit(_otaLogger.OtaEvent.HASH_MISMATCH, {
667
- message: 'Full bundle hash mismatch',
668
- metadata: {
669
- expected: patch.bundleHash,
670
- actual: actualHash,
671
- size
672
- }
673
- });
674
- throw new Error('Full bundle hash mismatch after fallback');
675
- }
676
- }
677
- await assertSigned(tempBundle, patch.bundleSignature, config, 'Full bundle');
678
- await syncAssets();
679
-
680
- // Atomic swap
681
- if (await _reactNativeBlobUtil.default.fs.exists(finalBundlePath)) {
682
- await _reactNativeBlobUtil.default.fs.unlink(finalBundlePath);
683
- }
684
- await _reactNativeBlobUtil.default.fs.mv(tempBundle, finalBundlePath);
685
- const metadata = await (0, _otaMetadata.loadMetadata)(config.baseAppVersion);
686
- const newMetadata = (0, _otaMetadata.registerNewVersion)(metadata, patch.toVersion, config.appVersion);
687
- await (0, _otaMetadata.saveMetadata)(newMetadata);
688
-
689
- // ── CRITICAL: Mark healthy BEFORE reload so native OtaBundleResolver
690
- // does NOT see lastLaunchSuccessful=false on the very next boot and
691
- // immediately rollback the version we just successfully downloaded.
692
- // (reloadBundle() calls Runtime.exit(0) — JS won't run again before restart)
693
- _NativeBsPatch.default.markAsHealthy();
694
- if (__DEV__) {
695
- console.log(`[otaCore] Fallback V${patch.toVersion} marked healthy — reloading.`);
696
- }
697
- if (options.autoReload !== false) {
698
- onStatus(_types.OtaStatus.SUCCESS, `Fallback successful! Reloading...`);
699
- _NativeBsPatch.default.reloadBundle();
700
- } else {
701
- onStatus(_types.OtaStatus.SUCCESS, `Fallback bundle prepared silently in background.`);
702
- }
703
- return;
704
- } catch (fallbackError) {
705
- if (__DEV__) {
706
- console.error('[otaCore] Full bundle fallback also failed:', fallbackError);
707
- }
708
- }
709
- }
579
+ await cleanup();
710
580
  onStatus(_types.OtaStatus.ERROR, `Error: ${error.message}`);
711
581
  throw error;
712
582
  }