react-native-bspatch 1.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +471 -0
  3. package/android/build.gradle +100 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +139 -0
  6. package/android/src/main/java/vn/reactnativebspatch/BsPatchPackage.kt +16 -0
  7. package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +227 -0
  8. package/cpp/CMakeLists.txt +43 -0
  9. package/cpp/bsdiff/bspatch.c +194 -0
  10. package/cpp/bsdiff/bspatch.h +41 -0
  11. package/cpp/bsdiff/bspatch_wrapper.c +150 -0
  12. package/cpp/bsdiff/bspatch_wrapper.h +6 -0
  13. package/cpp/bspatch_jni.cpp +41 -0
  14. package/cpp/bzip2/LICENSE +42 -0
  15. package/cpp/bzip2/blocksort.c +1094 -0
  16. package/cpp/bzip2/bzlib.c +1572 -0
  17. package/cpp/bzip2/bzlib.h +282 -0
  18. package/cpp/bzip2/bzlib_private.h +509 -0
  19. package/cpp/bzip2/compress.c +672 -0
  20. package/cpp/bzip2/crctable.c +104 -0
  21. package/cpp/bzip2/decompress.c +652 -0
  22. package/cpp/bzip2/huffman.c +205 -0
  23. package/cpp/bzip2/randtable.c +84 -0
  24. package/ios/OtaBundleResolver.swift +241 -0
  25. package/ios/RNBsPatch.h +8 -0
  26. package/ios/RNBsPatch.mm +198 -0
  27. package/lib/commonjs/NativeBsPatch.js +9 -0
  28. package/lib/commonjs/NativeBsPatch.js.map +1 -0
  29. package/lib/commonjs/crypto-js.d.js +2 -0
  30. package/lib/commonjs/crypto-js.d.js.map +1 -0
  31. package/lib/commonjs/index.js +90 -0
  32. package/lib/commonjs/index.js.map +1 -0
  33. package/lib/commonjs/ota/OtaProvider.js +306 -0
  34. package/lib/commonjs/ota/OtaProvider.js.map +1 -0
  35. package/lib/commonjs/ota/otaAssetResolver.js +145 -0
  36. package/lib/commonjs/ota/otaAssetResolver.js.map +1 -0
  37. package/lib/commonjs/ota/otaCore.js +736 -0
  38. package/lib/commonjs/ota/otaCore.js.map +1 -0
  39. package/lib/commonjs/ota/otaLogger.js +64 -0
  40. package/lib/commonjs/ota/otaLogger.js.map +1 -0
  41. package/lib/commonjs/ota/otaMetadata.js +138 -0
  42. package/lib/commonjs/ota/otaMetadata.js.map +1 -0
  43. package/lib/commonjs/ota/storage/CustomStorage.js +140 -0
  44. package/lib/commonjs/ota/storage/CustomStorage.js.map +1 -0
  45. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js +96 -0
  46. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js.map +1 -0
  47. package/lib/commonjs/ota/types.js +39 -0
  48. package/lib/commonjs/ota/types.js.map +1 -0
  49. package/lib/commonjs/package.json +1 -0
  50. package/lib/module/NativeBsPatch.js +5 -0
  51. package/lib/module/NativeBsPatch.js.map +1 -0
  52. package/lib/module/crypto-js.d.js +2 -0
  53. package/lib/module/crypto-js.d.js.map +1 -0
  54. package/lib/module/index.js +23 -0
  55. package/lib/module/index.js.map +1 -0
  56. package/lib/module/ota/OtaProvider.js +299 -0
  57. package/lib/module/ota/OtaProvider.js.map +1 -0
  58. package/lib/module/ota/otaAssetResolver.js +138 -0
  59. package/lib/module/ota/otaAssetResolver.js.map +1 -0
  60. package/lib/module/ota/otaCore.js +725 -0
  61. package/lib/module/ota/otaCore.js.map +1 -0
  62. package/lib/module/ota/otaLogger.js +59 -0
  63. package/lib/module/ota/otaLogger.js.map +1 -0
  64. package/lib/module/ota/otaMetadata.js +127 -0
  65. package/lib/module/ota/otaMetadata.js.map +1 -0
  66. package/lib/module/ota/storage/CustomStorage.js +134 -0
  67. package/lib/module/ota/storage/CustomStorage.js.map +1 -0
  68. package/lib/module/ota/storage/GitHubReleaseStorage.js +90 -0
  69. package/lib/module/ota/storage/GitHubReleaseStorage.js.map +1 -0
  70. package/lib/module/ota/types.js +38 -0
  71. package/lib/module/ota/types.js.map +1 -0
  72. package/lib/typescript/NativeBsPatch.d.ts +39 -0
  73. package/lib/typescript/NativeBsPatch.d.ts.map +1 -0
  74. package/lib/typescript/index.d.ts +15 -0
  75. package/lib/typescript/index.d.ts.map +1 -0
  76. package/lib/typescript/ota/OtaProvider.d.ts +10 -0
  77. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -0
  78. package/lib/typescript/ota/otaAssetResolver.d.ts +15 -0
  79. package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -0
  80. package/lib/typescript/ota/otaCore.d.ts +109 -0
  81. package/lib/typescript/ota/otaCore.d.ts.map +1 -0
  82. package/lib/typescript/ota/otaLogger.d.ts +44 -0
  83. package/lib/typescript/ota/otaLogger.d.ts.map +1 -0
  84. package/lib/typescript/ota/otaMetadata.d.ts +46 -0
  85. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -0
  86. package/lib/typescript/ota/storage/CustomStorage.d.ts +12 -0
  87. package/lib/typescript/ota/storage/CustomStorage.d.ts.map +1 -0
  88. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts +20 -0
  89. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts.map +1 -0
  90. package/lib/typescript/ota/types.d.ts +191 -0
  91. package/lib/typescript/ota/types.d.ts.map +1 -0
  92. package/package.json +89 -0
  93. package/react-native-bspatch.podspec +46 -0
  94. package/src/NativeBsPatch.ts +52 -0
  95. package/src/crypto-js.d.ts +1 -0
  96. package/src/index.ts +29 -0
  97. package/src/ota/OtaProvider.tsx +399 -0
  98. package/src/ota/otaAssetResolver.ts +182 -0
  99. package/src/ota/otaCore.ts +963 -0
  100. package/src/ota/otaLogger.ts +72 -0
  101. package/src/ota/otaMetadata.ts +149 -0
  102. package/src/ota/storage/CustomStorage.ts +143 -0
  103. package/src/ota/storage/GitHubReleaseStorage.ts +98 -0
  104. package/src/ota/types.ts +211 -0
  105. package/tools/bsdiff +0 -0
  106. package/tools/bsdiff.c +445 -0
  107. package/tools/bsdiff.h +45 -0
  108. package/tools/build_ota.sh +457 -0
  109. package/tools/setup_project.js +273 -0
  110. package/tools/update_registry.js +145 -0
  111. package/tools/upload_ota_custom.sh +138 -0
@@ -0,0 +1,736 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.verifySignature = exports.verifyHash = exports.semverGte = exports.getLocalPatchPath = exports.getLocalAssetPath = exports.fetchOtaManifest = exports.applyOtaPatchInternal = void 0;
7
+ var _reactNative = require("react-native");
8
+ var _reactNativeBlobUtil = _interopRequireDefault(require("react-native-blob-util"));
9
+ var _cryptoJs = _interopRequireDefault(require("crypto-js"));
10
+ var _NativeBsPatch = _interopRequireDefault(require("../NativeBsPatch"));
11
+ var _otaLogger = require("./otaLogger");
12
+ var _otaMetadata = require("./otaMetadata");
13
+ var _CustomStorage = require("./storage/CustomStorage");
14
+ var _GitHubReleaseStorage = require("./storage/GitHubReleaseStorage");
15
+ var _types = require("./types");
16
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
17
+ /**
18
+ * Core OTA Logic (Stateless)
19
+ *
20
+ * Responsibilities:
21
+ * - Download patches with retry
22
+ * - Verify integrity (SHA-256 hash)
23
+ * - Verify security (HMAC signature)
24
+ * - Handle gzip decompression
25
+ * - Apply binary patch (bsdiff)
26
+ * - Atomic bundle swap
27
+ * - Full bundle fallback on failure
28
+ * - Structured telemetry logging
29
+ */
30
+
31
+ const OTA_DIR = `${_reactNativeBlobUtil.default.fs.dirs.DocumentDir}/ota`;
32
+
33
+ // ─── Storage Provider Factory ──────────────────────────────────────────
34
+
35
+ const DEFAULT_STORAGE = new _CustomStorage.CustomStorageProvider();
36
+ const GITHUB_STORAGE = new _GitHubReleaseStorage.GitHubReleaseStorageProvider();
37
+
38
+ /**
39
+ * Get the storage provider for the given configuration.
40
+ * Priority: explicit storage instance > storageType > default (CustomStorage)
41
+ */
42
+ const getStorage = config => {
43
+ if (config.storage) return config.storage;
44
+ if (config.storageType === _types.OtaStorageType.GITHUB) return GITHUB_STORAGE;
45
+ return DEFAULT_STORAGE;
46
+ };
47
+
48
+ // ─── Helpers ──────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Compares two semantic version strings.
52
+ *
53
+ * @param a - First version string (e.g., "1.2.3")
54
+ * @param b - Second version string (e.g., "1.2.4")
55
+ * @returns `true` if `a >= b`, `false` otherwise
56
+ *
57
+ * @example
58
+ * semverGte("1.2.3", "1.2.3") // true
59
+ * semverGte("1.2.4", "1.2.3") // true
60
+ * semverGte("1.2.2", "1.2.3") // false
61
+ */
62
+ const semverGte = (a, b) => {
63
+ const pa = a.split('.').map(Number);
64
+ const pb = b.split('.').map(Number);
65
+ for (let i = 0; i < 3; i++) {
66
+ const va = pa[i] || 0;
67
+ const vb = pb[i] || 0;
68
+ if (va > vb) return true;
69
+ if (va < vb) return false;
70
+ }
71
+ return true; // equal
72
+ };
73
+
74
+ /**
75
+ * Verifies the SHA-256 hash of a file.
76
+ *
77
+ * @param path - Absolute path to the file to verify
78
+ * @param expectedHash - Expected SHA-256 hash (hex string). If undefined, verification is skipped.
79
+ * @returns `true` if hash matches or `expectedHash` is undefined, `false` otherwise
80
+ */
81
+ exports.semverGte = semverGte;
82
+ const verifyHash = async (path, expectedHash) => {
83
+ if (!expectedHash) return true;
84
+ try {
85
+ const hash = await _reactNativeBlobUtil.default.fs.hash(path, 'sha256');
86
+ return hash.toLowerCase() === expectedHash.toLowerCase();
87
+ } catch {
88
+ return false;
89
+ }
90
+ };
91
+
92
+ /**
93
+ * Verify HMAC-SHA256 signature of a file.
94
+ *
95
+ * Uses CryptoJS to verify that the signature matches the file content
96
+ * using the provided HMAC key.
97
+ *
98
+ * @param filePath - Path to the file to verify
99
+ * @param signature - Base64-encoded HMAC signature to verify against
100
+ * @param publicKey - Base64-encoded HMAC key
101
+ * @returns true if signature is valid, false otherwise
102
+ *
103
+ * @example
104
+ * // Server-side (Node.js) - generate signature:
105
+ * const crypto = require('crypto');
106
+ * const key = crypto.randomBytes(32).toString('base64');
107
+ * const fileBuffer = fs.readFileSync('bundle.patch');
108
+ * const signature = crypto.createHmac('sha256', Buffer.from(key, 'base64'))
109
+ * .update(fileBuffer)
110
+ * .digest('base64');
111
+ */
112
+ exports.verifyHash = verifyHash;
113
+ const verifySignature = async (filePath, signature, publicKey) => {
114
+ if (!signature || !publicKey) return true; // Skip if not configured
115
+ try {
116
+ // Read file content
117
+ const fileData = await _reactNativeBlobUtil.default.fs.readFile(filePath, 'base64');
118
+
119
+ // Convert base64 to WordArray for CryptoJS
120
+ const fileWords = _cryptoJs.default.enc.Base64.parse(fileData);
121
+ const keyWords = _cryptoJs.default.enc.Base64.parse(publicKey);
122
+ const signatureWords = _cryptoJs.default.enc.Base64.parse(signature);
123
+
124
+ // Compute HMAC
125
+ const hmac = _cryptoJs.default.HmacSHA256(fileWords, keyWords);
126
+
127
+ // Compare computed HMAC with provided signature
128
+ const isValid = hmac.toString(_cryptoJs.default.enc.Base64) === signatureWords.toString(_cryptoJs.default.enc.Base64);
129
+ if (!isValid && __DEV__) {
130
+ console.warn('[otaCore] Signature mismatch');
131
+ }
132
+ return isValid;
133
+ } catch (error) {
134
+ if (__DEV__) {
135
+ console.error('[otaCore] Signature verification failed:', error);
136
+ }
137
+ return false;
138
+ }
139
+ };
140
+
141
+ /**
142
+ * Decompress a .gz file in-place using native helpers.
143
+ * Falls back to skipping decompression if no native helper is available,
144
+ * but enforces that the patch file must NOT be gzip-compressed in that case.
145
+ */
146
+ exports.verifySignature = verifySignature;
147
+ const decompressGzip = async filePath => {
148
+ if (!filePath.endsWith('.gz')) {
149
+ return filePath;
150
+ }
151
+ const decompressedPath = filePath.replace(/\.gz$/, '');
152
+ try {
153
+ const success = await _NativeBsPatch.default.decompressGzip(filePath, decompressedPath);
154
+ if (success) {
155
+ return decompressedPath;
156
+ }
157
+ } catch {
158
+ // Native decompress not available — fall through
159
+ }
160
+
161
+ // No native gzip support: abort if file is actually compressed.
162
+ // Do NOT silently rename a .gz file to .patch — bspatch will read gzip
163
+ // magic bytes and fail with a confusing error.
164
+ console.error('[otaCore] .gz decompression is not supported on this platform. ' + 'Ensure the OTA server sends uncompressed patches (compressed: false).');
165
+ throw new Error('Gzip decompression is not supported on this platform.');
166
+ };
167
+
168
+ /**
169
+ * Download a file with retry logic using exponential backoff.
170
+ * Cancels any in-flight request before retrying to avoid resource leaks.
171
+ */
172
+ const downloadWithRetry = async (url, destPath, config, onProgress, headers = {}) => {
173
+ const retryLimit = config.network?.retry ?? 3;
174
+ const timeout = config.network?.timeoutMs ?? 30000;
175
+ for (let i = 0; i < retryLimit; i++) {
176
+ let taskRef = null;
177
+ try {
178
+ const task = _reactNativeBlobUtil.default.config({
179
+ fileCache: true,
180
+ path: destPath,
181
+ followRedirect: true,
182
+ timeout
183
+ }).fetch('GET', url, {
184
+ 'Cache-Control': 'no-cache',
185
+ ...headers
186
+ }).progress((received, total) => {
187
+ const percent = Math.floor(Number(received) / Number(total) * 100);
188
+ onProgress(percent);
189
+ });
190
+ taskRef = {
191
+ cancel: () => task.cancel()
192
+ };
193
+ await task;
194
+ return;
195
+ } catch (e) {
196
+ // Cancel any lingering request before retrying
197
+ taskRef?.cancel();
198
+ if (i === retryLimit - 1) throw e;
199
+ const delay = Math.pow(2, i) * 1000; // Exponential backoff: 1s, 2s, 4s...
200
+ if (__DEV__) {
201
+ console.warn(`[otaCore] Download attempt ${i + 1} failed. Retrying in ${delay}ms...`);
202
+ }
203
+ await new Promise(resolve => setTimeout(resolve, delay));
204
+ }
205
+ }
206
+ };
207
+
208
+ // ─── Manifest ─────────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * Fetches the list of available OTA patches from the remote manifest.
212
+ *
213
+ * Falls back to bundled patches if the remote fetch fails, ensuring
214
+ * the app can still apply updates that were bundled at build time.
215
+ *
216
+ * @param config - OTA configuration containing storage provider and fallback patches
217
+ * @returns Array of available patches, or bundled patches if remote fetch fails
218
+ */
219
+ const fetchOtaManifest = async config => {
220
+ try {
221
+ const storage = getStorage(config);
222
+ return await storage.fetchManifest(config);
223
+ } catch (error) {
224
+ console.warn('[otaCore] Manifest fetch error:', error.message);
225
+ return config.bundledPatches || [];
226
+ }
227
+ };
228
+
229
+ // ─── Hashed Asset Store ───────────────────────────────────────────────
230
+ exports.fetchOtaManifest = fetchOtaManifest;
231
+ const ASSET_STORE_DIR = `${_reactNativeBlobUtil.default.fs.dirs.DocumentDir}/ota/assets`;
232
+
233
+ /**
234
+ * Downloads and syncs assets from the content-addressed asset store.
235
+ * Only downloads assets that are not already cached locally.
236
+ *
237
+ * @param assetManifestUrl URL to the asset-manifest.json for this version
238
+ * @param config OTA configuration
239
+ * @param log Logger instance
240
+ * @param onStatus Status update callback
241
+ */
242
+ const downloadAndSyncAssets = async (assetManifestUrl, version, config, log, onStatus, onProgress) => {
243
+ onStatus(_types.OtaStatus.SYNCING_ASSETS, 'Syncing assets...');
244
+ const versionDir = `${OTA_DIR}/v${version}`;
245
+ const manifestPath = `${versionDir}/asset-manifest-v${version}.json`;
246
+
247
+ // Ensure asset store dir exists
248
+ if (!(await _reactNativeBlobUtil.default.fs.exists(ASSET_STORE_DIR))) {
249
+ await _reactNativeBlobUtil.default.fs.mkdir(ASSET_STORE_DIR);
250
+ }
251
+
252
+ // Fetch the asset manifest
253
+ let assets = [];
254
+ try {
255
+ if (__DEV__) {
256
+ console.log(`[otaCore] Fetching asset manifest from: ${assetManifestUrl}`);
257
+ }
258
+ const resp = await _reactNativeBlobUtil.default.config({
259
+ followRedirect: true,
260
+ timeout: 10000
261
+ }).fetch('GET', assetManifestUrl, {
262
+ 'Cache-Control': 'no-cache'
263
+ });
264
+ if (resp.respInfo.status !== 200) {
265
+ throw new Error(`Manifest fetch failed with status ${resp.respInfo.status}`);
266
+ }
267
+
268
+ // Save manifest to disk for future reloads (AssetResolver needs it)
269
+ if (!(await _reactNativeBlobUtil.default.fs.exists(versionDir))) {
270
+ await _reactNativeBlobUtil.default.fs.mkdir(versionDir);
271
+ }
272
+ const manifestData = typeof resp.data === 'string' ? resp.data : JSON.stringify(resp.data);
273
+ await _reactNativeBlobUtil.default.fs.writeFile(manifestPath, manifestData, 'utf8');
274
+ if (__DEV__) {
275
+ console.log(`[otaCore] Saved asset manifest to: ${manifestPath}`);
276
+ }
277
+ const manifest = JSON.parse(manifestData);
278
+ // Filter out base assets, we don't need to download them
279
+ assets = (manifest.assets || []).filter(a => !a.isBase);
280
+ } catch (e) {
281
+ if (__DEV__) {
282
+ console.warn('[otaCore] Failed to fetch asset manifest:', e);
283
+ }
284
+ return;
285
+ }
286
+ if (assets.length === 0) {
287
+ if (__DEV__) {
288
+ console.log('[otaCore] No new assets to sync for this version.');
289
+ }
290
+ return;
291
+ }
292
+
293
+ // Only notify UI now that we actually have assets to download
294
+ onStatus(_types.OtaStatus.SYNCING_ASSETS, `Syncing ${assets.length} asset(s)...`);
295
+ let processed = 0;
296
+ let downloaded = 0;
297
+ let cached = 0;
298
+ const updateProgress = () => {
299
+ processed++;
300
+ const percent = Math.round(processed / assets.length * 100);
301
+ onProgress(percent);
302
+ };
303
+
304
+ // Concurrency-limited download queue
305
+ const maxConcurrency = 6; // Optimal for most mobile networks
306
+ let index = 0;
307
+ const downloadNext = async () => {
308
+ if (index >= assets.length) return;
309
+ const asset = assets[index++];
310
+ const filename = `${asset.hash}.${asset.ext}`;
311
+ const localPath = `${ASSET_STORE_DIR}/${filename}`;
312
+
313
+ // Check if already cached
314
+ if (await _reactNativeBlobUtil.default.fs.exists(localPath)) {
315
+ cached++;
316
+ updateProgress();
317
+ await downloadNext(); // Start next download
318
+ return;
319
+ }
320
+
321
+ // Download missing asset
322
+ onStatus(_types.OtaStatus.SYNCING_ASSETS, `Syncing assets (${processed + 1}/${assets.length})...`);
323
+ try {
324
+ const storage = getStorage(config);
325
+ const assetUrl = await storage.getAssetUrl(asset.hash, asset.ext, config);
326
+ if (!assetUrl) {
327
+ if (__DEV__) {
328
+ console.warn(`[otaCore] Could not get URL for asset ${filename}`);
329
+ }
330
+ } else {
331
+ await _reactNativeBlobUtil.default.config({
332
+ fileCache: true,
333
+ path: localPath,
334
+ followRedirect: true,
335
+ timeout: config.network?.timeoutMs ?? 30000
336
+ }).fetch('GET', assetUrl);
337
+ downloaded++;
338
+ log.emit(_otaLogger.OtaEvent.DOWNLOAD_SUCCESS, {
339
+ message: `Asset downloaded: ${filename}`
340
+ });
341
+ }
342
+ } catch (e) {
343
+ if (__DEV__) {
344
+ console.warn(`[otaCore] Failed to download asset ${filename}:`, e);
345
+ }
346
+ // Non-fatal: continue with other assets
347
+ } finally {
348
+ updateProgress();
349
+ await downloadNext(); // Start next download
350
+ }
351
+ };
352
+
353
+ // Start initial batch of downloads
354
+ const initialWorkers = Math.min(maxConcurrency, assets.length);
355
+ const workers = Array(initialWorkers).fill(null).map(() => downloadNext());
356
+ await Promise.all(workers);
357
+ onStatus(_types.OtaStatus.SYNCING_ASSETS, `Assets synced (${downloaded} new, ${cached} cached)`);
358
+ console.log(`[otaCore] Assets: ${downloaded} downloaded, ${cached} from cache`);
359
+ };
360
+
361
+ /**
362
+ * Gets the local filesystem path for a content-addressed asset.
363
+ *
364
+ * Assets are stored by their hash, so the same asset file is shared
365
+ * across all OTA versions that reference it.
366
+ *
367
+ * @param hash - SHA-256 hash of the asset content
368
+ * @param ext - File extension (e.g., "png", "jpg")
369
+ * @returns Absolute path to the asset in the local store
370
+ *
371
+ * @example
372
+ * getLocalAssetPath("abc123...", "png")
373
+ * // Returns: "/var/mobile/Containers/Data/Application/.../ota/assets/abc123....png"
374
+ */
375
+ const getLocalAssetPath = (hash, ext) => `${ASSET_STORE_DIR}/${hash}.${ext}`;
376
+
377
+ /**
378
+ * Gets the local filesystem path for a downloaded patch file.
379
+ *
380
+ * @param patch - Patch metadata object
381
+ * @param docDir - Document directory path (use `ReactNativeBlobUtil.fs.dirs.DocumentDir`)
382
+ * @returns Absolute path to the patch file
383
+ */
384
+ exports.getLocalAssetPath = getLocalAssetPath;
385
+ const getLocalPatchPath = (patch, docDir) => `${docDir}/ota/patch-${patch.id}.patch`;
386
+
387
+ // ─── Main Update Pipeline ─────────────────────────────────────────────
388
+
389
+ /**
390
+ * Applies a single patch and reloads the bundle.
391
+ *
392
+ * Pipeline:
393
+ * 1. Version compatibility check
394
+ * 2. Download patch (with retry)
395
+ * 3. Decompress (if .gz)
396
+ * 4. Security signature check
397
+ * 5. Resolve source bundle
398
+ * 6. Verify source hash
399
+ * 7. Apply binary patch
400
+ * 8. Verify result hash
401
+ * 9. Atomic commit (mv)
402
+ * 10. Update metadata
403
+ * 11. Reload bundle
404
+ *
405
+ * On failure → fallback to full bundle download if URL available.
406
+ */
407
+ exports.getLocalPatchPath = getLocalPatchPath;
408
+ const applyOtaPatchInternal = async (patch, config, onStatus, onProgress) => {
409
+ const log = (0, _otaLogger.createOtaLogger)(config.onEvent);
410
+ await (0, _otaMetadata.ensureOtaDir)();
411
+ const totalStart = Date.now();
412
+ const finalBundlePath = (0, _otaMetadata.getBundlePath)(patch.toVersion, config.baseAppVersion) || '';
413
+ const tempBundle = `${finalBundlePath}.tmp`;
414
+ const localPatchPath = getLocalPatchPath(patch, _reactNativeBlobUtil.default.fs.dirs.DocumentDir);
415
+ try {
416
+ // ── Step 1: Version Compatibility ──
417
+ if (patch.minAppVersion) {
418
+ if (!semverGte(config.appVersion, patch.minAppVersion)) {
419
+ log.emit(_otaLogger.OtaEvent.VERSION_INCOMPATIBLE, {
420
+ message: `App ${config.appVersion} < required ${patch.minAppVersion}`,
421
+ version: patch.toVersion
422
+ });
423
+ throw new Error(`Native app ${config.appVersion} is too old. Update to ${patch.minAppVersion}+ first.`);
424
+ }
425
+ }
426
+
427
+ // ── Step 2: Download ──
428
+ let downloadMs = 0;
429
+ let patchSizeBytes = 0;
430
+ let fromCache = false;
431
+ if (await _reactNativeBlobUtil.default.fs.exists(localPatchPath)) {
432
+ const stat = await _reactNativeBlobUtil.default.fs.stat(localPatchPath);
433
+ patchSizeBytes = Number(stat.size);
434
+ fromCache = true;
435
+ onProgress(100);
436
+ } else {
437
+ log.emit(_otaLogger.OtaEvent.DOWNLOAD_START, {
438
+ version: patch.toVersion
439
+ });
440
+ onStatus(_types.OtaStatus.DOWNLOADING, `Downloading V${patch.toVersion}...`);
441
+ const dlStart = Date.now();
442
+ try {
443
+ const storage = getStorage(config);
444
+ const url = await storage.getUpdateFileUrl(patch.filename, config);
445
+ await downloadWithRetry(url, localPatchPath, config, onProgress, {});
446
+ downloadMs = Date.now() - dlStart;
447
+ const stat = await _reactNativeBlobUtil.default.fs.stat(localPatchPath);
448
+ patchSizeBytes = Number(stat.size);
449
+ log.emit(_otaLogger.OtaEvent.DOWNLOAD_SUCCESS, {
450
+ version: patch.toVersion,
451
+ durationMs: downloadMs,
452
+ sizeKB: patchSizeBytes / 1024
453
+ });
454
+ } catch (dlError) {
455
+ log.emit(_otaLogger.OtaEvent.DOWNLOAD_FAIL, {
456
+ error: dlError.message,
457
+ version: patch.toVersion
458
+ });
459
+ throw dlError;
460
+ }
461
+ }
462
+
463
+ // ── Step 3: Decompress if needed ──
464
+ let actualPatchPath = localPatchPath;
465
+ if (patch.compressed) {
466
+ log.emit(_otaLogger.OtaEvent.DECOMPRESS_START);
467
+ onStatus(_types.OtaStatus.APPLYING, 'Decompressing...');
468
+ actualPatchPath = await decompressGzip(localPatchPath);
469
+ log.emit(_otaLogger.OtaEvent.DECOMPRESS_SUCCESS);
470
+ }
471
+
472
+ // ── Step 4: Signature Verification ──
473
+ if (patch.signature && config.publicKey) {
474
+ onStatus(_types.OtaStatus.APPLYING, 'Verifying signature...');
475
+ const sigValid = await verifySignature(actualPatchPath, patch.signature, config.publicKey);
476
+ if (!sigValid) {
477
+ log.emit(_otaLogger.OtaEvent.SIGNATURE_INVALID, {
478
+ version: patch.toVersion
479
+ });
480
+ throw new Error('Patch signature verification failed. Possible MITM.');
481
+ }
482
+ }
483
+
484
+ // ── Step 4.2: Download & Sync Assets ──
485
+ if (patch.assetManifestUrl) {
486
+ const storage = getStorage(config);
487
+ const manifestUrl = await storage.getUpdateFileUrl(patch.assetManifestUrl, config);
488
+ await downloadAndSyncAssets(manifestUrl, patch.toVersion, config, log, onStatus, onProgress);
489
+ }
490
+
491
+ // ── Step 5: Resolve Source Bundle ──
492
+ onStatus(_types.OtaStatus.APPLYING, 'Preparing source...');
493
+ const metadata = await (0, _otaMetadata.loadMetadata)(config.baseAppVersion);
494
+ const oldBundlePath = (0, _otaMetadata.getBundlePath)(metadata.currentVersion, config.baseAppVersion);
495
+
496
+ // ── Resolve the actual old bundle path ──────────────────────────────
497
+ // getBundlePath returns null when currentVersion == baseAppVersion, meaning
498
+ // "use the APK's baked-in bundle". However, if BASE_APP_VERSION was bumped
499
+ // to match a version that was applied via OTA (not actually baked into the
500
+ // installed APK), direct APK extraction would produce a DIFFERENT file than
501
+ // what the build script recorded as the baseHash for the next patch.
502
+ //
503
+ // Strategy (in order of precedence):
504
+ // 1. Use the explicit OTA bundle path from getBundlePath (version > base) ✓
505
+ // 2. If null (version == base), check for a previously OTA-stored bundle
506
+ // at ota/v{currentVersion}/ — its hash matches the server registry.
507
+ // 3. Fall back to APK extraction only if no OTA bundle exists on disk.
508
+ const bundleFilename = _reactNative.Platform.OS === 'android' ? 'index.android.bundle' : 'main.jsbundle';
509
+ let actualOldPath = '';
510
+ if (!oldBundlePath) {
511
+ // currentVersion == baseAppVersion: prefer OTA-stored bundle for this
512
+ // version over APK extraction (handles BASE_APP_VERSION bump scenarios).
513
+ const otaStoredPath = `${OTA_DIR}/v${metadata.currentVersion}/${bundleFilename}`;
514
+ if (await _reactNativeBlobUtil.default.fs.exists(otaStoredPath)) {
515
+ if (__DEV__) {
516
+ console.log(`[otaCore] Source: OTA-stored bundle for V${metadata.currentVersion} (BASE_APP_VERSION match)`);
517
+ }
518
+ actualOldPath = otaStoredPath;
519
+ } else {
520
+ // True base: extract byte-for-byte from APK assets.
521
+ // RNFS.copyFileAssets is known to produce inconsistent bytes in some
522
+ // APK configurations (e.g., release signing + resource shrinking).
523
+ actualOldPath = `${OTA_DIR}/base.hbc`;
524
+ if (!(await _reactNativeBlobUtil.default.fs.exists(actualOldPath))) {
525
+ if (__DEV__) {
526
+ console.log(`[otaCore] Source: extracting bundle from APK assets → ${actualOldPath}`);
527
+ }
528
+ await _NativeBsPatch.default.extractBundleFromAssets(bundleFilename, actualOldPath);
529
+ } else if (__DEV__) {
530
+ console.log(`[otaCore] Source: cached APK-extracted bundle → ${actualOldPath}`);
531
+ }
532
+ }
533
+ } else {
534
+ actualOldPath = oldBundlePath;
535
+ }
536
+
537
+ // ── Debug: log file sizes for diagnostic ──
538
+ try {
539
+ const oldStat = await _reactNativeBlobUtil.default.fs.stat(actualOldPath);
540
+ const patchStat = await _reactNativeBlobUtil.default.fs.stat(actualPatchPath);
541
+ if (__DEV__) {
542
+ console.log(`[otaCore] DEBUG old bundle size=${oldStat.size} path=${actualOldPath}`);
543
+ console.log(`[otaCore] DEBUG patch size=${patchStat.size} path=${actualPatchPath}`);
544
+ console.log(`[otaCore] DEBUG baseHash expected=${patch.baseHash}`);
545
+ }
546
+ } catch {
547
+ // Non-fatal: stat is for diagnostic only
548
+ }
549
+
550
+ // ── Step 6: Verify Source Integrity ──
551
+ if (patch.baseHash) {
552
+ log.emit(_otaLogger.OtaEvent.HASH_VERIFY_START, {
553
+ message: 'Verifying source bundle'
554
+ });
555
+ onStatus(_types.OtaStatus.APPLYING, 'Verifying source integrity...');
556
+ const isValid = await verifyHash(actualOldPath, patch.baseHash);
557
+ if (!isValid) {
558
+ // Re-hash only for the diagnostic log (verifyHash doesn't return the actual hash)
559
+ let actualSourceHash = '';
560
+ let srcSize = 'unknown';
561
+ try {
562
+ actualSourceHash = await _reactNativeBlobUtil.default.fs.hash(actualOldPath, 'sha256');
563
+ const srcStat = await _reactNativeBlobUtil.default.fs.stat(actualOldPath);
564
+ srcSize = `${srcStat.size} bytes`;
565
+ } catch {
566
+ // stat failure is non-fatal for the error message
567
+ }
568
+ if (__DEV__) {
569
+ 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})`);
570
+ }
571
+ log.emit(_otaLogger.OtaEvent.HASH_MISMATCH, {
572
+ version: metadata.currentVersion,
573
+ message: 'Source bundle hash mismatch',
574
+ metadata: {
575
+ expected: patch.baseHash,
576
+ actual: actualSourceHash,
577
+ path: actualOldPath
578
+ }
579
+ });
580
+ throw new Error('Source bundle hash mismatch. Fallback required.');
581
+ }
582
+ log.emit(_otaLogger.OtaEvent.HASH_VERIFY_SUCCESS, {
583
+ message: 'Source bundle verified'
584
+ });
585
+ }
586
+
587
+ // ── Step 7: Apply Patch ──
588
+ const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
589
+ if (!(await _reactNativeBlobUtil.default.fs.exists(versionDir))) {
590
+ await _reactNativeBlobUtil.default.fs.mkdir(versionDir);
591
+ }
592
+ log.emit(_otaLogger.OtaEvent.PATCH_START, {
593
+ version: patch.toVersion
594
+ });
595
+ onStatus(_types.OtaStatus.APPLYING, 'Applying binary patch...');
596
+ const applyStart = Date.now();
597
+ const success = await _NativeBsPatch.default.applyPatch(actualOldPath, tempBundle, actualPatchPath);
598
+ const applyMs = Date.now() - applyStart;
599
+ if (!success) {
600
+ log.emit(_otaLogger.OtaEvent.PATCH_FAIL, {
601
+ message: 'BsPatch returned false',
602
+ version: patch.toVersion
603
+ });
604
+ throw new Error('BsPatch application failed');
605
+ }
606
+
607
+ // ── Step 8: Verify Result Integrity ──
608
+ if (patch.bundleHash) {
609
+ log.emit(_otaLogger.OtaEvent.HASH_VERIFY_START, {
610
+ message: 'Verifying result bundle'
611
+ });
612
+ onStatus(_types.OtaStatus.APPLYING, 'Verifying integrity...');
613
+ const isValid = await verifyHash(tempBundle, patch.bundleHash);
614
+ if (!isValid) {
615
+ console.error('[otaCore] Result bundle hash mismatch | expected:', patch.bundleHash, 'path:', tempBundle);
616
+ log.emit(_otaLogger.OtaEvent.HASH_MISMATCH, {
617
+ message: 'Result bundle hash mismatch',
618
+ version: patch.toVersion,
619
+ metadata: {
620
+ expected: patch.bundleHash,
621
+ path: tempBundle
622
+ }
623
+ });
624
+ throw new Error('Result bundle hash mismatch');
625
+ }
626
+ log.emit(_otaLogger.OtaEvent.HASH_VERIFY_SUCCESS, {
627
+ message: 'Result bundle verified'
628
+ });
629
+ }
630
+
631
+ // ── Step 9: Atomic Commit ──
632
+ if (await _reactNativeBlobUtil.default.fs.exists(finalBundlePath)) {
633
+ await _reactNativeBlobUtil.default.fs.unlink(finalBundlePath);
634
+ }
635
+ await _reactNativeBlobUtil.default.fs.mv(tempBundle, finalBundlePath);
636
+
637
+ // ── Step 10: Update Metadata ──
638
+ const newMetadata = (0, _otaMetadata.registerNewVersion)(metadata, patch.toVersion);
639
+ await (0, _otaMetadata.saveMetadata)(newMetadata);
640
+ const totalMs = Date.now() - totalStart;
641
+ log.emit(_otaLogger.OtaEvent.PATCH_SUCCESS, {
642
+ version: patch.toVersion,
643
+ durationMs: totalMs,
644
+ sizeKB: patchSizeBytes / 1024
645
+ });
646
+ onStatus(_types.OtaStatus.SUCCESS, `Update V${patch.toVersion} Successful!`, {
647
+ patchSizeKB: patchSizeBytes / 1024,
648
+ downloadMs,
649
+ applyMs,
650
+ totalMs,
651
+ fromCache
652
+ });
653
+
654
+ // ── Step 11: Reload ──
655
+ // CRITICAL: Mark healthy BEFORE reload — reloadBundle() calls Runtime.exit(0)
656
+ // and JS will NOT run again before restart. Native OtaBundleResolver sets
657
+ // lastLaunchSuccessful=false before loading the new bundle. Without this call,
658
+ // the next cold boot sees false → rollback to previous version.
659
+ _NativeBsPatch.default.markAsHealthy();
660
+ if (__DEV__) {
661
+ console.log(`[otaCore] V${patch.toVersion} marked healthy — reloading.`);
662
+ }
663
+ _NativeBsPatch.default.reloadBundle();
664
+ } catch (error) {
665
+ if (__DEV__) {
666
+ console.error('[otaCore] Pipeline failed:', error);
667
+ }
668
+
669
+ // ── Fallback: Full Bundle Download ──
670
+ if (patch.fullBundleUrl) {
671
+ try {
672
+ onStatus(_types.OtaStatus.DOWNLOADING, 'Fallback: Downloading full bundle...');
673
+ const storage = getStorage(config);
674
+ const url = await storage.getUpdateFileUrl(patch.fullBundleUrl || patch.filename, config);
675
+ await downloadWithRetry(url, tempBundle, config, onProgress, {});
676
+
677
+ // Verify full bundle hash
678
+ if (patch.bundleHash) {
679
+ const isValid = await verifyHash(tempBundle, patch.bundleHash);
680
+ if (!isValid) {
681
+ let actualHash = '';
682
+ let size;
683
+ try {
684
+ actualHash = await _reactNativeBlobUtil.default.fs.hash(tempBundle, 'sha256');
685
+ const stat = await _reactNativeBlobUtil.default.fs.stat(tempBundle);
686
+ size = stat.size;
687
+ } catch {
688
+ // stat failure is non-fatal
689
+ }
690
+ if (__DEV__) {
691
+ console.error('[otaCore] Full bundle hash mismatch | expected:', patch.bundleHash, 'actual:', actualHash);
692
+ }
693
+ log.emit(_otaLogger.OtaEvent.HASH_MISMATCH, {
694
+ message: 'Full bundle hash mismatch',
695
+ metadata: {
696
+ expected: patch.bundleHash,
697
+ actual: actualHash,
698
+ size
699
+ }
700
+ });
701
+ throw new Error('Full bundle hash mismatch after fallback');
702
+ }
703
+ }
704
+
705
+ // Atomic swap
706
+ if (await _reactNativeBlobUtil.default.fs.exists(finalBundlePath)) {
707
+ await _reactNativeBlobUtil.default.fs.unlink(finalBundlePath);
708
+ }
709
+ await _reactNativeBlobUtil.default.fs.mv(tempBundle, finalBundlePath);
710
+ const metadata = await (0, _otaMetadata.loadMetadata)(config.baseAppVersion);
711
+ const newMetadata = (0, _otaMetadata.registerNewVersion)(metadata, patch.toVersion);
712
+ await (0, _otaMetadata.saveMetadata)(newMetadata);
713
+
714
+ // ── CRITICAL: Mark healthy BEFORE reload so native OtaBundleResolver
715
+ // does NOT see lastLaunchSuccessful=false on the very next boot and
716
+ // immediately rollback the version we just successfully downloaded.
717
+ // (reloadBundle() calls Runtime.exit(0) — JS won't run again before restart)
718
+ _NativeBsPatch.default.markAsHealthy();
719
+ if (__DEV__) {
720
+ console.log(`[otaCore] Fallback V${patch.toVersion} marked healthy — reloading.`);
721
+ }
722
+ onStatus(_types.OtaStatus.SUCCESS, `Fallback successful! Reloading...`);
723
+ _NativeBsPatch.default.reloadBundle();
724
+ return;
725
+ } catch (fallbackError) {
726
+ if (__DEV__) {
727
+ console.error('[otaCore] Full bundle fallback also failed:', fallbackError);
728
+ }
729
+ }
730
+ }
731
+ onStatus(_types.OtaStatus.ERROR, `Error: ${error.message}`);
732
+ throw error;
733
+ }
734
+ };
735
+ exports.applyOtaPatchInternal = applyOtaPatchInternal;
736
+ //# sourceMappingURL=otaCore.js.map