react-native-bspatch 2.0.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -151
- package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +0 -25
- package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +14 -6
- package/ios/OtaBundleResolver.swift +14 -6
- package/ios/RNBsPatch.mm +1 -28
- package/lib/commonjs/NativeBsPatch.js.map +1 -1
- package/lib/commonjs/ota/OtaProvider.js +29 -16
- package/lib/commonjs/ota/OtaProvider.js.map +1 -1
- package/lib/commonjs/ota/otaAssetResolver.js +0 -20
- package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
- package/lib/commonjs/ota/otaCore.js +200 -330
- package/lib/commonjs/ota/otaCore.js.map +1 -1
- package/lib/commonjs/ota/otaMetadata.js +21 -21
- package/lib/commonjs/ota/otaMetadata.js.map +1 -1
- package/lib/commonjs/ota/storage/GitHubRawStorage.js +13 -15
- package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/commonjs/ota/types.js.map +1 -1
- package/lib/module/NativeBsPatch.js.map +1 -1
- package/lib/module/ota/OtaProvider.js +30 -17
- package/lib/module/ota/OtaProvider.js.map +1 -1
- package/lib/module/ota/otaAssetResolver.js +1 -21
- package/lib/module/ota/otaAssetResolver.js.map +1 -1
- package/lib/module/ota/otaCore.js +197 -329
- package/lib/module/ota/otaCore.js.map +1 -1
- package/lib/module/ota/otaMetadata.js +19 -19
- package/lib/module/ota/otaMetadata.js.map +1 -1
- package/lib/module/ota/storage/GitHubRawStorage.js +13 -15
- package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/module/ota/types.js.map +1 -1
- package/lib/typescript/NativeBsPatch.d.ts +0 -1
- package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
- package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
- package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -1
- package/lib/typescript/ota/otaCore.d.ts +13 -3
- package/lib/typescript/ota/otaCore.d.ts.map +1 -1
- package/lib/typescript/ota/otaMetadata.d.ts +9 -7
- package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
- package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
- package/lib/typescript/ota/types.d.ts +5 -19
- package/lib/typescript/ota/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeBsPatch.ts +0 -5
- package/src/ota/OtaProvider.tsx +30 -23
- package/src/ota/otaAssetResolver.ts +1 -17
- package/src/ota/otaCore.ts +203 -457
- package/src/ota/otaMetadata.ts +30 -20
- package/src/ota/storage/GitHubRawStorage.ts +13 -21
- package/src/ota/types.ts +5 -20
- package/tools/build_ota.sh +206 -516
- package/tools/setup_project.js +17 -115
- package/tools/update_registry.js +31 -32
- package/tools/upload_ota.sh +61 -14
package/README.md
CHANGED
|
@@ -4,20 +4,20 @@ Premium OTA update solution for React Native using **TurboModules** + **bsdiff b
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **Binary delta patches** —
|
|
7
|
+
- **Binary delta patches** — after the first OTA, clients download only the diff from the OTA bundle they already run (typically a few hundred KB)
|
|
8
8
|
- **POSIX `mmap()` Zero-Copy Engine** — patches directly in mapped pages, saving ~85–90% heap RAM
|
|
9
9
|
- **ARM NEON SIMD Fast-Path** — vectorized diff patching via Clang SIMD
|
|
10
10
|
- **Silent Background Updates** — download and apply updates completely in the background; smoothly activates on next launch or app backgrounding
|
|
11
11
|
- **Auto Native Version Detection** — auto-detects `versionName` (Android) and `CFBundleShortVersionString` (iOS); no manual version sync in JS
|
|
12
12
|
- **Asymmetric Cryptography (RSA & ECDSA)** — sign patches on server/CI with Private Key; client verifies with Public Key (zero secret leakage)
|
|
13
|
-
- **
|
|
14
|
-
- **One-Command Version Synchronization** — `
|
|
15
|
-
- **Full
|
|
13
|
+
- **Shorebird-style CLI** — `patch android|ios`, release version read from the native project and confirmed with one Enter
|
|
14
|
+
- **One-Command Version Synchronization** — `react-native-bspatch version <x.y.z>` syncs `package.json`, Android `build.gradle`, and iOS Xcode project
|
|
15
|
+
- **Full bundle for the first OTA** — store installs (and clients outside the patch window, or whose patch fails) download the complete bundle; no base-bundle capture needed
|
|
16
16
|
- **Atomic bundle swap** — never leaves the app in a broken state
|
|
17
17
|
- **Crash detection + auto-rollback** — native-side protection runs **before** JS boots; if the updated bundle crashes, the app automatically rolls back to the previous version
|
|
18
18
|
- **Integrity verification** — SHA-256 hash check on both source bundle and result bundle
|
|
19
19
|
- **Disk Garbage Collection** — automatically removes obsolete bundle versions and dangling temporary files
|
|
20
|
-
- **Content-addressed asset store** — images/assets downloaded per
|
|
20
|
+
- **Content-addressed asset store** — images/assets downloaded once per content hash and redirected via `setCustomSourceTransformer`
|
|
21
21
|
- **Structured telemetry** — all OTA events forwarded to your analytics via `onEvent` callback
|
|
22
22
|
- **New Architecture ready** — built with TurboModules (no legacy Bridge)
|
|
23
23
|
|
|
@@ -53,7 +53,7 @@ android {
|
|
|
53
53
|
|
|
54
54
|
### iOS
|
|
55
55
|
|
|
56
|
-
No additional steps —
|
|
56
|
+
No additional steps — auto-linked via CocoaPods.
|
|
57
57
|
|
|
58
58
|
---
|
|
59
59
|
|
|
@@ -70,9 +70,8 @@ const config = {
|
|
|
70
70
|
updateMode: OtaUpdateMode.BACKGROUND_DOWNLOAD, // Silent download without interrupting user
|
|
71
71
|
allowDebugUpdates: false, // default: OTA is blocked in __DEV__
|
|
72
72
|
customServer: {
|
|
73
|
-
baseUrl:
|
|
74
|
-
channel:
|
|
75
|
-
baseFolder: 'ota',
|
|
73
|
+
baseUrl: Config.OTA_BASE_URL!, // public storage URL (R2 / GitHub raw), no fallback
|
|
74
|
+
channel: Config.ENVIRONMENT!, // flavor: development / staging / production
|
|
76
75
|
},
|
|
77
76
|
};
|
|
78
77
|
|
|
@@ -136,9 +135,6 @@ type OtaConfig = {
|
|
|
136
135
|
*/
|
|
137
136
|
baseAppVersion: number;
|
|
138
137
|
|
|
139
|
-
/** Fallback patches used when manifest fetch fails */
|
|
140
|
-
bundledPatches?: OtaPatch[];
|
|
141
|
-
|
|
142
138
|
/**
|
|
143
139
|
* Allow OTA checks and patch application while React Native is running in
|
|
144
140
|
* development mode (`__DEV__`).
|
|
@@ -155,14 +151,10 @@ type OtaConfig = {
|
|
|
155
151
|
*/
|
|
156
152
|
updateMode?: OtaUpdateMode;
|
|
157
153
|
|
|
158
|
-
/** Max rollback attempts before resetting to factory build (default: 3) */
|
|
159
|
-
maxRollback?: number;
|
|
160
|
-
|
|
161
154
|
/** Network retry and timeout settings */
|
|
162
155
|
network?: {
|
|
163
156
|
retry?: number; // max retry attempts (default: 3)
|
|
164
157
|
timeoutMs?: number; // request timeout in ms (default: 30000)
|
|
165
|
-
wifiOnly?: boolean; // TODO (default: false)
|
|
166
158
|
};
|
|
167
159
|
|
|
168
160
|
/**
|
|
@@ -178,13 +170,16 @@ type OtaConfig = {
|
|
|
178
170
|
*/
|
|
179
171
|
publicKey?: string;
|
|
180
172
|
|
|
181
|
-
/** Custom storage provider. Omit to use
|
|
173
|
+
/** Custom storage provider. Omit to use the default static-host provider. */
|
|
182
174
|
storage?: OtaStorageProvider;
|
|
183
175
|
|
|
184
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Static storage layout (public, read-only — never credentials). Required unless
|
|
178
|
+
* `storage` is set; invalid values disable OTA (red box in __DEV__), no fallback.
|
|
179
|
+
*/
|
|
185
180
|
customServer?: {
|
|
186
|
-
baseUrl: string; // https://raw.githubusercontent.com/<owner>/<repo>/<branch>
|
|
187
|
-
channel: string;
|
|
181
|
+
baseUrl: string; // R2 public URL, CDN, or https://raw.githubusercontent.com/<owner>/<repo>/<branch>
|
|
182
|
+
channel: string; // flavor
|
|
188
183
|
baseFolder?: string; // default: 'ota'
|
|
189
184
|
};
|
|
190
185
|
};
|
|
@@ -239,17 +234,24 @@ Your OTA server must expose a `ota_registry.json` at the configured path (e.g. `
|
|
|
239
234
|
"production": {
|
|
240
235
|
"patches": [
|
|
241
236
|
{
|
|
242
|
-
"id": "
|
|
243
|
-
"label": "Feature release",
|
|
237
|
+
"id": "full-to-v3-production-android",
|
|
244
238
|
"appVersion": "1.0.0",
|
|
245
|
-
"filename": "v1-to-v2.patch",
|
|
246
239
|
"fromVersion": 1,
|
|
247
|
-
"toVersion":
|
|
248
|
-
"
|
|
249
|
-
"bundleHash": "sha256_of_v2_bundle",
|
|
240
|
+
"toVersion": 3,
|
|
241
|
+
"bundleHash": "sha256_of_v3_bundle",
|
|
250
242
|
"platform": "android",
|
|
251
|
-
"
|
|
252
|
-
|
|
243
|
+
"fullBundleUrl": "bundle_1.0.0_v3_android_production.hbc"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"id": "v2-to-v3-production-android",
|
|
247
|
+
"appVersion": "1.0.0",
|
|
248
|
+
"filename": "patch_1.0.0_v2_to_v3_production_android.patch",
|
|
249
|
+
"fromVersion": 2,
|
|
250
|
+
"toVersion": 3,
|
|
251
|
+
"baseHash": "sha256_of_v2_bundle",
|
|
252
|
+
"bundleHash": "sha256_of_v3_bundle",
|
|
253
|
+
"platform": "android",
|
|
254
|
+
"fullBundleUrl": "bundle_1.0.0_v3_android_production.hbc"
|
|
253
255
|
}
|
|
254
256
|
]
|
|
255
257
|
}
|
|
@@ -259,7 +261,7 @@ Your OTA server must expose a `ota_registry.json` at the configured path (e.g. `
|
|
|
259
261
|
}
|
|
260
262
|
```
|
|
261
263
|
|
|
262
|
-
|
|
264
|
+
Every version has one full-bundle entry (no `filename`) plus patches from the last 5 OTA versions. The client applies the patch whose `fromVersion` is the OTA version installed on the device; otherwise (store build = v1, too far behind, or patch failure) it installs `fullBundleUrl`. The store bundle is never a patch source, so it never has to match byte-for-byte.
|
|
263
265
|
|
|
264
266
|
---
|
|
265
267
|
|
|
@@ -274,9 +276,12 @@ The native `OtaBundleResolver` runs **before JS boots** on every cold start:
|
|
|
274
276
|
2. If lastLaunchSuccessful == false → rollback to previous version
|
|
275
277
|
3. If currentVersion == baseAppVersion → use APK/IPA bundle
|
|
276
278
|
4. Otherwise → load OTA bundle from ota/v{version}/index.android.bundle (or main.jsbundle)
|
|
277
|
-
5.
|
|
278
|
-
6. JS boots →
|
|
279
|
-
7. If
|
|
279
|
+
5. Unless this version is already verified: mark lastLaunchSuccessful = false (pending)
|
|
280
|
+
6. JS boots → OtaProvider renders the app → marks the running version healthy + verified
|
|
281
|
+
7. If that never happens (crash on load or first render) → next boot sees false → rolls back
|
|
282
|
+
and records the version in failedVersions so it is never re-applied
|
|
283
|
+
8. A verified version is never rolled back later (a crash after it rendered is an app bug,
|
|
284
|
+
and killing the app during a download must not discard a good bundle)
|
|
280
285
|
```
|
|
281
286
|
|
|
282
287
|
This means crash protection is **native-side and zero-cost** — it doesn't require JS to run first.
|
|
@@ -285,7 +290,7 @@ This means crash protection is **native-side and zero-cost** — it doesn't requ
|
|
|
285
290
|
|
|
286
291
|
## Custom Storage Provider
|
|
287
292
|
|
|
288
|
-
The
|
|
293
|
+
The default provider (`GitHubRawStorageProvider`) reads any public static host with the `customServer` layout — Cloudflare R2, GitHub raw, a CDN. Implement the interface only for a backend that needs something else (signed URLs, an API):
|
|
289
294
|
|
|
290
295
|
```ts
|
|
291
296
|
interface OtaStorageProvider {
|
|
@@ -346,115 +351,92 @@ These are called automatically by `OtaProvider`. Only call manually if you manag
|
|
|
346
351
|
|
|
347
352
|
The library ships with an **automated build pipeline** (`tools/build_ota.sh`) that handles the entire release workflow: bundling, Hermes compilation, asset discovery, bsdiff patch generation, and server upload.
|
|
348
353
|
|
|
349
|
-
###
|
|
350
|
-
|
|
351
|
-
Add these to your `package.json` (or use the existing ones in this project):
|
|
354
|
+
### Commands
|
|
352
355
|
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
"ota:clean": "yarn react-native-bspatch reset"
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
### Workflow
|
|
367
|
-
|
|
368
|
-
```
|
|
369
|
-
ota:setup ──► (Build Release APK/IPA) ──► ota:build ──► (repeat for each OTA update)
|
|
370
|
-
│ │ │
|
|
371
|
-
│ │ └── Incremental patch from Base Bundle v1
|
|
372
|
-
│ │
|
|
373
|
-
│ └── Automatic Base Capture (Gradle & Xcode):
|
|
374
|
-
│ Each release build automatically saves `bundle_{appVersion}_v1_{platform}.hbc`.
|
|
375
|
-
│ No manual `ota:init` is required!
|
|
376
|
-
│
|
|
377
|
-
└── Patches native integration into project files:
|
|
378
|
-
• iOS: AppDelegate.swift — adds bundleURL() override
|
|
379
|
-
• iOS: project.pbxproj — syncs and auto-captures base bundle on Release archive
|
|
380
|
-
• Android: MainApplication.kt — adds OtaBundleResolver.resolve() call
|
|
381
|
-
• Android: app/build.gradle — auto-captures base bundle upon assembleRelease / bundleRelease
|
|
382
|
-
Idempotent: safe to run multiple times; skips already-patched files.
|
|
356
|
+
```bash
|
|
357
|
+
npx react-native-bspatch setup # once: wire OtaBundleResolver into the app
|
|
358
|
+
npx react-native-bspatch version 1.0.5 # set native version (package.json, Gradle, Xcode)
|
|
359
|
+
# … release 1.0.5 to the stores (this is OTA v1) …
|
|
360
|
+
npx react-native-bspatch patch android # build + publish the next OTA
|
|
361
|
+
npx react-native-bspatch patch ios --flavor staging
|
|
362
|
+
npx react-native-bspatch patch # both platforms (versions must match)
|
|
363
|
+
npx react-native-bspatch reset --flavor staging --push # wipe this release's OTAs
|
|
383
364
|
```
|
|
384
365
|
|
|
385
|
-
|
|
366
|
+
| Option | Default | Description |
|
|
367
|
+
|---|---|---|
|
|
368
|
+
| `android` / `ios` | both | Platform to patch |
|
|
369
|
+
| `--flavor <name>` | `dev` | Channel (`dev`→`development`, `prod`→`production`) |
|
|
370
|
+
| `--release-version <x.y.z>` | read from `versionName` / `MARKETING_VERSION`, confirmed by prompt | Store release the OTA targets |
|
|
371
|
+
| `-y` | | Skip the prompt, use the detected version |
|
|
386
372
|
|
|
387
|
-
|
|
388
|
-
|---|---|
|
|
389
|
-
| `yarn ota:setup` | Inject OTA integration into AppDelegate.swift, MainApplication.kt, project.pbxproj, and app/build.gradle. |
|
|
390
|
-
| `yarn ota:build` | Generate incremental `.patch` from previous bundle, upload to server, update registry. |
|
|
391
|
-
| `yarn ota:seed --apk <path>` | **Zero Mismatch Recovery:** Seed base bundle directly from an existing release APK/AAB. |
|
|
392
|
-
| `yarn ota:seed --ipa <path>` | **Zero Mismatch Recovery:** Seed base bundle directly from an existing release IPA. |
|
|
393
|
-
| `yarn ota:init` | Manual first release: build base APK/IPA + bundle + base asset manifest. Creates `ota_registry.json`. |
|
|
394
|
-
| `yarn ota:build --platform android` | Build Android only |
|
|
395
|
-
| `yarn ota:build --platform ios` | Build iOS only |
|
|
396
|
-
| `yarn ota:build --flavor staging` | Build with flavor "staging" (default: "dev") |
|
|
397
|
-
| `yarn ota:clean` | Wipe `ota_registry.json` + output files. Preserves base asset manifests so `ota:build` still works after clean. |
|
|
398
|
-
|
|
399
|
-
> [!TIP]
|
|
400
|
-
> **Zero Mismatch & Fail-Safe Protection:**
|
|
401
|
-
> 1. **Auto-Capture:** When you build a Release APK/AAB or Archive on Xcode, the bundle is automatically captured into `output/` as Base Bundle v1.
|
|
402
|
-
> 2. **Seed Binary:** If you forgot to capture the base bundle and already submitted your APK/IPA to the Store, run `yarn ota:seed --apk ./app-release.apk`. It extracts the exact byte-for-byte bundle directly from the release binary, guaranteeing 100% hash match.
|
|
403
|
-
> 3. **Fail-Safe Guard:** `ota:build` will refuse to generate an update if no authentic Base Bundle v1 is found, preventing corrupt patches that trigger full-bundle fallback downloads.
|
|
373
|
+
The release version is read from the native project (`versionName` / `MARKETING_VERSION`) and confirmed in a prompt — Enter keeps it. `-y` or `--release-version` skip the prompt (CI). Every patch ships a full bundle plus bsdiff patches from the last 5 OTA versions; v1 (the store build) is never a patch source, so nothing has to be captured from the store binary.
|
|
404
374
|
|
|
405
375
|
### How It Works
|
|
406
376
|
|
|
407
|
-
1. **Bundle** — `npx react-native bundle`
|
|
408
|
-
2. **Assets** —
|
|
409
|
-
3. **Patch** — `bsdiff` generates a
|
|
377
|
+
1. **Bundle** — `npx react-native bundle` + `hermesc` generate Hermes bytecode (`.hbc`), uploaded as the version's full bundle.
|
|
378
|
+
2. **Assets** — Every asset is copied to the content-addressed `asset-store/` (`{sha256}.{ext}`) and listed in the per-version manifest. Clients download each hash once and reuse it across versions.
|
|
379
|
+
3. **Patch** — `bsdiff` generates a delta from each of the last 5 OTA bundles (v2+) to the new one.
|
|
410
380
|
4. **Registry** — Updates `ota_registry.json` with new `baseHash`, `bundleHash`, and asset manifest URL for each platform/channel.
|
|
411
381
|
5. **Upload** — Uploads bundles, patches, and asset store to the configured server.
|
|
412
382
|
|
|
413
383
|
### One-time Setup
|
|
414
384
|
|
|
415
385
|
```bash
|
|
416
|
-
# 1.
|
|
417
|
-
|
|
386
|
+
# 1. Patch AppDelegate.swift / MainApplication.kt
|
|
387
|
+
npx react-native-bspatch setup
|
|
418
388
|
|
|
419
|
-
# 2.
|
|
420
|
-
# Gradle and Xcode will automatically capture the base bundle (v1)!
|
|
421
|
-
# OR seed from an existing release binary:
|
|
422
|
-
yarn ota:seed --apk ./release.apk
|
|
389
|
+
# 2. Release your app to App Store / Google Play (this is v1)
|
|
423
390
|
|
|
424
|
-
# 3. For each
|
|
425
|
-
|
|
391
|
+
# 3. For each OTA update:
|
|
392
|
+
npx react-native-bspatch patch android
|
|
393
|
+
npx react-native-bspatch patch ios
|
|
426
394
|
```
|
|
427
395
|
|
|
428
396
|
### Environment Variables
|
|
429
397
|
|
|
430
|
-
|
|
398
|
+
The build reads two files from the project root:
|
|
431
399
|
|
|
432
|
-
|
|
400
|
+
| File | Contents | Read by the app? |
|
|
401
|
+
|---|---|---|
|
|
402
|
+
| `.env.<flavor>` (`.env.development`, `.env.staging`, ...) | `OTA_BASE_URL` | **Yes** — react-native-config bakes it into the APK/IPA |
|
|
403
|
+
| `.env.ota` | Upload secrets (`OTA_R2_*`) | No |
|
|
433
404
|
|
|
434
|
-
|
|
405
|
+
> [!CAUTION]
|
|
406
|
+
> Never put upload secrets in `.env.<flavor>`: react-native-config ships every key of that file inside the app binary.
|
|
435
407
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
408
|
+
The app and the build share `OTA_BASE_URL` from `.env.<flavor>`, so they can't drift apart:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
customServer: {
|
|
412
|
+
baseUrl: Config.OTA_BASE_URL!, // no fallback: OTA is disabled (red box in dev) if missing
|
|
413
|
+
channel: Config.ENVIRONMENT!,
|
|
414
|
+
},
|
|
440
415
|
```
|
|
441
416
|
|
|
442
|
-
|
|
417
|
+
Builds publish to **Cloudflare R2** when `.env.ota` sets `OTA_R2_BUCKET`, otherwise to a GitHub storage repository (auth via `gh auth token` / `GH_TOKEN` / `GITHUB_TOKEN`).
|
|
443
418
|
|
|
444
|
-
|
|
419
|
+
R2 setup: enable the bucket's Public Development URL (or a custom domain) and create an R2 API token with *Object Read & Write* on the bucket. Uploads use `curl --aws-sigv4`, no extra CLI. Bundles, patches and assets are uploaded once with an immutable cache header; `ota_registry.json` is uploaded last with `no-cache`.
|
|
445
420
|
|
|
446
421
|
```bash
|
|
447
|
-
|
|
448
|
-
|
|
422
|
+
# .env.<flavor>
|
|
423
|
+
OTA_BASE_URL=https://pub-xxxx.r2.dev
|
|
424
|
+
|
|
425
|
+
# .env.ota
|
|
426
|
+
OTA_R2_ACCOUNT_ID=...
|
|
427
|
+
OTA_R2_BUCKET=...
|
|
428
|
+
OTA_R2_ACCESS_KEY_ID=...
|
|
429
|
+
OTA_R2_SECRET_ACCESS_KEY=...
|
|
449
430
|
```
|
|
450
431
|
|
|
451
|
-
| Variable | Required | Default | Description |
|
|
452
|
-
|
|
453
|
-
| `OTA_BASE_URL` | Yes | - | `https://raw.githubusercontent.com/<owner>/<repo>/<branch>` |
|
|
454
|
-
| `OTA_FOLDER` | No | `ota` |
|
|
455
|
-
| `
|
|
432
|
+
| Variable | File | Required | Default | Description |
|
|
433
|
+
|----------|------|----------|---------|-------------|
|
|
434
|
+
| `OTA_BASE_URL` | `.env.<flavor>` | Yes | - | Public base URL (R2 public URL, or `https://raw.githubusercontent.com/<owner>/<repo>/<branch>`) |
|
|
435
|
+
| `OTA_FOLDER` | `.env.<flavor>` | No | `ota` | Folder prefix in storage; only set it to share one bucket/repo with other data (and set `customServer.baseFolder` to match) |
|
|
436
|
+
| `OTA_R2_*` | `.env.ota` | For R2 | - | `ACCOUNT_ID`, `BUCKET`, `ACCESS_KEY_ID`, `SECRET_ACCESS_KEY` |
|
|
437
|
+
| `OTA_PRIVATE_KEY` | shell env (CI) | No | - | PEM private key; locally keep `ota_private_key.pem` in the project root instead (`.env.ota` values are single-line) |
|
|
456
438
|
|
|
457
|
-
|
|
439
|
+
Keep both files out of git (`.env.*` in `.gitignore`).
|
|
458
440
|
|
|
459
441
|
---
|
|
460
442
|
|
|
@@ -506,31 +488,23 @@ On the client device:
|
|
|
506
488
|
|
|
507
489
|
> **Fail-Fast Safety:** The library never falls back silently to `"1.0.0"`. If native version information is missing or corrupted, it immediately throws a descriptive error to prevent version mismatches.
|
|
508
490
|
|
|
509
|
-
### 2. One-
|
|
510
|
-
When releasing a new native binary version (e.g. `1.0.3`)
|
|
491
|
+
### 2. One-Command Version Sync
|
|
492
|
+
When releasing a new native binary version (e.g. `1.0.3`):
|
|
511
493
|
|
|
512
494
|
```bash
|
|
513
|
-
|
|
495
|
+
npx react-native-bspatch version 1.0.3
|
|
514
496
|
```
|
|
515
497
|
|
|
516
|
-
This updates
|
|
517
|
-
1. `package.json` ➔ `"version": "1.0.3"`
|
|
518
|
-
2. `android/app/build.gradle` ➔ `versionName "1.0.3"` and **increments `versionCode`**
|
|
519
|
-
3. `ios/*.xcodeproj/project.pbxproj` ➔ `MARKETING_VERSION = 1.0.3` and **increments `CURRENT_PROJECT_VERSION`**
|
|
520
|
-
4. `src/ota/otaConfig.ts` (if present) ➔ `APP_VERSION = '1.0.3'`
|
|
498
|
+
This updates `package.json`, `android/app/build.gradle` (`versionName`, increments `versionCode`) and `ios/*.xcodeproj/project.pbxproj` (`MARKETING_VERSION`, increments `CURRENT_PROJECT_VERSION`).
|
|
521
499
|
|
|
522
|
-
### 3.
|
|
523
|
-
|
|
500
|
+
### 3. Release Version Confirmation
|
|
501
|
+
`patch android` reads `versionName`, `patch ios` reads `MARKETING_VERSION`, and asks for confirmation:
|
|
524
502
|
|
|
525
503
|
```text
|
|
526
|
-
|
|
527
|
-
Detected Native App Version: 1.0.2 (android/app/build.gradle)
|
|
528
|
-
Enter App Version to build [Press Enter for '1.0.2']:
|
|
529
|
-
────────────────────────────────────────────────────────
|
|
504
|
+
Release version for android [1.0.2]:
|
|
530
505
|
```
|
|
531
506
|
|
|
532
|
-
|
|
533
|
-
- **CI/CD Automation**: Pass `-y` / `--yes` or `--app-version <ver>` to skip the interactive prompt.
|
|
507
|
+
Press **Enter** to keep it or type another version. `-y` or `--release-version <x.y.z>` skip the prompt (CI).
|
|
534
508
|
|
|
535
509
|
---
|
|
536
510
|
|
|
@@ -540,43 +514,44 @@ When executing `tools/build_ota.sh`, the script auto-detects the version from `b
|
|
|
540
514
|
- **SIMD Vectorization**: The inner patching loop includes `#pragma clang loop vectorize(enable)` for Clang ARM NEON 128-bit vectorization.
|
|
541
515
|
- **64KB Streaming Buffers**: File hashing and stream copying are upgraded to 64KB chunk sizes across Android and iOS.
|
|
542
516
|
- **Disk Garbage Collection**: Calling `markHealthy()` automatically deletes obsolete bundle versions and dangling `.tmp` files from disk, keeping app storage minimal.
|
|
543
|
-
- **HTTP 304 ETag Caching**: Registry queries send `If-None-Match`. On `304 Not Modified`, cached patches are returned instantly with zero JSON parsing overhead.
|
|
544
517
|
|
|
545
518
|
## File Structure
|
|
546
519
|
|
|
547
520
|
```
|
|
548
|
-
|
|
549
|
-
├── index.ts # Public JS entry point
|
|
521
|
+
react-native-bspatch/
|
|
550
522
|
├── src/
|
|
551
|
-
│ ├──
|
|
523
|
+
│ ├── index.ts # Public JS entry point
|
|
524
|
+
│ ├── NativeBsPatch.ts # TurboModule spec
|
|
552
525
|
│ └── ota/
|
|
553
|
-
│ ├── otaCore.ts
|
|
554
|
-
│ ├── otaMetadata.ts
|
|
555
|
-
│ ├── otaLogger.ts
|
|
556
|
-
│ ├── otaAssetResolver.ts
|
|
557
|
-
│ ├── OtaProvider.tsx # React
|
|
558
|
-
│ ├── types.ts
|
|
526
|
+
│ ├── otaCore.ts # Pipeline: patch from installed OTA bundle, else full bundle
|
|
527
|
+
│ ├── otaMetadata.ts # Locked metadata updates, health marking, disk GC
|
|
528
|
+
│ ├── otaLogger.ts # Structured event telemetry
|
|
529
|
+
│ ├── otaAssetResolver.ts # Image source interceptor + asset store
|
|
530
|
+
│ ├── OtaProvider.tsx # React provider + update modes
|
|
531
|
+
│ ├── types.ts
|
|
559
532
|
│ └── storage/
|
|
560
|
-
│ └── GitHubRawStorage.ts
|
|
533
|
+
│ └── GitHubRawStorage.ts # Default provider: any static host (R2, GitHub raw, CDN)
|
|
561
534
|
├── android/src/main/java/vn/reactnativebspatch/
|
|
562
|
-
│ ├── BsPatchModule.kt
|
|
563
|
-
│ └── OtaBundleResolver.kt
|
|
535
|
+
│ ├── BsPatchModule.kt # applyPatch, verifySignature, reload
|
|
536
|
+
│ └── OtaBundleResolver.kt # Bundle resolution + crash rollback (before JS)
|
|
564
537
|
├── ios/
|
|
565
|
-
│ ├── RNBsPatch.mm
|
|
566
|
-
│ └── OtaBundleResolver.swift
|
|
567
|
-
├── cpp/
|
|
568
|
-
│ ├── CMakeLists.txt # CMake build for bspatch C library
|
|
569
|
-
│ └── bsdiff/ # bsdiff/bspatch source
|
|
538
|
+
│ ├── RNBsPatch.mm # applyPatch, verifySignature, reload
|
|
539
|
+
│ └── OtaBundleResolver.swift # Bundle resolution + crash rollback (before JS)
|
|
540
|
+
├── cpp/bsdiff/ # bspatch (C, mmap)
|
|
570
541
|
└── tools/
|
|
571
|
-
├── build_ota.sh
|
|
572
|
-
├──
|
|
573
|
-
|
|
542
|
+
├── build_ota.sh # CLI: patch / reset / setup / version
|
|
543
|
+
├── upload_ota.sh # Upload to Cloudflare R2 or a GitHub storage repo
|
|
544
|
+
├── update_registry.js # Writes ota_registry.json entries + signatures
|
|
545
|
+
├── setup_project.js # AppDelegate / MainApplication wiring
|
|
546
|
+
├── bump_version.js # Native version sync
|
|
547
|
+
└── generate_keys.js # RSA / EC signing keypair
|
|
574
548
|
```
|
|
575
549
|
|
|
576
550
|
---
|
|
577
551
|
|
|
578
552
|
## Known Limitations
|
|
579
553
|
|
|
580
|
-
-
|
|
581
|
-
-
|
|
554
|
+
- The first OTA after a store install, and clients more than 5 OTA versions behind, download the full bundle (patches are only built from the last 5 OTA versions)
|
|
555
|
+
- Health is confirmed when the app first renders; a crash after that is not rolled back
|
|
556
|
+
- OTA version numbers are shared by both platforms of a flavor, so `patch android` then `patch ios` gives iOS the next number
|
|
582
557
|
- Asset redirection via `setCustomSourceTransformer` requires React Native **0.69+**
|
|
@@ -8,9 +8,7 @@ import com.facebook.react.bridge.Callback
|
|
|
8
8
|
import com.facebook.react.bridge.Promise
|
|
9
9
|
import java.io.File
|
|
10
10
|
import java.io.FileInputStream
|
|
11
|
-
import java.io.FileOutputStream
|
|
12
11
|
import java.io.BufferedInputStream
|
|
13
|
-
import java.io.BufferedOutputStream
|
|
14
12
|
import java.util.concurrent.Executors
|
|
15
13
|
|
|
16
14
|
class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeBsPatchSpec(reactContext) {
|
|
@@ -42,29 +40,6 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
|
|
|
42
40
|
}
|
|
43
41
|
}
|
|
44
42
|
|
|
45
|
-
/**
|
|
46
|
-
* Extracts the JS bundle from APK assets using Android AssetManager.
|
|
47
|
-
* Guarantees byte-exact copy — RNFS.copyFileAssets can produce different
|
|
48
|
-
* bytes in certain APK configurations.
|
|
49
|
-
*/
|
|
50
|
-
override fun extractBundleFromAssets(assetName: String, destPath: String, promise: Promise) {
|
|
51
|
-
backgroundExecutor.execute {
|
|
52
|
-
try {
|
|
53
|
-
val assetManager = reactContext.assets
|
|
54
|
-
assetManager.open(assetName).use { input ->
|
|
55
|
-
val destFile = File(destPath)
|
|
56
|
-
destFile.parentFile?.mkdirs()
|
|
57
|
-
BufferedOutputStream(FileOutputStream(destFile), IO_BUFFER_SIZE).use { output ->
|
|
58
|
-
input.copyTo(output, bufferSize = IO_BUFFER_SIZE)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
promise.resolve(true)
|
|
62
|
-
} catch (e: Exception) {
|
|
63
|
-
promise.reject("ERR_EXTRACT_ASSET", e.message, e)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
43
|
override fun markAsHealthy() {
|
|
69
44
|
OtaBundleResolver.markAsHealthy(reactContext)
|
|
70
45
|
}
|
|
@@ -19,8 +19,9 @@ import java.io.File
|
|
|
19
19
|
* 2. If lastLaunchSuccessful == false → rollback to previous version
|
|
20
20
|
* 3. Mark current launch as pending (lastLaunchSuccessful = false)
|
|
21
21
|
* 4. Return the bundle path (or null for APK default)
|
|
22
|
-
* 5. JS
|
|
23
|
-
* 6. If crash before
|
|
22
|
+
* 5. JS marks healthy after first render → lastLaunchSuccessful = true, verifiedVersion = current
|
|
23
|
+
* 6. If crash before that → next boot sees false → rollback
|
|
24
|
+
* 7. A verified version is never marked pending again (later crashes/kills don't roll it back)
|
|
24
25
|
*
|
|
25
26
|
* Usage in MainApplication.kt:
|
|
26
27
|
* ```
|
|
@@ -70,6 +71,8 @@ object OtaBundleResolver {
|
|
|
70
71
|
val currentVersion = metadata.optInt("currentVersion", baseVersion)
|
|
71
72
|
val lastLaunchOk = metadata.optBoolean("lastLaunchSuccessful", true)
|
|
72
73
|
val rollbackCount = metadata.optInt("rollbackCount", 0)
|
|
74
|
+
// Verified = launched and rendered before; never pending, never rolled back
|
|
75
|
+
val verifiedVersion = metadata.optInt("verifiedVersion", -1)
|
|
73
76
|
|
|
74
77
|
// ── Step 1: Crash detection & rollback ──────────────────────────
|
|
75
78
|
if (!lastLaunchOk && currentVersion > baseVersion) {
|
|
@@ -80,6 +83,7 @@ object OtaBundleResolver {
|
|
|
80
83
|
metadata = performRollback(metadata, baseVersion, rollbackCount, otaDir)
|
|
81
84
|
metadata.put("failedVersions", failed)
|
|
82
85
|
metadata.put("appVersion", otaAppVersion)
|
|
86
|
+
if (verifiedVersion >= 0) metadata.put("verifiedVersion", verifiedVersion)
|
|
83
87
|
writeMetadata(metadataFile, metadata)
|
|
84
88
|
|
|
85
89
|
val newVersion = metadata.optInt("currentVersion", baseVersion)
|
|
@@ -98,8 +102,10 @@ object OtaBundleResolver {
|
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
// Mark this rollback attempt as pending
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
if (newVersion != verifiedVersion) {
|
|
106
|
+
metadata.put("lastLaunchSuccessful", false)
|
|
107
|
+
writeMetadata(metadataFile, metadata)
|
|
108
|
+
}
|
|
103
109
|
|
|
104
110
|
Log.i(TAG, "Trying rollback bundle: ${rollbackBundle.absolutePath}")
|
|
105
111
|
return rollbackBundle.absolutePath
|
|
@@ -122,8 +128,10 @@ object OtaBundleResolver {
|
|
|
122
128
|
|
|
123
129
|
// Mark launch as pending — if Hermes crashes loading this bundle,
|
|
124
130
|
// the next boot will see false and trigger rollback
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
if (currentVersion != verifiedVersion) {
|
|
132
|
+
metadata.put("lastLaunchSuccessful", false)
|
|
133
|
+
writeMetadata(metadataFile, metadata)
|
|
134
|
+
}
|
|
127
135
|
|
|
128
136
|
Log.i(TAG, "Loading OTA bundle: ${bundleFile.absolutePath}")
|
|
129
137
|
bundleFile.absolutePath
|
|
@@ -13,8 +13,9 @@ import Foundation
|
|
|
13
13
|
* 2. If lastLaunchSuccessful == false → rollback to previous version
|
|
14
14
|
* 3. Mark current launch as pending (lastLaunchSuccessful = false)
|
|
15
15
|
* 4. Return the bundle path (or nil for IPA default)
|
|
16
|
-
* 5. JS
|
|
17
|
-
* 6. If crash before
|
|
16
|
+
* 5. JS marks healthy after first render → lastLaunchSuccessful = true, verifiedVersion = current
|
|
17
|
+
* 6. If crash before that → next boot sees false → rollback
|
|
18
|
+
* 7. A verified version is never marked pending again (later crashes/kills don't roll it back)
|
|
18
19
|
*
|
|
19
20
|
* Usage in AppDelegate.swift:
|
|
20
21
|
* ```swift
|
|
@@ -76,6 +77,8 @@ public class OtaBundleResolver: NSObject {
|
|
|
76
77
|
let currentVersion = metadata["currentVersion"] as? Int ?? baseVersion
|
|
77
78
|
let lastLaunchOk = metadata["lastLaunchSuccessful"] as? Bool ?? true
|
|
78
79
|
let rollbackCount = metadata["rollbackCount"] as? Int ?? 0
|
|
80
|
+
// Verified = launched and rendered before; never pending, never rolled back
|
|
81
|
+
let verifiedVersion = metadata["verifiedVersion"] as? Int
|
|
79
82
|
|
|
80
83
|
// ── Step 1: Crash detection & rollback ──────────────────────────
|
|
81
84
|
if !lastLaunchOk && currentVersion > baseVersion {
|
|
@@ -86,6 +89,7 @@ public class OtaBundleResolver: NSObject {
|
|
|
86
89
|
metadata = performRollback(metadata: metadata, baseVersion: baseVersion, currentRollbackCount: rollbackCount, otaDir: otaDir)
|
|
87
90
|
metadata["failedVersions"] = failed
|
|
88
91
|
metadata["appVersion"] = otaAppVersion
|
|
92
|
+
metadata["verifiedVersion"] = verifiedVersion
|
|
89
93
|
writeMetadata(url: metadataURL, metadata: metadata)
|
|
90
94
|
|
|
91
95
|
let newVersion = metadata["currentVersion"] as? Int ?? baseVersion
|
|
@@ -103,8 +107,10 @@ public class OtaBundleResolver: NSObject {
|
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
// Mark this rollback attempt as pending
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
if newVersion != verifiedVersion {
|
|
111
|
+
metadata["lastLaunchSuccessful"] = false
|
|
112
|
+
writeMetadata(url: metadataURL, metadata: metadata)
|
|
113
|
+
}
|
|
108
114
|
|
|
109
115
|
NSLog("[OtaBundleResolver] Trying rollback bundle: \(rollbackBundle.path)")
|
|
110
116
|
return rollbackBundle
|
|
@@ -126,8 +132,10 @@ public class OtaBundleResolver: NSObject {
|
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
// Mark launch as pending
|
|
129
|
-
|
|
130
|
-
|
|
135
|
+
if currentVersion != verifiedVersion {
|
|
136
|
+
metadata["lastLaunchSuccessful"] = false
|
|
137
|
+
writeMetadata(url: metadataURL, metadata: metadata)
|
|
138
|
+
}
|
|
131
139
|
|
|
132
140
|
NSLog("[OtaBundleResolver] Loading OTA bundle: \(bundleURL.path)")
|
|
133
141
|
return bundleURL
|
package/ios/RNBsPatch.mm
CHANGED
|
@@ -57,33 +57,6 @@ RCT_EXPORT_METHOD(applyPatch:(NSString *)oldPath
|
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
RCT_EXPORT_METHOD(extractBundleFromAssets:(NSString *)assetName
|
|
61
|
-
destPath:(NSString *)destPath
|
|
62
|
-
resolve:(RCTPromiseResolveBlock)resolve
|
|
63
|
-
reject:(RCTPromiseRejectBlock)reject)
|
|
64
|
-
{
|
|
65
|
-
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
|
66
|
-
NSString *resourceName = [assetName stringByDeletingPathExtension];
|
|
67
|
-
NSString *resourceType = [assetName pathExtension];
|
|
68
|
-
if (resourceType.length == 0) resourceType = @"jsbundle";
|
|
69
|
-
|
|
70
|
-
NSString *bundlePath = [[NSBundle mainBundle] pathForResource:resourceName ofType:resourceType];
|
|
71
|
-
if (!bundlePath) {
|
|
72
|
-
reject(@"ERR_EXTRACT_ASSET", [NSString stringWithFormat:@"%@ not found in main bundle", assetName], nil);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
NSError *error = nil;
|
|
76
|
-
// copyItemAtPath fails if the destination exists (base.hbc is re-extracted on every update)
|
|
77
|
-
[[NSFileManager defaultManager] removeItemAtPath:destPath error:nil];
|
|
78
|
-
BOOL success = [[NSFileManager defaultManager] copyItemAtPath:bundlePath toPath:destPath error:&error];
|
|
79
|
-
if (success) {
|
|
80
|
-
resolve(@(YES));
|
|
81
|
-
} else {
|
|
82
|
-
reject(@"ERR_EXTRACT_ASSET", error.localizedDescription, error);
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
60
|
RCT_EXPORT_METHOD(reloadBundle)
|
|
88
61
|
{
|
|
89
62
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
@@ -125,7 +98,7 @@ static NSData *RNBsPatchKeyFromSPKI(NSData *spki, BOOL *isRSA)
|
|
|
125
98
|
{
|
|
126
99
|
static const uint8_t rsaOID[] = {0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01};
|
|
127
100
|
static const uint8_t ecOID[] = {0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01};
|
|
128
|
-
const uint8_t *b = spki.bytes;
|
|
101
|
+
const uint8_t *b = (const uint8_t *)spki.bytes;
|
|
129
102
|
NSUInteger len = spki.length, pos = 0, inner;
|
|
130
103
|
uint8_t tag;
|
|
131
104
|
NSRange outer, algo, oid, bits;
|