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.
Files changed (52) hide show
  1. package/README.md +126 -151
  2. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +0 -25
  3. package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +14 -6
  4. package/ios/OtaBundleResolver.swift +14 -6
  5. package/ios/RNBsPatch.mm +1 -28
  6. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  7. package/lib/commonjs/ota/OtaProvider.js +29 -16
  8. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  9. package/lib/commonjs/ota/otaAssetResolver.js +0 -20
  10. package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
  11. package/lib/commonjs/ota/otaCore.js +200 -330
  12. package/lib/commonjs/ota/otaCore.js.map +1 -1
  13. package/lib/commonjs/ota/otaMetadata.js +21 -21
  14. package/lib/commonjs/ota/otaMetadata.js.map +1 -1
  15. package/lib/commonjs/ota/storage/GitHubRawStorage.js +13 -15
  16. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
  17. package/lib/commonjs/ota/types.js.map +1 -1
  18. package/lib/module/NativeBsPatch.js.map +1 -1
  19. package/lib/module/ota/OtaProvider.js +30 -17
  20. package/lib/module/ota/OtaProvider.js.map +1 -1
  21. package/lib/module/ota/otaAssetResolver.js +1 -21
  22. package/lib/module/ota/otaAssetResolver.js.map +1 -1
  23. package/lib/module/ota/otaCore.js +197 -329
  24. package/lib/module/ota/otaCore.js.map +1 -1
  25. package/lib/module/ota/otaMetadata.js +19 -19
  26. package/lib/module/ota/otaMetadata.js.map +1 -1
  27. package/lib/module/ota/storage/GitHubRawStorage.js +13 -15
  28. package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
  29. package/lib/module/ota/types.js.map +1 -1
  30. package/lib/typescript/NativeBsPatch.d.ts +0 -1
  31. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  32. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  33. package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -1
  34. package/lib/typescript/ota/otaCore.d.ts +13 -3
  35. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  36. package/lib/typescript/ota/otaMetadata.d.ts +9 -7
  37. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
  38. package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
  39. package/lib/typescript/ota/types.d.ts +5 -19
  40. package/lib/typescript/ota/types.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/NativeBsPatch.ts +0 -5
  43. package/src/ota/OtaProvider.tsx +30 -23
  44. package/src/ota/otaAssetResolver.ts +1 -17
  45. package/src/ota/otaCore.ts +203 -457
  46. package/src/ota/otaMetadata.ts +30 -20
  47. package/src/ota/storage/GitHubRawStorage.ts +13 -21
  48. package/src/ota/types.ts +5 -20
  49. package/tools/build_ota.sh +206 -516
  50. package/tools/setup_project.js +17 -115
  51. package/tools/update_registry.js +31 -32
  52. 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** — only download the diff between old and new bundle (typically 5–30% of full bundle size)
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
- - **Interactive Build Verification** — CLI automatically prompts and confirms detected native version before generating releases
14
- - **One-Command Version Synchronization** — `npm run bump-version <version>` syncs `package.json`, Android `build.gradle`, and iOS Xcode project
15
- - **Full-bundle fallback** — if patch fails, automatically downloads the complete bundle
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-version and redirected via `setCustomSourceTransformer`
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 — `react-native-bspatch` and its `SSZipArchive` dependency are auto-linked via CocoaPods.
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: 'https://raw.githubusercontent.com/<owner>/<storage-repo>/main',
74
- channel: 'production',
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 GitHubRawStorageProvider. */
173
+ /** Custom storage provider. Omit to use the default static-host provider. */
182
174
  storage?: OtaStorageProvider;
183
175
 
184
- /** Static storage layout read by GITHUB_RAW (public, read-only — no credentials) */
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": "v1-to-v2",
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": 2,
248
- "baseHash": "sha256_of_v1_bundle",
249
- "bundleHash": "sha256_of_v2_bundle",
240
+ "toVersion": 3,
241
+ "bundleHash": "sha256_of_v3_bundle",
250
242
  "platform": "android",
251
- "mandatory": false,
252
- "fullBundleUrl": "v2-full.zip"
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
- > **Important:** `baseHash` must be the exact SHA-256 of the bundle that users currently have installed (after OTA patching, not just the APK bundle). The most reliable way to get this is to record it **after** each successful patch application, store it server-side, and reference it as `baseHash` for the next patch in the chain.
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. Mark lastLaunchSuccessful = false (will reset to true when JS calls markAsHealthy)
278
- 6. JS boots → runs bootstrap → calls markAsHealthy()
279
- 7. If JS never calls markAsHealthy() (crash) → next boot sees false → rolls back
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 library ships with `GitHubRawStorageProvider` (default). To use a different backend (e.g. Cloudflare), implement the interface:
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
- ### Project Scripts
350
-
351
- Add these to your `package.json` (or use the existing ones in this project):
354
+ ### Commands
352
355
 
353
- ```json
354
- {
355
- "scripts": {
356
- "react-native-bspatch": "bash ./src/native_modules/react-native-bspatch/tools/build_ota.sh",
357
- "ota:setup": "yarn react-native-bspatch setup",
358
- "ota:init": "yarn react-native-bspatch base",
359
- "ota:seed": "yarn react-native-bspatch seed",
360
- "ota:build": "yarn react-native-bspatch",
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
- ### Build Commands
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
- | Command | Action |
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` generates Hermes bytecode (`.hbc`). iOS base build uses the Xcode-compiled bundle to guarantee byte-exact match with the IPA.
408
- 2. **Assets** — Compares against `base-assets_{platform}_{flavor}.json` (saved at `ota:init`). Unchanged assets are skipped; changed/new assets are copied to `asset-store/` and included in the per-version manifest.
409
- 3. **Patch** — `bsdiff` generates a binary delta from the previous bundle to the new one. The diff is typically 5–30% the size of a full bundle.
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. Add the npm scripts to package.json (or run directly)
417
- bash ./src/native_modules/react-native-bspatch/tools/build_ota.sh setup
386
+ # 1. Patch AppDelegate.swift / MainApplication.kt
387
+ npx react-native-bspatch setup
418
388
 
419
- # 2. Build your app normally for App Store / Google Play
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 subsequent release:
425
- yarn ota:build
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
- Builds publish to a GitHub storage repository. Authentication uses `gh auth token` (or `GH_TOKEN` / `GITHUB_TOKEN`), so run `gh auth login` once.
398
+ The build reads two files from the project root:
431
399
 
432
- **Option 1: `.env` file (recommended)**
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
- Create a `.env` file in your **React Native project root** (not in the module directory):
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
- ```bash
437
- # In your project root (e.g., /path/to/YourApp/)
438
- OTA_BASE_URL=https://raw.githubusercontent.com/<owner>/<storage-repo>/main
439
- OTA_FOLDER=ota
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
- The build script automatically loads `.env` from the project root.
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
- **Option 2: Export environment variables**
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
- export OTA_BASE_URL="https://raw.githubusercontent.com/<owner>/<storage-repo>/main"
448
- yarn ota:build
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` | Base folder in the storage repo |
455
- | `OTA_PRIVATE_KEY` | No | - | PEM private key (or `ota_private_key.pem` in project root) |
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
- > **Security Warning:** Never commit credentials to source control. The `.env` file is gitignored.
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-Touch Version Synchronization Tool
510
- When releasing a new native binary version (e.g. `1.0.3`), synchronize all project files with a single command:
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
- node ./node_modules/react-native-bspatch/tools/bump_version.js 1.0.3
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. Interactive CLI Confirmation
523
- When executing `tools/build_ota.sh`, the script auto-detects the version from `build.gradle` / Xcode / `package.json` and prompts for confirmation in the terminal:
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
- - **Interactive**: Press **Enter** to proceed with the detected version, or type a custom version.
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
- src/native_modules/react-native-bspatch/
549
- ├── index.ts # Public JS entry point
521
+ react-native-bspatch/
550
522
  ├── src/
551
- │ ├── NativeBsPatch.ts # TurboModule TypeScript wrapper
523
+ │ ├── index.ts # Public JS entry point
524
+ │ ├── NativeBsPatch.ts # TurboModule spec
552
525
  │ └── ota/
553
- │ ├── otaCore.ts # Core pipeline: download → patch → fallback
554
- │ ├── otaMetadata.ts # Metadata load/save with race-condition guard
555
- │ ├── otaLogger.ts # Structured event telemetry
556
- │ ├── otaAssetResolver.ts # Image source interceptor + asset store
557
- │ ├── OtaProvider.tsx # React Context provider + auto-update logic
558
- │ ├── types.ts # All TypeScript type definitions
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 # GitHub raw repository storage (default)
533
+ │ └── GitHubRawStorage.ts # Default provider: any static host (R2, GitHub raw, CDN)
561
534
  ├── android/src/main/java/vn/reactnativebspatch/
562
- │ ├── BsPatchModule.kt # Native: applyPatch, extract, verifySignature
563
- │ └── OtaBundleResolver.kt # Native: bundle resolution + rollback
535
+ │ ├── BsPatchModule.kt # applyPatch, verifySignature, reload
536
+ │ └── OtaBundleResolver.kt # Bundle resolution + crash rollback (before JS)
564
537
  ├── ios/
565
- │ ├── RNBsPatch.mm # Native iOS bridge + signature verify
566
- │ └── OtaBundleResolver.swift # Native iOS bundle resolution + rollback
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 # Main build pipeline: bundle → patch → upload
572
- ├── setup_project.js # Idempotent project integration injector
573
- └── update_registry.js # Updates ota_registry.json after each build
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
- - `BACKGROUND_DOWNLOAD` mode is a stub — auto-update currently works in `MANUAL`, `ON_APP_START`, and `ON_APP_FOREGROUND` modes only
581
- - Patch chain must be **sequential** (e.g. V1→V2→V3). The library resolves the best available direct patch for your current version; it does not chain multiple patches in a single update
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 calls markAsHealthy() → sets lastLaunchSuccessful = true
23
- * 6. If crash before markAsHealthy → next boot sees false → rollback
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
- metadata.put("lastLaunchSuccessful", false)
102
- writeMetadata(metadataFile, metadata)
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
- metadata.put("lastLaunchSuccessful", false)
126
- writeMetadata(metadataFile, metadata)
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 calls markAsHealthy() → sets lastLaunchSuccessful = true
17
- * 6. If crash before markAsHealthy → next boot sees false → rollback
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
- metadata["lastLaunchSuccessful"] = false
107
- writeMetadata(url: metadataURL, metadata: metadata)
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
- metadata["lastLaunchSuccessful"] = false
130
- writeMetadata(url: metadataURL, metadata: metadata)
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;