react-native-bspatch 1.1.4 → 2.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 +22 -32
- package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +15 -102
- package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +21 -1
- package/cpp/bsdiff/bspatch.c +1 -0
- package/ios/OtaBundleResolver.swift +16 -1
- package/ios/RNBsPatch.mm +61 -154
- package/lib/commonjs/NativeBsPatch.js.map +1 -1
- package/lib/commonjs/index.js +4 -4
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ota/OtaProvider.js +26 -22
- package/lib/commonjs/ota/OtaProvider.js.map +1 -1
- package/lib/commonjs/ota/otaAssetResolver.js +8 -2
- package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
- package/lib/commonjs/ota/otaCore.js +89 -141
- package/lib/commonjs/ota/otaCore.js.map +1 -1
- package/lib/commonjs/ota/otaLogger.js +0 -2
- package/lib/commonjs/ota/otaLogger.js.map +1 -1
- package/lib/commonjs/ota/otaMetadata.js +18 -7
- package/lib/commonjs/ota/otaMetadata.js.map +1 -1
- package/lib/commonjs/ota/storage/GitHubRawStorage.js +3 -1
- package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/commonjs/ota/types.js +1 -10
- package/lib/commonjs/ota/types.js.map +1 -1
- package/lib/module/NativeBsPatch.js.map +1 -1
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/ota/OtaProvider.js +26 -22
- package/lib/module/ota/OtaProvider.js.map +1 -1
- package/lib/module/ota/otaAssetResolver.js +8 -2
- package/lib/module/ota/otaAssetResolver.js.map +1 -1
- package/lib/module/ota/otaCore.js +88 -139
- package/lib/module/ota/otaCore.js.map +1 -1
- package/lib/module/ota/otaLogger.js +0 -2
- package/lib/module/ota/otaLogger.js.map +1 -1
- package/lib/module/ota/otaMetadata.js +18 -7
- package/lib/module/ota/otaMetadata.js.map +1 -1
- package/lib/module/ota/storage/GitHubRawStorage.js +3 -1
- package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/module/ota/types.js +0 -9
- package/lib/module/ota/types.js.map +1 -1
- package/lib/typescript/NativeBsPatch.d.ts +3 -15
- package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +1 -1
- package/lib/typescript/index.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 +5 -42
- package/lib/typescript/ota/otaCore.d.ts.map +1 -1
- package/lib/typescript/ota/otaLogger.d.ts +1 -3
- package/lib/typescript/ota/otaLogger.d.ts.map +1 -1
- package/lib/typescript/ota/otaMetadata.d.ts +2 -2
- package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
- package/lib/typescript/ota/types.d.ts +18 -54
- package/lib/typescript/ota/types.d.ts.map +1 -1
- package/package.json +3 -6
- package/react-native-bspatch.podspec +0 -1
- package/src/NativeBsPatch.ts +3 -16
- package/src/index.ts +1 -1
- package/src/ota/OtaProvider.tsx +24 -23
- package/src/ota/otaAssetResolver.ts +5 -2
- package/src/ota/otaCore.ts +118 -176
- package/src/ota/otaLogger.ts +0 -2
- package/src/ota/otaMetadata.ts +18 -8
- package/src/ota/storage/GitHubRawStorage.ts +1 -1
- package/src/ota/types.ts +18 -57
- package/tools/build_ota.sh +101 -174
- package/tools/setup_project.js +33 -14
- package/tools/update_registry.js +24 -28
- package/tools/{upload_ota_custom.sh → upload_ota.sh} +33 -114
- package/lib/commonjs/crypto-js.d.js +0 -2
- package/lib/commonjs/crypto-js.d.js.map +0 -1
- package/lib/commonjs/ota/storage/CustomStorage.js +0 -160
- package/lib/commonjs/ota/storage/CustomStorage.js.map +0 -1
- package/lib/commonjs/ota/storage/GitHubReleaseStorage.js +0 -99
- package/lib/commonjs/ota/storage/GitHubReleaseStorage.js.map +0 -1
- package/lib/module/crypto-js.d.js +0 -2
- package/lib/module/crypto-js.d.js.map +0 -1
- package/lib/module/ota/storage/CustomStorage.js +0 -154
- package/lib/module/ota/storage/CustomStorage.js.map +0 -1
- package/lib/module/ota/storage/GitHubReleaseStorage.js +0 -93
- package/lib/module/ota/storage/GitHubReleaseStorage.js.map +0 -1
- package/lib/typescript/ota/storage/CustomStorage.d.ts +0 -12
- package/lib/typescript/ota/storage/CustomStorage.d.ts.map +0 -1
- package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts +0 -20
- package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts.map +0 -1
- package/src/crypto-js.d.ts +0 -1
- package/src/ota/storage/CustomStorage.ts +0 -166
- package/src/ota/storage/GitHubReleaseStorage.ts +0 -103
- package/tools/bsdiff +0 -0
package/src/ota/otaCore.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* Responsibilities:
|
|
5
5
|
* - Download patches with retry
|
|
6
6
|
* - Verify integrity (SHA-256 hash)
|
|
7
|
-
* - Verify security (
|
|
8
|
-
* - Handle gzip decompression
|
|
7
|
+
* - Verify security (RSA/ECDSA signature)
|
|
9
8
|
* - Apply binary patch (bsdiff)
|
|
10
9
|
* - Atomic bundle swap
|
|
11
10
|
* - Full bundle fallback on failure
|
|
@@ -14,7 +13,6 @@
|
|
|
14
13
|
|
|
15
14
|
import { Platform } from 'react-native';
|
|
16
15
|
import ReactNativeBlobUtil from 'react-native-blob-util';
|
|
17
|
-
import CryptoJS from 'crypto-js';
|
|
18
16
|
import NativeBsPatch from '../NativeBsPatch';
|
|
19
17
|
import { createOtaLogger, OtaEvent } from './otaLogger';
|
|
20
18
|
import {
|
|
@@ -24,12 +22,9 @@ import {
|
|
|
24
22
|
registerNewVersion,
|
|
25
23
|
saveMetadata,
|
|
26
24
|
} from './otaMetadata';
|
|
27
|
-
import { CustomStorageProvider } from './storage/CustomStorage';
|
|
28
|
-
import { GitHubReleaseStorageProvider } from './storage/GitHubReleaseStorage';
|
|
29
25
|
import { GitHubRawStorageProvider } from './storage/GitHubRawStorage';
|
|
30
26
|
import {
|
|
31
27
|
OtaStatus,
|
|
32
|
-
OtaStorageType,
|
|
33
28
|
type OtaAsset,
|
|
34
29
|
type OtaConfig,
|
|
35
30
|
type OtaMetrics,
|
|
@@ -41,20 +36,11 @@ const OTA_DIR = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota`;
|
|
|
41
36
|
|
|
42
37
|
// ─── Storage Provider Factory ──────────────────────────────────────────
|
|
43
38
|
|
|
44
|
-
const DEFAULT_STORAGE = new
|
|
45
|
-
const GITHUB_STORAGE = new GitHubReleaseStorageProvider();
|
|
46
|
-
const GITHUB_RAW_STORAGE = new GitHubRawStorageProvider();
|
|
39
|
+
const DEFAULT_STORAGE = new GitHubRawStorageProvider();
|
|
47
40
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*/
|
|
52
|
-
const getStorage = (config: OtaConfig): OtaStorageProvider => {
|
|
53
|
-
if (config.storage) return config.storage;
|
|
54
|
-
if (config.storageType === OtaStorageType.GITHUB) return GITHUB_STORAGE;
|
|
55
|
-
if (config.storageType === OtaStorageType.GITHUB_RAW) return GITHUB_RAW_STORAGE;
|
|
56
|
-
return DEFAULT_STORAGE;
|
|
57
|
-
};
|
|
41
|
+
/** Explicit `config.storage` (e.g. a future Cloudflare provider) or GitHub raw. */
|
|
42
|
+
const getStorage = (config: OtaConfig): OtaStorageProvider =>
|
|
43
|
+
config.storage ?? DEFAULT_STORAGE;
|
|
58
44
|
|
|
59
45
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
60
46
|
|
|
@@ -103,93 +89,22 @@ export const verifyHash = async (
|
|
|
103
89
|
};
|
|
104
90
|
|
|
105
91
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* Uses CryptoJS to verify that the signature matches the file content
|
|
109
|
-
* using the provided HMAC key.
|
|
110
|
-
*
|
|
111
|
-
* @param filePath - Path to the file to verify
|
|
112
|
-
* @param signature - Base64-encoded HMAC signature to verify against
|
|
113
|
-
* @param publicKey - Base64-encoded HMAC key
|
|
114
|
-
* @returns true if signature is valid, false otherwise
|
|
115
|
-
*
|
|
116
|
-
* @example
|
|
117
|
-
* // Server-side (Node.js) - generate signature:
|
|
118
|
-
* const crypto = require('crypto');
|
|
119
|
-
* const key = crypto.randomBytes(32).toString('base64');
|
|
120
|
-
* const fileBuffer = fs.readFileSync('bundle.patch');
|
|
121
|
-
* const signature = crypto.createHmac('sha256', Buffer.from(key, 'base64'))
|
|
122
|
-
* .update(fileBuffer)
|
|
123
|
-
* .digest('base64');
|
|
92
|
+
* When `config.publicKey` is set, an RSA/ECDSA SHA-256 signature is mandatory
|
|
93
|
+
* and must verify (fail closed). Without a public key, signing is not enforced.
|
|
124
94
|
*/
|
|
125
|
-
|
|
95
|
+
const assertSigned = async (
|
|
126
96
|
filePath: string,
|
|
127
|
-
signature
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
return isValid;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Fallback to JS CryptoJS if native module is running an older binary
|
|
142
|
-
const fileData = await ReactNativeBlobUtil.fs.readFile(filePath, 'base64');
|
|
143
|
-
const fileWords = CryptoJS.enc.Base64.parse(fileData);
|
|
144
|
-
const keyWords = CryptoJS.enc.Base64.parse(publicKey);
|
|
145
|
-
const signatureWords = CryptoJS.enc.Base64.parse(signature);
|
|
146
|
-
const hmac = CryptoJS.HmacSHA256(fileWords, keyWords);
|
|
147
|
-
const isValid = hmac.toString(CryptoJS.enc.Base64) === signatureWords.toString(CryptoJS.enc.Base64);
|
|
148
|
-
|
|
149
|
-
if (!isValid && __DEV__) {
|
|
150
|
-
console.warn('[otaCore] Signature mismatch');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return isValid;
|
|
154
|
-
} catch (error) {
|
|
155
|
-
if (__DEV__) {
|
|
156
|
-
console.error('[otaCore] Signature verification failed:', error);
|
|
157
|
-
}
|
|
158
|
-
return false;
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Decompress a .gz file in-place using native helpers.
|
|
164
|
-
* Falls back to skipping decompression if no native helper is available,
|
|
165
|
-
* but enforces that the patch file must NOT be gzip-compressed in that case.
|
|
166
|
-
*/
|
|
167
|
-
const decompressGzip = async (filePath: string): Promise<string> => {
|
|
168
|
-
if (!filePath.endsWith('.gz')) {
|
|
169
|
-
return filePath;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const decompressedPath = filePath.replace(/\.gz$/, '');
|
|
173
|
-
try {
|
|
174
|
-
const success = await NativeBsPatch.decompressGzip(
|
|
175
|
-
filePath,
|
|
176
|
-
decompressedPath,
|
|
177
|
-
);
|
|
178
|
-
if (success) {
|
|
179
|
-
return decompressedPath;
|
|
180
|
-
}
|
|
181
|
-
} catch {
|
|
182
|
-
// Native decompress not available — fall through
|
|
97
|
+
signature: string | undefined,
|
|
98
|
+
config: OtaConfig,
|
|
99
|
+
what: string,
|
|
100
|
+
): Promise<void> => {
|
|
101
|
+
if (!config.publicKey) return;
|
|
102
|
+
const valid =
|
|
103
|
+
!!signature &&
|
|
104
|
+
(await NativeBsPatch.verifySignature(filePath, signature, config.publicKey).catch(() => false));
|
|
105
|
+
if (!valid) {
|
|
106
|
+
throw new Error(`${what} signature missing or invalid`);
|
|
183
107
|
}
|
|
184
|
-
|
|
185
|
-
// No native gzip support: abort if file is actually compressed.
|
|
186
|
-
// Do NOT silently rename a .gz file to .patch — bspatch will read gzip
|
|
187
|
-
// magic bytes and fail with a confusing error.
|
|
188
|
-
console.error(
|
|
189
|
-
'[otaCore] .gz decompression is not supported on this platform. ' +
|
|
190
|
-
'Ensure the OTA server sends uncompressed patches (compressed: false).',
|
|
191
|
-
);
|
|
192
|
-
throw new Error('Gzip decompression is not supported on this platform.');
|
|
193
108
|
};
|
|
194
109
|
|
|
195
110
|
/**
|
|
@@ -208,10 +123,13 @@ const downloadWithRetry = async (
|
|
|
208
123
|
|
|
209
124
|
for (let i = 0; i < retryLimit; i++) {
|
|
210
125
|
let taskRef: { cancel: () => void } | null = null;
|
|
126
|
+
// Download to .part and rename only on a 2xx response, so a cut-off
|
|
127
|
+
// download or an error page never sits at destPath looking complete.
|
|
128
|
+
const partPath = `${destPath}.part`;
|
|
211
129
|
try {
|
|
212
130
|
const task = ReactNativeBlobUtil.config({
|
|
213
131
|
fileCache: true,
|
|
214
|
-
path:
|
|
132
|
+
path: partPath,
|
|
215
133
|
followRedirect: true,
|
|
216
134
|
timeout,
|
|
217
135
|
})
|
|
@@ -226,11 +144,20 @@ const downloadWithRetry = async (
|
|
|
226
144
|
onProgress(percent);
|
|
227
145
|
});
|
|
228
146
|
taskRef = { cancel: () => task.cancel() };
|
|
229
|
-
await task;
|
|
147
|
+
const resp = await task;
|
|
148
|
+
const status = resp.respInfo.status;
|
|
149
|
+
if (status < 200 || status >= 300) {
|
|
150
|
+
throw new Error(`HTTP ${status} for ${url}`);
|
|
151
|
+
}
|
|
152
|
+
if (await ReactNativeBlobUtil.fs.exists(destPath)) {
|
|
153
|
+
await ReactNativeBlobUtil.fs.unlink(destPath);
|
|
154
|
+
}
|
|
155
|
+
await ReactNativeBlobUtil.fs.mv(partPath, destPath);
|
|
230
156
|
return;
|
|
231
157
|
} catch (e) {
|
|
232
158
|
// Cancel any lingering request before retrying
|
|
233
159
|
taskRef?.cancel();
|
|
160
|
+
await ReactNativeBlobUtil.fs.unlink(partPath).catch(() => {});
|
|
234
161
|
if (i === retryLimit - 1) throw e;
|
|
235
162
|
const delay = Math.pow(2, i) * 1000; // Exponential backoff: 1s, 2s, 4s...
|
|
236
163
|
if (__DEV__) {
|
|
@@ -347,12 +274,16 @@ const downloadAndSyncAssets = async (
|
|
|
347
274
|
|
|
348
275
|
const manifest = JSON.parse(manifestData);
|
|
349
276
|
// Filter out base assets, we don't need to download them
|
|
350
|
-
|
|
277
|
+
// Hash/ext become file names: reject anything that isn't a plain sha256 + extension
|
|
278
|
+
assets = (manifest.assets || []).filter(
|
|
279
|
+
(a: any) =>
|
|
280
|
+
!a.isBase &&
|
|
281
|
+
/^[a-f0-9]{64}$/i.test(a.hash) &&
|
|
282
|
+
/^[a-z0-9]+$/i.test(a.ext),
|
|
283
|
+
);
|
|
351
284
|
} catch (e) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
return;
|
|
285
|
+
// Fatal: activating the bundle without its manifest leaves images unresolved
|
|
286
|
+
throw new Error(`Failed to fetch asset manifest: ${(e as Error).message}`);
|
|
356
287
|
}
|
|
357
288
|
|
|
358
289
|
if (assets.length === 0) {
|
|
@@ -368,6 +299,7 @@ const downloadAndSyncAssets = async (
|
|
|
368
299
|
let processed = 0;
|
|
369
300
|
let downloaded = 0;
|
|
370
301
|
let cached = 0;
|
|
302
|
+
let failed = 0;
|
|
371
303
|
|
|
372
304
|
const updateProgress = () => {
|
|
373
305
|
processed++;
|
|
@@ -413,12 +345,11 @@ const downloadAndSyncAssets = async (
|
|
|
413
345
|
);
|
|
414
346
|
}
|
|
415
347
|
} else {
|
|
416
|
-
await
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}).fetch('GET', assetUrl);
|
|
348
|
+
await downloadWithRetry(assetUrl, localPath, config, () => {});
|
|
349
|
+
if (!(await verifyHash(localPath, asset.hash))) {
|
|
350
|
+
await ReactNativeBlobUtil.fs.unlink(localPath).catch(() => {});
|
|
351
|
+
throw new Error('hash mismatch');
|
|
352
|
+
}
|
|
422
353
|
downloaded++;
|
|
423
354
|
log.emit(OtaEvent.DOWNLOAD_SUCCESS, {
|
|
424
355
|
message: `Asset downloaded: ${filename}`,
|
|
@@ -431,7 +362,7 @@ const downloadAndSyncAssets = async (
|
|
|
431
362
|
e,
|
|
432
363
|
);
|
|
433
364
|
}
|
|
434
|
-
//
|
|
365
|
+
failed++; // keep going, fail the sync at the end
|
|
435
366
|
} finally {
|
|
436
367
|
updateProgress();
|
|
437
368
|
await downloadNext(); // Start next download
|
|
@@ -445,6 +376,10 @@ const downloadAndSyncAssets = async (
|
|
|
445
376
|
.map(() => downloadNext());
|
|
446
377
|
await Promise.all(workers);
|
|
447
378
|
|
|
379
|
+
if (failed > 0) {
|
|
380
|
+
throw new Error(`${failed} asset(s) failed to download`);
|
|
381
|
+
}
|
|
382
|
+
|
|
448
383
|
onStatus(
|
|
449
384
|
OtaStatus.SYNCING_ASSETS,
|
|
450
385
|
`Assets synced (${downloaded} new, ${cached} cached)`,
|
|
@@ -479,7 +414,7 @@ export const getLocalAssetPath = (hash: string, ext: string): string =>
|
|
|
479
414
|
* @returns Absolute path to the patch file
|
|
480
415
|
*/
|
|
481
416
|
export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
|
|
482
|
-
`${docDir}/ota/patch-${patch.id}.patch`;
|
|
417
|
+
`${docDir}/ota/patch-${patch.appVersion}-${patch.id}.patch`;
|
|
483
418
|
|
|
484
419
|
// ─── Main Update Pipeline ─────────────────────────────────────────────
|
|
485
420
|
|
|
@@ -489,7 +424,6 @@ export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
|
|
|
489
424
|
* Pipeline:
|
|
490
425
|
* 1. Version compatibility check
|
|
491
426
|
* 2. Download patch (with retry)
|
|
492
|
-
* 3. Decompress (if .gz)
|
|
493
427
|
* 4. Security signature check
|
|
494
428
|
* 5. Resolve source bundle
|
|
495
429
|
* 6. Verify source hash
|
|
@@ -501,7 +435,22 @@ export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
|
|
|
501
435
|
*
|
|
502
436
|
* On failure → fallback to full bundle download if URL available.
|
|
503
437
|
*/
|
|
504
|
-
|
|
438
|
+
let updateInFlight = false;
|
|
439
|
+
|
|
440
|
+
/** Runs the pipeline below; rejects if another update is already running. */
|
|
441
|
+
export const applyOtaPatchInternal: typeof runUpdatePipeline = async (...args) => {
|
|
442
|
+
if (updateInFlight) {
|
|
443
|
+
throw new Error('Another OTA update is already in progress');
|
|
444
|
+
}
|
|
445
|
+
updateInFlight = true;
|
|
446
|
+
try {
|
|
447
|
+
return await runUpdatePipeline(...args);
|
|
448
|
+
} finally {
|
|
449
|
+
updateInFlight = false;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const runUpdatePipeline = async (
|
|
505
454
|
patch: OtaPatch,
|
|
506
455
|
config: OtaConfig,
|
|
507
456
|
onStatus: (
|
|
@@ -532,6 +481,23 @@ export const applyOtaPatchInternal = async (
|
|
|
532
481
|
ReactNativeBlobUtil.fs.dirs.DocumentDir,
|
|
533
482
|
);
|
|
534
483
|
|
|
484
|
+
// Cached per asset hash, so calling it again in the fallback is cheap
|
|
485
|
+
const syncAssets = async () => {
|
|
486
|
+
if (!patch.assetManifestUrl) return;
|
|
487
|
+
const manifestUrl = await getStorage(config).getUpdateFileUrl(
|
|
488
|
+
patch.assetManifestUrl,
|
|
489
|
+
config,
|
|
490
|
+
);
|
|
491
|
+
await downloadAndSyncAssets(
|
|
492
|
+
manifestUrl,
|
|
493
|
+
patch.toVersion,
|
|
494
|
+
config,
|
|
495
|
+
log,
|
|
496
|
+
onStatus,
|
|
497
|
+
onProgress,
|
|
498
|
+
);
|
|
499
|
+
};
|
|
500
|
+
|
|
535
501
|
try {
|
|
536
502
|
// ── Step 1: Version Compatibility ──
|
|
537
503
|
if (patch.minAppVersion) {
|
|
@@ -607,49 +573,20 @@ export const applyOtaPatchInternal = async (
|
|
|
607
573
|
}
|
|
608
574
|
}
|
|
609
575
|
|
|
610
|
-
// ── Step 3: Decompress if needed ──
|
|
611
|
-
let actualPatchPath = localPatchPath;
|
|
612
|
-
if (patch.compressed) {
|
|
613
|
-
log.emit(OtaEvent.DECOMPRESS_START);
|
|
614
|
-
onStatus(OtaStatus.APPLYING, 'Decompressing...');
|
|
615
|
-
actualPatchPath = await decompressGzip(localPatchPath);
|
|
616
|
-
log.emit(OtaEvent.DECOMPRESS_SUCCESS);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
576
|
// ── Step 4: Signature Verification ──
|
|
620
|
-
|
|
577
|
+
// Verify before feeding the patch to native bspatch
|
|
578
|
+
if (config.publicKey) {
|
|
621
579
|
onStatus(OtaStatus.APPLYING, 'Verifying signature...');
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
if (!sigValid) {
|
|
628
|
-
log.emit(OtaEvent.SIGNATURE_INVALID, {
|
|
629
|
-
version: patch.toVersion,
|
|
630
|
-
});
|
|
631
|
-
throw new Error(
|
|
632
|
-
'Patch signature verification failed. Possible MITM.',
|
|
633
|
-
);
|
|
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;
|
|
634
585
|
}
|
|
635
586
|
}
|
|
636
587
|
|
|
637
588
|
// ── Step 4.2: Download & Sync Assets ──
|
|
638
|
-
|
|
639
|
-
const storage = getStorage(config);
|
|
640
|
-
const manifestUrl = await storage.getUpdateFileUrl(
|
|
641
|
-
patch.assetManifestUrl,
|
|
642
|
-
config,
|
|
643
|
-
);
|
|
644
|
-
await downloadAndSyncAssets(
|
|
645
|
-
manifestUrl,
|
|
646
|
-
patch.toVersion,
|
|
647
|
-
config,
|
|
648
|
-
log,
|
|
649
|
-
onStatus,
|
|
650
|
-
onProgress,
|
|
651
|
-
);
|
|
652
|
-
}
|
|
589
|
+
await syncAssets();
|
|
653
590
|
|
|
654
591
|
// ── Step 5: Resolve Source Bundle ──
|
|
655
592
|
onStatus(OtaStatus.APPLYING, 'Preparing source...');
|
|
@@ -692,22 +629,13 @@ export const applyOtaPatchInternal = async (
|
|
|
692
629
|
// True base: extract byte-for-byte from APK assets.
|
|
693
630
|
// RNFS.copyFileAssets is known to produce inconsistent bytes in some
|
|
694
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.
|
|
695
634
|
actualOldPath = `${OTA_DIR}/base.hbc`;
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
);
|
|
701
|
-
}
|
|
702
|
-
await NativeBsPatch.extractBundleFromAssets(
|
|
703
|
-
bundleFilename,
|
|
704
|
-
actualOldPath,
|
|
705
|
-
);
|
|
706
|
-
} else if (__DEV__) {
|
|
707
|
-
console.log(
|
|
708
|
-
`[otaCore] Source: cached APK-extracted bundle → ${actualOldPath}`,
|
|
709
|
-
);
|
|
710
|
-
}
|
|
635
|
+
await NativeBsPatch.extractBundleFromAssets(
|
|
636
|
+
bundleFilename,
|
|
637
|
+
actualOldPath,
|
|
638
|
+
);
|
|
711
639
|
}
|
|
712
640
|
} else {
|
|
713
641
|
actualOldPath = oldBundlePath;
|
|
@@ -717,14 +645,14 @@ export const applyOtaPatchInternal = async (
|
|
|
717
645
|
try {
|
|
718
646
|
const oldStat = await ReactNativeBlobUtil.fs.stat(actualOldPath);
|
|
719
647
|
const patchStat = await ReactNativeBlobUtil.fs.stat(
|
|
720
|
-
|
|
648
|
+
localPatchPath,
|
|
721
649
|
);
|
|
722
650
|
if (__DEV__) {
|
|
723
651
|
console.log(
|
|
724
652
|
`[otaCore] DEBUG old bundle size=${oldStat.size} path=${actualOldPath}`,
|
|
725
653
|
);
|
|
726
654
|
console.log(
|
|
727
|
-
`[otaCore] DEBUG patch size=${patchStat.size} path=${
|
|
655
|
+
`[otaCore] DEBUG patch size=${patchStat.size} path=${localPatchPath}`,
|
|
728
656
|
);
|
|
729
657
|
console.log(
|
|
730
658
|
`[otaCore] DEBUG baseHash expected=${patch.baseHash}`,
|
|
@@ -797,7 +725,7 @@ export const applyOtaPatchInternal = async (
|
|
|
797
725
|
const success = await NativeBsPatch.applyPatch(
|
|
798
726
|
actualOldPath,
|
|
799
727
|
tempBundle,
|
|
800
|
-
|
|
728
|
+
localPatchPath,
|
|
801
729
|
);
|
|
802
730
|
const applyMs = Date.now() - applyStart;
|
|
803
731
|
|
|
@@ -839,14 +767,18 @@ export const applyOtaPatchInternal = async (
|
|
|
839
767
|
});
|
|
840
768
|
}
|
|
841
769
|
|
|
770
|
+
await assertSigned(tempBundle, patch.bundleSignature, config, 'Bundle');
|
|
771
|
+
|
|
842
772
|
// ── Step 9: Atomic Commit ──
|
|
843
773
|
if (await ReactNativeBlobUtil.fs.exists(finalBundlePath)) {
|
|
844
774
|
await ReactNativeBlobUtil.fs.unlink(finalBundlePath);
|
|
845
775
|
}
|
|
846
776
|
await ReactNativeBlobUtil.fs.mv(tempBundle, finalBundlePath);
|
|
847
777
|
|
|
778
|
+
await ReactNativeBlobUtil.fs.unlink(localPatchPath).catch(() => {});
|
|
779
|
+
|
|
848
780
|
// ── Step 10: Update Metadata ──
|
|
849
|
-
const newMetadata = registerNewVersion(metadata, patch.toVersion);
|
|
781
|
+
const newMetadata = registerNewVersion(metadata, patch.toVersion, config.appVersion);
|
|
850
782
|
await saveMetadata(newMetadata);
|
|
851
783
|
|
|
852
784
|
const totalMs = Date.now() - totalStart;
|
|
@@ -886,6 +818,8 @@ export const applyOtaPatchInternal = async (
|
|
|
886
818
|
if (__DEV__) {
|
|
887
819
|
console.error('[otaCore] Pipeline failed:', error);
|
|
888
820
|
}
|
|
821
|
+
await ReactNativeBlobUtil.fs.unlink(localPatchPath).catch(() => {});
|
|
822
|
+
await ReactNativeBlobUtil.fs.unlink(tempBundle).catch(() => {});
|
|
889
823
|
|
|
890
824
|
// ── Fallback: Full Bundle Download ──
|
|
891
825
|
if (patch.fullBundleUrl) {
|
|
@@ -900,6 +834,10 @@ export const applyOtaPatchInternal = async (
|
|
|
900
834
|
config,
|
|
901
835
|
);
|
|
902
836
|
|
|
837
|
+
const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
|
|
838
|
+
if (!(await ReactNativeBlobUtil.fs.exists(versionDir))) {
|
|
839
|
+
await ReactNativeBlobUtil.fs.mkdir(versionDir);
|
|
840
|
+
}
|
|
903
841
|
await downloadWithRetry(
|
|
904
842
|
url,
|
|
905
843
|
tempBundle,
|
|
@@ -951,6 +889,9 @@ export const applyOtaPatchInternal = async (
|
|
|
951
889
|
}
|
|
952
890
|
}
|
|
953
891
|
|
|
892
|
+
await assertSigned(tempBundle, patch.bundleSignature, config, 'Full bundle');
|
|
893
|
+
await syncAssets();
|
|
894
|
+
|
|
954
895
|
// Atomic swap
|
|
955
896
|
if (await ReactNativeBlobUtil.fs.exists(finalBundlePath)) {
|
|
956
897
|
await ReactNativeBlobUtil.fs.unlink(finalBundlePath);
|
|
@@ -961,6 +902,7 @@ export const applyOtaPatchInternal = async (
|
|
|
961
902
|
const newMetadata = registerNewVersion(
|
|
962
903
|
metadata,
|
|
963
904
|
patch.toVersion,
|
|
905
|
+
config.appVersion,
|
|
964
906
|
);
|
|
965
907
|
await saveMetadata(newMetadata);
|
|
966
908
|
|
package/src/ota/otaLogger.ts
CHANGED
|
@@ -24,8 +24,6 @@ export enum OtaEvent {
|
|
|
24
24
|
RESET_FACTORY = 'reset_factory',
|
|
25
25
|
VERSION_INCOMPATIBLE = 'version_incompatible',
|
|
26
26
|
SIGNATURE_INVALID = 'signature_invalid',
|
|
27
|
-
DECOMPRESS_START = 'decompress_start',
|
|
28
|
-
DECOMPRESS_SUCCESS = 'decompress_success',
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
export type OtaLogEntry = {
|
package/src/ota/otaMetadata.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { OtaMetadata } from './types';
|
|
|
4
4
|
|
|
5
5
|
const OTA_DIR = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota`;
|
|
6
6
|
const METADATA_PATH = `${OTA_DIR}/metadata.json`;
|
|
7
|
+
const METADATA_TMP_PATH = `${METADATA_PATH}.tmp`;
|
|
7
8
|
|
|
8
9
|
/** Promise-based lock to prevent concurrent metadata writes (double-tap guard). */
|
|
9
10
|
let saveLock: Promise<void> = Promise.resolve();
|
|
@@ -27,12 +28,11 @@ export const loadMetadata = async (
|
|
|
27
28
|
defaultVersion: number,
|
|
28
29
|
): Promise<OtaMetadata> => {
|
|
29
30
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
'utf8'
|
|
34
|
-
|
|
35
|
-
return JSON.parse(content);
|
|
31
|
+
// A crash between unlink and rename in saveMetadata leaves only the .tmp copy
|
|
32
|
+
for (const path of [METADATA_PATH, METADATA_TMP_PATH]) {
|
|
33
|
+
if (await ReactNativeBlobUtil.fs.exists(path)) {
|
|
34
|
+
return JSON.parse(await ReactNativeBlobUtil.fs.readFile(path, 'utf8'));
|
|
35
|
+
}
|
|
36
36
|
}
|
|
37
37
|
} catch (e) {
|
|
38
38
|
console.warn('[otaMetadata] Failed to load metadata:', e);
|
|
@@ -60,11 +60,16 @@ export const saveMetadata = async (metadata: OtaMetadata): Promise<void> => {
|
|
|
60
60
|
try {
|
|
61
61
|
await prevLock;
|
|
62
62
|
await ensureOtaDir();
|
|
63
|
+
// Write-then-rename so a kill mid-write never leaves truncated JSON
|
|
63
64
|
await ReactNativeBlobUtil.fs.writeFile(
|
|
64
|
-
|
|
65
|
+
METADATA_TMP_PATH,
|
|
65
66
|
JSON.stringify(metadata, null, 2),
|
|
66
67
|
'utf8',
|
|
67
68
|
);
|
|
69
|
+
if (await ReactNativeBlobUtil.fs.exists(METADATA_PATH)) {
|
|
70
|
+
await ReactNativeBlobUtil.fs.unlink(METADATA_PATH);
|
|
71
|
+
}
|
|
72
|
+
await ReactNativeBlobUtil.fs.mv(METADATA_TMP_PATH, METADATA_PATH);
|
|
68
73
|
} catch (e) {
|
|
69
74
|
console.error('[otaMetadata] Failed to save metadata:', e);
|
|
70
75
|
throw new Error(`Failed to save metadata: ${(e as Error).message}`);
|
|
@@ -87,6 +92,7 @@ export const MAX_VERSION_HISTORY = 3;
|
|
|
87
92
|
export const registerNewVersion = (
|
|
88
93
|
metadata: OtaMetadata,
|
|
89
94
|
newVersion: number,
|
|
95
|
+
appVersion?: string,
|
|
90
96
|
): OtaMetadata => {
|
|
91
97
|
const versions = [
|
|
92
98
|
metadata.currentVersion,
|
|
@@ -99,6 +105,7 @@ export const registerNewVersion = (
|
|
|
99
105
|
|
|
100
106
|
return {
|
|
101
107
|
...metadata,
|
|
108
|
+
appVersion: appVersion ?? metadata.appVersion,
|
|
102
109
|
currentVersion: newVersion,
|
|
103
110
|
previousVersions: uniquePrevious,
|
|
104
111
|
rollbackCount: 0,
|
|
@@ -138,7 +145,7 @@ export const cleanupOldVersions = async (
|
|
|
138
145
|
}
|
|
139
146
|
}
|
|
140
147
|
// Clean up leftover temporary bundle files (.tmp)
|
|
141
|
-
if (item.endsWith('.tmp')) {
|
|
148
|
+
if (item.endsWith('.tmp') && item !== 'metadata.json.tmp') {
|
|
142
149
|
await ReactNativeBlobUtil.fs.unlink(`${OTA_DIR}/${item}`).catch(() => {});
|
|
143
150
|
}
|
|
144
151
|
}
|
|
@@ -157,10 +164,13 @@ export const cleanupOldVersions = async (
|
|
|
157
164
|
*/
|
|
158
165
|
export const markHealthy = async (
|
|
159
166
|
defaultVersion: number,
|
|
167
|
+
appVersion?: string,
|
|
160
168
|
): Promise<OtaMetadata> => {
|
|
161
169
|
const metadata = await loadMetadata(defaultVersion);
|
|
162
170
|
const updated: OtaMetadata = {
|
|
163
171
|
...metadata,
|
|
172
|
+
// Stamp legacy metadata so the native resolver can detect a store update later
|
|
173
|
+
appVersion: metadata.appVersion ?? appVersion,
|
|
164
174
|
lastLaunchSuccessful: true,
|
|
165
175
|
rollbackCount: 0,
|
|
166
176
|
};
|