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