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.
- package/README.md +126 -151
- package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +0 -25
- package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +14 -6
- package/ios/OtaBundleResolver.swift +14 -6
- package/ios/RNBsPatch.mm +1 -28
- package/lib/commonjs/NativeBsPatch.js.map +1 -1
- package/lib/commonjs/ota/OtaProvider.js +29 -16
- package/lib/commonjs/ota/OtaProvider.js.map +1 -1
- package/lib/commonjs/ota/otaAssetResolver.js +0 -20
- package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
- package/lib/commonjs/ota/otaCore.js +200 -330
- package/lib/commonjs/ota/otaCore.js.map +1 -1
- package/lib/commonjs/ota/otaMetadata.js +21 -21
- package/lib/commonjs/ota/otaMetadata.js.map +1 -1
- package/lib/commonjs/ota/storage/GitHubRawStorage.js +13 -15
- package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/commonjs/ota/types.js.map +1 -1
- package/lib/module/NativeBsPatch.js.map +1 -1
- package/lib/module/ota/OtaProvider.js +30 -17
- package/lib/module/ota/OtaProvider.js.map +1 -1
- package/lib/module/ota/otaAssetResolver.js +1 -21
- package/lib/module/ota/otaAssetResolver.js.map +1 -1
- package/lib/module/ota/otaCore.js +197 -329
- package/lib/module/ota/otaCore.js.map +1 -1
- package/lib/module/ota/otaMetadata.js +19 -19
- package/lib/module/ota/otaMetadata.js.map +1 -1
- package/lib/module/ota/storage/GitHubRawStorage.js +13 -15
- package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/module/ota/types.js.map +1 -1
- package/lib/typescript/NativeBsPatch.d.ts +0 -1
- package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
- package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
- package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -1
- package/lib/typescript/ota/otaCore.d.ts +13 -3
- package/lib/typescript/ota/otaCore.d.ts.map +1 -1
- package/lib/typescript/ota/otaMetadata.d.ts +9 -7
- package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
- package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
- package/lib/typescript/ota/types.d.ts +5 -19
- package/lib/typescript/ota/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeBsPatch.ts +0 -5
- package/src/ota/OtaProvider.tsx +30 -23
- package/src/ota/otaAssetResolver.ts +1 -17
- package/src/ota/otaCore.ts +203 -457
- package/src/ota/otaMetadata.ts +30 -20
- package/src/ota/storage/GitHubRawStorage.ts +13 -21
- package/src/ota/types.ts +5 -20
- package/tools/build_ota.sh +206 -516
- package/tools/setup_project.js +17 -115
- package/tools/update_registry.js +31 -32
- package/tools/upload_ota.sh +61 -14
package/src/ota/otaCore.ts
CHANGED
|
@@ -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
|
-
|
|
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`
|
|
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
|
|
200
|
+
* @returns Patches for this app version + channel; rejects if the registry can't be read
|
|
185
201
|
*/
|
|
186
|
-
export const fetchOtaManifest =
|
|
187
|
-
config
|
|
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
|
-
*
|
|
442
|
+
* Installs one OTA version.
|
|
423
443
|
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
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
|
-
*
|
|
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
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
512
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
577
|
-
|
|
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
|
-
//
|
|
589
|
-
await
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
773
|
-
if (
|
|
774
|
-
|
|
643
|
+
const installFullBundle = async () => {
|
|
644
|
+
if (!patch.fullBundleUrl) {
|
|
645
|
+
throw new Error(`No full bundle for V${patch.toVersion}`);
|
|
775
646
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
await
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
669
|
+
if (canPatch) {
|
|
670
|
+
try {
|
|
671
|
+
await applyPatchFile(sourcePath!);
|
|
672
|
+
return;
|
|
673
|
+
} catch (error) {
|
|
674
|
+
await cleanup();
|
|
804
675
|
if (__DEV__) {
|
|
805
|
-
console.
|
|
806
|
-
`[otaCore] V${patch.toVersion} marked healthy — reloading.`,
|
|
807
|
-
);
|
|
676
|
+
console.warn('[otaCore] Patch failed, falling back to full bundle:', error);
|
|
808
677
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
}
|