react-native-bspatch 4.0.3 → 5.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 +87 -30
- package/android/build.gradle +25 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +86 -6
- package/android/src/main/res/values/bspatch_public_key.xml +5 -0
- package/cpp/bsdiff/bspatch_wrapper.c +34 -0
- package/cpp/bsdiff/bspatch_wrapper.h +2 -0
- package/cpp/bspatch_jni.cpp +18 -0
- package/ios/RNBsPatch.mm +142 -1
- package/lib/commonjs/NativeBsPatch.js.map +1 -1
- package/lib/commonjs/index.js +9 -2
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ota/OtaProvider.js +124 -32
- package/lib/commonjs/ota/OtaProvider.js.map +1 -1
- package/lib/commonjs/ota/eligibility.js +40 -0
- package/lib/commonjs/ota/eligibility.js.map +1 -0
- package/lib/commonjs/ota/entrySignature.js +23 -0
- package/lib/commonjs/ota/entrySignature.js.map +1 -0
- package/lib/commonjs/ota/installId.js +27 -0
- package/lib/commonjs/ota/installId.js.map +1 -0
- package/lib/commonjs/ota/otaCore.js +96 -21
- package/lib/commonjs/ota/otaCore.js.map +1 -1
- package/lib/commonjs/ota/storage/{GitHubRawStorage.js → R2Storage.js} +20 -15
- package/lib/commonjs/ota/storage/R2Storage.js.map +1 -0
- package/lib/commonjs/ota/types.js.map +1 -1
- package/lib/module/NativeBsPatch.js.map +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/ota/OtaProvider.js +124 -32
- package/lib/module/ota/OtaProvider.js.map +1 -1
- package/lib/module/ota/eligibility.js +34 -0
- package/lib/module/ota/eligibility.js.map +1 -0
- package/lib/module/ota/entrySignature.js +17 -0
- package/lib/module/ota/entrySignature.js.map +1 -0
- package/lib/module/ota/installId.js +22 -0
- package/lib/module/ota/installId.js.map +1 -0
- package/lib/module/ota/otaCore.js +96 -21
- package/lib/module/ota/otaCore.js.map +1 -1
- package/lib/module/ota/storage/{GitHubRawStorage.js → R2Storage.js} +18 -14
- package/lib/module/ota/storage/R2Storage.js.map +1 -0
- package/lib/module/ota/types.js.map +1 -1
- package/lib/typescript/NativeBsPatch.d.ts +28 -2
- package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +3 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
- package/lib/typescript/ota/eligibility.d.ts +19 -0
- package/lib/typescript/ota/eligibility.d.ts.map +1 -0
- package/lib/typescript/ota/entrySignature.d.ts +9 -0
- package/lib/typescript/ota/entrySignature.d.ts.map +1 -0
- package/lib/typescript/ota/installId.d.ts +3 -0
- package/lib/typescript/ota/installId.d.ts.map +1 -0
- package/lib/typescript/ota/otaCore.d.ts.map +1 -1
- package/lib/typescript/ota/storage/{GitHubRawStorage.d.ts → R2Storage.d.ts} +4 -3
- package/lib/typescript/ota/storage/R2Storage.d.ts.map +1 -0
- package/lib/typescript/ota/types.d.ts +54 -5
- package/lib/typescript/ota/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/react-native-bspatch.podspec +3 -1
- package/src/NativeBsPatch.ts +34 -2
- package/src/index.ts +5 -1
- package/src/ota/OtaProvider.tsx +128 -43
- package/src/ota/eligibility.ts +48 -0
- package/src/ota/entrySignature.ts +31 -0
- package/src/ota/installId.ts +18 -0
- package/src/ota/otaCore.ts +102 -21
- package/src/ota/storage/{GitHubRawStorage.ts → R2Storage.ts} +17 -13
- package/src/ota/types.ts +51 -5
- package/tools/build_ota.sh +343 -116
- package/tools/doctor.js +130 -0
- package/tools/ensure_signing_key.js +216 -0
- package/tools/fingerprint.js +111 -0
- package/tools/ios_fingerprint.sh +9 -0
- package/tools/ota_admin.js +131 -0
- package/tools/setup_project.js +41 -15
- package/tools/update_registry.js +111 -11
- package/tools/upload_ota.sh +85 -184
- package/tools/verify_publish.js +81 -0
- package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +0 -1
- package/lib/module/ota/storage/GitHubRawStorage.js.map +0 -1
- package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Premium OTA update solution for React Native using **TurboModules** + **bsdiff b
|
|
|
7
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
|
-
- **Silent Background Updates** — download and apply updates completely in the background;
|
|
10
|
+
- **Silent Background Updates** — download and apply updates completely in the background; activates on next cold start or when you call `reloadBundle()`
|
|
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
13
|
- **Shorebird-style CLI** — `patch android|ios`, release version read from the native project and confirmed with one Enter
|
|
@@ -70,7 +70,7 @@ 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: Config.OTA_BASE_URL!, // public
|
|
73
|
+
baseUrl: Config.OTA_BASE_URL!, // R2 public URL, no fallback
|
|
74
74
|
channel: Config.ENVIRONMENT!, // flavor: development / staging / production
|
|
75
75
|
},
|
|
76
76
|
};
|
|
@@ -147,16 +147,20 @@ type OtaConfig = {
|
|
|
147
147
|
* - MANUAL: call checkUpdate() / applyUpdate() manually
|
|
148
148
|
* - ON_APP_START: check and apply automatically on cold start
|
|
149
149
|
* - ON_APP_FOREGROUND: check when app returns to foreground
|
|
150
|
-
* - BACKGROUND_DOWNLOAD: download and patch in background; applies on next
|
|
150
|
+
* - BACKGROUND_DOWNLOAD: download and patch in background; applies on next cold start or reloadBundle()
|
|
151
151
|
*/
|
|
152
152
|
updateMode?: OtaUpdateMode;
|
|
153
153
|
|
|
154
|
-
/** Network
|
|
154
|
+
/** Network settings */
|
|
155
155
|
network?: {
|
|
156
|
-
retry?: number; //
|
|
157
|
-
timeoutMs?: number; //
|
|
156
|
+
retry?: number; // attempts per file (default: 3); each retry resumes the partial download
|
|
157
|
+
timeoutMs?: number; // idle timeout in ms (default: 30000)
|
|
158
|
+
wifiOnly?: boolean; // automatic downloads only on Wi-Fi/Ethernet (applyUpdate() always downloads)
|
|
158
159
|
};
|
|
159
160
|
|
|
161
|
+
/** Minimum time between automatic checks (start, foreground, checkUpdate()). Default 10 min. */
|
|
162
|
+
minCheckIntervalMs?: number;
|
|
163
|
+
|
|
160
164
|
/**
|
|
161
165
|
* Optional telemetry callback — all OTA lifecycle events are forwarded here.
|
|
162
166
|
* Use it to pipe events to your analytics service.
|
|
@@ -164,9 +168,15 @@ type OtaConfig = {
|
|
|
164
168
|
onEvent?: (entry: OtaLogEntry) => void;
|
|
165
169
|
|
|
166
170
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
171
|
+
* POST every event as JSON to `url` (fire-and-forget), with installId, platform,
|
|
172
|
+
* appVersion, appBuild, runtimeFingerprint, flavor and the running OTA version attached.
|
|
173
|
+
* A Cloudflare Worker writing to D1/Analytics Engine is enough for a dashboard.
|
|
174
|
+
*/
|
|
175
|
+
telemetry?: { url: string; headers?: Record<string, string> };
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* SPKI public key(s), PEM or base64 (comma-separated for several). Defaults to the
|
|
179
|
+
* signing + backup keys `patch` embedded in the app: leave it unset.
|
|
170
180
|
*/
|
|
171
181
|
publicKey?: string;
|
|
172
182
|
|
|
@@ -178,9 +188,10 @@ type OtaConfig = {
|
|
|
178
188
|
* `storage` is set; invalid values disable OTA (red box in __DEV__), no fallback.
|
|
179
189
|
*/
|
|
180
190
|
customServer?: {
|
|
181
|
-
baseUrl: string; // R2 public URL
|
|
191
|
+
baseUrl: string; // R2 public URL (r2.dev or custom domain)
|
|
182
192
|
channel: string; // flavor
|
|
183
193
|
baseFolder?: string; // default: 'ota'
|
|
194
|
+
headers?: Record<string, string>; // sent with every request (e.g. auth for a private server)
|
|
184
195
|
};
|
|
185
196
|
};
|
|
186
197
|
```
|
|
@@ -192,7 +203,7 @@ enum OtaUpdateMode {
|
|
|
192
203
|
MANUAL = 'MANUAL', // No auto behavior
|
|
193
204
|
ON_APP_START = 'ON_APP_START', // Check + apply on every cold start
|
|
194
205
|
ON_APP_FOREGROUND = 'ON_APP_FOREGROUND', // Check when app returns to foreground
|
|
195
|
-
BACKGROUND_DOWNLOAD = 'BACKGROUND_DOWNLOAD', // Silent download; applies on next start or
|
|
206
|
+
BACKGROUND_DOWNLOAD = 'BACKGROUND_DOWNLOAD', // Silent download; applies on next cold start or reloadBundle()
|
|
196
207
|
}
|
|
197
208
|
```
|
|
198
209
|
|
|
@@ -240,7 +251,13 @@ Your OTA server must expose a `ota_registry.json` at the configured path (e.g. `
|
|
|
240
251
|
"toVersion": 3,
|
|
241
252
|
"bundleHash": "sha256_of_v3_bundle",
|
|
242
253
|
"platform": "android",
|
|
243
|
-
"fullBundleUrl": "bundle_1.0.0_v3_android_production.hbc"
|
|
254
|
+
"fullBundleUrl": "bundle_1.0.0_v3_android_production.hbc",
|
|
255
|
+
"bundleSize": 8558054,
|
|
256
|
+
"assetManifestUrl": "asset-manifest_1.0.0_v3_android_production.json",
|
|
257
|
+
"assetManifestHash": "sha256_of_the_asset_manifest",
|
|
258
|
+
"flavor": "production",
|
|
259
|
+
"bundleSignature": "base64…",
|
|
260
|
+
"entrySignature": "base64…"
|
|
244
261
|
},
|
|
245
262
|
{
|
|
246
263
|
"id": "v2-to-v3-production-android",
|
|
@@ -261,6 +278,8 @@ Your OTA server must expose a `ota_registry.json` at the configured path (e.g. `
|
|
|
261
278
|
}
|
|
262
279
|
```
|
|
263
280
|
|
|
281
|
+
Every version also gets `release_<app>_v<N>_<platform>_<flavor>.json` next to its bundle: who published it and when, release notes (`-m`), git commit/branch/dirty, bundle (raw + gzip), patch and asset sizes, and the signing key id. Clients never read it; it's there for a dashboard. It is public like everything in the bucket, so keep notes free of secrets.
|
|
282
|
+
|
|
264
283
|
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.
|
|
265
284
|
|
|
266
285
|
---
|
|
@@ -291,7 +310,7 @@ This means crash protection is **native-side and zero-cost** — it doesn't requ
|
|
|
291
310
|
|
|
292
311
|
## Custom Storage Provider
|
|
293
312
|
|
|
294
|
-
The default provider (`GitHubRawStorageProvider`) reads
|
|
313
|
+
The default provider (`R2StorageProvider`, formerly `GitHubRawStorageProvider`) reads the bucket's public URL with the `customServer` layout. Implement the interface only for a backend that needs something else (signed URLs, an API):
|
|
295
314
|
|
|
296
315
|
```ts
|
|
297
316
|
interface OtaStorageProvider {
|
|
@@ -325,10 +344,13 @@ const {
|
|
|
325
344
|
isChecking, // True while fetching manifest
|
|
326
345
|
metrics, // OtaMetrics | null — timing/size data after update
|
|
327
346
|
isUpdateReady, // True if background update is downloaded and ready
|
|
347
|
+
isMandatory, // The ready update is mandatory: it reloads as soon as the app is in the foreground
|
|
348
|
+
installId, // Stable random ID of this install (rollout bucket, QA lists, telemetry)
|
|
328
349
|
applyUpdate, // () => Promise<void> — download and apply update immediately
|
|
350
|
+
applyVersion, // (version) => Promise<void> — download and apply a specific version from the manifest
|
|
329
351
|
resetToFactory, // () => Promise<void> — wipe OTA state, reload APK/IPA
|
|
330
352
|
markAsHealthy, // () => Promise<void> — manually mark launch OK
|
|
331
|
-
checkUpdate, // () => Promise<void> —
|
|
353
|
+
checkUpdate, // ({ force }?) => Promise<void> — check now (throttled by minCheckIntervalMs unless force)
|
|
332
354
|
reloadBundle, // () => void — immediately reload JS bundle to apply ready update
|
|
333
355
|
} = useOta();
|
|
334
356
|
```
|
|
@@ -355,31 +377,53 @@ The library ships with an **automated build pipeline** (`tools/build_ota.sh`) th
|
|
|
355
377
|
### Commands
|
|
356
378
|
|
|
357
379
|
```bash
|
|
358
|
-
npx react-native-bspatch setup # once: wire OtaBundleResolver into the app
|
|
359
380
|
npx react-native-bspatch version 1.0.5 # set native version (package.json, Gradle, Xcode)
|
|
360
|
-
#
|
|
381
|
+
npx react-native-bspatch patch # first run also creates the signing keys and wires the native project
|
|
382
|
+
# … build + release 1.0.5 to the stores (this is OTA v1) …
|
|
361
383
|
npx react-native-bspatch patch android # build + publish the next OTA
|
|
362
|
-
npx react-native-bspatch patch ios --flavor staging
|
|
363
|
-
npx react-native-bspatch patch
|
|
384
|
+
npx react-native-bspatch patch ios --flavor staging -m "fix checkout"
|
|
385
|
+
npx react-native-bspatch patch --dry-run # build everything, upload nothing
|
|
386
|
+
|
|
387
|
+
npx react-native-bspatch status --flavor production # what's live, who published it, controls
|
|
388
|
+
npx react-native-bspatch doctor # check storage URL, keys, native wiring, fingerprint
|
|
389
|
+
npx react-native-bspatch rollout 7 20 --flavor production # v7 to 20% of installs (100 = everyone)
|
|
390
|
+
npx react-native-bspatch disable 7 # kill switch (enable 7 undoes it)
|
|
391
|
+
npx react-native-bspatch mandatory 7 # apply v7 as soon as it's downloaded (--off undoes it)
|
|
392
|
+
npx react-native-bspatch qa 7 --ids <installId>,… # v7 only for these installs (--off undoes it)
|
|
393
|
+
npx react-native-bspatch promote --from staging --flavor production # ship staging's bundle, no build
|
|
394
|
+
npx react-native-bspatch rollback --to 5 # publish v5's bundle again as the next version
|
|
364
395
|
npx react-native-bspatch reset --flavor staging --push # wipe this release's OTAs
|
|
365
396
|
```
|
|
366
397
|
|
|
398
|
+
Every command takes `--flavor`, `android`/`ios`, `--release-version`, `-y`; `patch`, `promote`, `rollback` and `status` take `--json` (machine-readable output for CI). Controls act on the live version's registry entries and aren't signed: they can only hold an update back or release one that is already signed. Clients pick changes up on their next check.
|
|
399
|
+
|
|
367
400
|
| Option | Default | Description |
|
|
368
401
|
|---|---|---|
|
|
369
402
|
| `android` / `ios` | both | Platform to patch |
|
|
370
403
|
| `--flavor <name>` | `dev` | Channel (`dev`→`development`, `prod`→`production`) |
|
|
371
404
|
| `--release-version <x.y.z>` | read from `versionName` / `MARKETING_VERSION`, confirmed by prompt | Store release the OTA targets |
|
|
372
|
-
| `-
|
|
405
|
+
| `-m "<notes>"` | asked in a terminal | Release notes, kept in the version's `release_*.json` |
|
|
406
|
+
| `-y` | | Skip the prompts, use the detected version |
|
|
407
|
+
| `--reset-cache` | | Clear the Metro cache before bundling (it's cleared automatically when `.env.<flavor>` changed since the last build) |
|
|
408
|
+
| `--dry-run` | | Build, diff and sign, but upload nothing |
|
|
409
|
+
| `--json` | | Print the version's `release_*.json` after the summary |
|
|
373
410
|
|
|
374
411
|
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.
|
|
375
412
|
|
|
376
413
|
### How It Works
|
|
377
414
|
|
|
378
|
-
1. **Bundle** — `npx react-native bundle` + `hermesc` generate Hermes bytecode (`.hbc`), uploaded as the version's full bundle.
|
|
415
|
+
1. **Bundle** — `npx react-native bundle` + `hermesc` generate Hermes bytecode (`.hbc`), uploaded gzipped (`.hbc.gz`, ~60% smaller) as the version's full bundle. Clients download it resumably and decompress it natively.
|
|
379
416
|
2. **Assets** — Every asset is copied to the content-addressed `asset-store/` (`{sha256}.{ext}`) and listed in the per-version manifest with its Metro path. Clients download each hash once (cached in `ota/assets/`) and copy it to `ota/vN/<path>` — the location React Native resolves for a bundle loaded from `ota/vN/` (`drawable-*/`, `raw/` on Android; `assets/…` on iOS).
|
|
380
417
|
3. **Patch** — `bsdiff` generates a delta from each of the last 5 OTA bundles (v2+) to the new one.
|
|
381
418
|
4. **Registry** — Updates `ota_registry.json` with new `baseHash`, `bundleHash`, and asset manifest URL for each platform/channel.
|
|
382
|
-
5. **Upload** — Uploads
|
|
419
|
+
5. **Upload** — Uploads the bundle, patches and new assets to R2, then the registry.
|
|
420
|
+
6. **Verify** — Reads the release back through `OTA_BASE_URL`, the way the app will: the registry must list the new version, every file must be served and the entries must verify against the signing key. An app pointed at another bucket fails here, not weeks later.
|
|
421
|
+
|
|
422
|
+
### Runtime fingerprint (native compatibility)
|
|
423
|
+
|
|
424
|
+
An OTA bundle only installs on a binary with the same native code. `setup` (run by every `patch`) adds a step to the Xcode "Bundle React Native code and images" phase, and the library's Gradle file a `resValue`, that stamp the store build with a fingerprint of its native dependencies: react-native, every direct dependency with native code (name@version), `ios/Podfile.lock` / the Gradle dependency lines, and this library's own native sources (so a JS-only release of the library still ships by OTA). `patch` records the same fingerprint in every entry; a client skips entries for other native code, so adding a native module and publishing an OTA can't crash installed apps. The summary flags it when the fingerprint changed since the live version. Not covered: your own AppDelegate/MainApplication code (changing it needs a store build anyway).
|
|
425
|
+
|
|
426
|
+
Entries also carry the Hermes bytecode version and a `requires` list of client features (e.g. `gzip`): a client skips what its Hermes can't read or what it doesn't support, instead of failing on it.
|
|
383
427
|
|
|
384
428
|
### One-time Setup
|
|
385
429
|
|
|
@@ -415,9 +459,9 @@ customServer: {
|
|
|
415
459
|
},
|
|
416
460
|
```
|
|
417
461
|
|
|
418
|
-
Builds publish to **Cloudflare R2
|
|
462
|
+
Builds publish to **Cloudflare R2**, the only supported storage for now.
|
|
419
463
|
|
|
420
|
-
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`.
|
|
464
|
+
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`. Bundles are stored gzip `Content-Encoding` (Hermes bytecode shrinks ~60%): every client's HTTP stack decodes them transparently, older app versions included.
|
|
421
465
|
|
|
422
466
|
```bash
|
|
423
467
|
# .env.<flavor>
|
|
@@ -432,9 +476,9 @@ OTA_R2_SECRET_ACCESS_KEY=...
|
|
|
432
476
|
|
|
433
477
|
| Variable | File | Required | Default | Description |
|
|
434
478
|
|----------|------|----------|---------|-------------|
|
|
435
|
-
| `OTA_BASE_URL` | `.env.<flavor>` | Yes | - |
|
|
436
|
-
| `OTA_FOLDER` | `.env.<flavor>` | No | `ota` | Folder prefix in storage; only set it to share one bucket
|
|
437
|
-
| `OTA_R2_*` | `.env.ota` |
|
|
479
|
+
| `OTA_BASE_URL` | `.env.<flavor>` | Yes | - | R2 public URL (r2.dev or custom domain) |
|
|
480
|
+
| `OTA_FOLDER` | `.env.<flavor>` | No | `ota` | Folder prefix in storage; only set it to share one bucket with other data (and set `customServer.baseFolder` to match) |
|
|
481
|
+
| `OTA_R2_*` | `.env.ota` | Yes | - | `ACCOUNT_ID`, `BUCKET`, `ACCESS_KEY_ID`, `SECRET_ACCESS_KEY` |
|
|
438
482
|
| `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) |
|
|
439
483
|
|
|
440
484
|
Keep both files out of git (`.env.*` in `.gitignore`).
|
|
@@ -469,14 +513,27 @@ const otaConfig: OtaConfig = {
|
|
|
469
513
|
};
|
|
470
514
|
```
|
|
471
515
|
|
|
516
|
+
### Zero-setup signing
|
|
517
|
+
|
|
518
|
+
`patch` handles the key itself, so there is nothing to set up:
|
|
519
|
+
|
|
520
|
+
- **No key yet** (`ota_private_key.pem` / `OTA_PRIVATE_KEY`) and the app has never been signed: it creates the signing key and a **backup key** (`ota_private_key.backup.pem`) in the project root, gitignores both, and embeds both public keys in the app (`Info.plist` → `BsPatchPublicKey`, `android/app/src/main/res/values/bspatch_public_key.xml`). `OtaProvider` verifies with them when `config.publicKey` isn't set. Commit those files; store builds from then on verify every update.
|
|
521
|
+
- **Move the backup key offline** (password manager) and delete it from the machine. If the signing key is ever lost or leaked, put the backup in its place as `ota_private_key.pem`: installed apps already trust it, so no store build is needed.
|
|
522
|
+
- **Key present**: it must be one of the public keys the app already has (embedded, or `publicKey:` in JS), otherwise `patch` stops before building. An app without embedded keys gets them (plus a backup) on the next patch.
|
|
523
|
+
- **Key missing but the app was signed before** (new machine, CI): `patch` stops and asks for the original (or backup) key instead of creating one, since installed apps would reject anything signed with a new key. On CI, set `OTA_PRIVATE_KEY`.
|
|
524
|
+
|
|
472
525
|
### 2. Automated Signing at Build Time
|
|
473
526
|
|
|
474
|
-
When you run `tools/build_ota.sh`, it automatically searches for `ota_private_key.pem` (or the `OTA_PRIVATE_KEY` environment variable). If found, it **signs each patch (`signature`)
|
|
527
|
+
When you run `tools/build_ota.sh`, it automatically searches for `ota_private_key.pem` (or the `OTA_PRIVATE_KEY` environment variable). If found, it **signs each patch (`signature`), the resulting bundle (`bundleSignature`) and each registry entry (`entrySignature`) with SHA-256** and injects the signatures into `ota_registry.json`. The bundle signature is checked on both the patch path and the full-bundle fallback. If the key is present but a file can't be signed, the build fails.
|
|
528
|
+
|
|
529
|
+
`entrySignature` covers the fields that decide what a client installs: `appVersion`, `flavor`, `platform`, `fromVersion`, `toVersion`, `bundleHash`, `assetManifestHash` and `minAppVersion`. The client checks that appVersion, flavor and platform are its own and that the asset manifest matches its hash, so whoever serves the registry (say, a lapsed domain) can't replay another flavor's, platform's or release's signed bundle, or swap images.
|
|
530
|
+
|
|
531
|
+
> **Upgrading from 4.x:** clients with `publicKey` reject entries without `entrySignature`. Publish once for **each** platform after upgrading, before shipping a store build with the new version.
|
|
475
532
|
|
|
476
533
|
On the client device:
|
|
477
534
|
- **Android**: Uses Android Security Provider (`SHA256withRSA` / `SHA256withECDSA`).
|
|
478
535
|
- **iOS**: Uses Apple Security framework (`SecKeyVerifySignature`).
|
|
479
|
-
- **Fail closed**: when `publicKey` is set, a missing or invalid signature rejects the update. HMAC/symmetric keys are no longer supported (the public key ships in the app, so it can't be a secret).
|
|
536
|
+
- **Fail closed**: when `publicKey` is set, a missing or invalid signature (bundle, patch or registry entry) rejects the update. HMAC/symmetric keys are no longer supported (the public key ships in the app, so it can't be a secret).
|
|
480
537
|
|
|
481
538
|
---
|
|
482
539
|
|
|
@@ -530,7 +587,7 @@ react-native-bspatch/
|
|
|
530
587
|
│ ├── OtaProvider.tsx # React provider + update modes
|
|
531
588
|
│ ├── types.ts
|
|
532
589
|
│ └── storage/
|
|
533
|
-
│ └──
|
|
590
|
+
│ └── R2Storage.ts # Default provider: R2 public URL
|
|
534
591
|
├── android/src/main/java/vn/reactnativebspatch/
|
|
535
592
|
│ ├── BsPatchModule.kt # applyPatch, verifySignature, reload
|
|
536
593
|
│ └── OtaBundleResolver.kt # Bundle resolution + crash rollback (before JS)
|
|
@@ -541,7 +598,7 @@ react-native-bspatch/
|
|
|
541
598
|
└── tools/
|
|
542
599
|
├── cli.js # bin entry (Yarn Berry runs bins with node)
|
|
543
600
|
├── build_ota.sh # CLI: patch / reset / setup / version
|
|
544
|
-
├── upload_ota.sh # Upload to Cloudflare R2
|
|
601
|
+
├── upload_ota.sh # Upload to Cloudflare R2
|
|
545
602
|
├── update_registry.js # Writes ota_registry.json entries + signatures
|
|
546
603
|
├── setup_project.js # AppDelegate / MainApplication wiring
|
|
547
604
|
├── bump_version.js # Native version sync
|
package/android/build.gradle
CHANGED
|
@@ -18,6 +18,26 @@ def reactNativeArchitectures() {
|
|
|
18
18
|
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// The app's native runtime fingerprint (tools/fingerprint.js), read by getRuntimeFingerprint():
|
|
22
|
+
// an OTA only installs on a binary with the same one. Never fails the build, but a "" build
|
|
23
|
+
// takes no OTA with a fingerprint: fix node/the script and rebuild.
|
|
24
|
+
def runtimeFingerprint() {
|
|
25
|
+
def appRoot = rootProject.projectDir.parentFile
|
|
26
|
+
try {
|
|
27
|
+
def result = providers.exec {
|
|
28
|
+
commandLine "node", file("../tools/fingerprint.js").absolutePath, appRoot.absolutePath, "android"
|
|
29
|
+
workingDir appRoot
|
|
30
|
+
ignoreExitValue = true
|
|
31
|
+
}
|
|
32
|
+
def out = result.standardOutput.asText.get().trim()
|
|
33
|
+
if (result.result.get().exitValue == 0 && out) return out
|
|
34
|
+
logger.warn("react-native-bspatch: runtime fingerprint failed (${result.standardError.asText.get().trim()}): this build will not accept OTA updates")
|
|
35
|
+
} catch (Exception e) {
|
|
36
|
+
logger.warn("react-native-bspatch: runtime fingerprint failed (${e.message}): this build will not accept OTA updates")
|
|
37
|
+
}
|
|
38
|
+
return ""
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
android {
|
|
22
42
|
namespace "vn.reactnativebspatch"
|
|
23
43
|
|
|
@@ -26,6 +46,7 @@ android {
|
|
|
26
46
|
defaultConfig {
|
|
27
47
|
minSdkVersion getExtOrIntegerDefault("minSdkVersion")
|
|
28
48
|
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
|
|
49
|
+
resValue "string", "bspatch_runtime_fingerprint", runtimeFingerprint()
|
|
29
50
|
externalNativeBuild {
|
|
30
51
|
cmake {
|
|
31
52
|
cppFlags "-Wall -fstack-protector-all"
|
|
@@ -40,6 +61,10 @@ android {
|
|
|
40
61
|
}
|
|
41
62
|
}
|
|
42
63
|
|
|
64
|
+
buildFeatures {
|
|
65
|
+
resValues true
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
buildTypes {
|
|
44
69
|
release {
|
|
45
70
|
minifyEnabled false
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
package vn.reactnativebspatch
|
|
2
2
|
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.ConnectivityManager
|
|
5
|
+
import android.net.NetworkCapabilities
|
|
6
|
+
import android.os.Build
|
|
3
7
|
import android.os.Handler
|
|
4
8
|
import android.os.Looper
|
|
9
|
+
import android.os.StatFs
|
|
5
10
|
import android.os.SystemClock
|
|
6
11
|
import com.facebook.react.bridge.Arguments
|
|
7
12
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
@@ -15,6 +20,8 @@ import java.net.HttpURLConnection
|
|
|
15
20
|
import java.net.URL
|
|
16
21
|
import java.security.MessageDigest
|
|
17
22
|
import java.util.concurrent.Executors
|
|
23
|
+
import java.util.zip.GZIPInputStream
|
|
24
|
+
import org.json.JSONObject
|
|
18
25
|
|
|
19
26
|
class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeBsPatchSpec(reactContext) {
|
|
20
27
|
|
|
@@ -35,6 +42,7 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
|
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
private external fun nativeApplyPatch(oldPath: String, newPath: String, patchPath: String): Boolean
|
|
45
|
+
private external fun nativeDecompressBz2(srcPath: String, destPath: String): Boolean
|
|
38
46
|
|
|
39
47
|
override fun applyPatch(oldPath: String, newPath: String, patchPath: String, promise: com.facebook.react.bridge.Promise) {
|
|
40
48
|
backgroundExecutor.execute {
|
|
@@ -147,26 +155,98 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
|
|
|
147
155
|
|
|
148
156
|
override fun getDocumentDir(): String = reactContext.filesDir.absolutePath
|
|
149
157
|
|
|
150
|
-
|
|
158
|
+
// App resources override the library's empty default (see res/values/bspatch_public_key.xml)
|
|
159
|
+
override fun getEmbeddedPublicKeys(): String = reactContext.getString(R.string.bspatch_public_key)
|
|
160
|
+
|
|
161
|
+
override fun getAppBuild(): String {
|
|
162
|
+
val info = reactContext.packageManager.getPackageInfo(reactContext.packageName, 0)
|
|
163
|
+
return if (Build.VERSION.SDK_INT >= 28) info.longVersionCode.toString()
|
|
164
|
+
else @Suppress("DEPRECATION") info.versionCode.toString()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// resValue generated by this library's build.gradle (tools/fingerprint.js) at app build time
|
|
168
|
+
override fun getRuntimeFingerprint(): String = reactContext.getString(R.string.bspatch_runtime_fingerprint)
|
|
169
|
+
|
|
170
|
+
override fun getFreeDiskSpace(promise: Promise) = io(promise) {
|
|
171
|
+
StatFs(reactContext.filesDir.path).availableBytes.toDouble()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
override fun getNetworkType(promise: Promise) {
|
|
175
|
+
val type = try {
|
|
176
|
+
val cm = reactContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
177
|
+
val caps = cm.getNetworkCapabilities(cm.activeNetwork)
|
|
178
|
+
when {
|
|
179
|
+
caps == null -> "none"
|
|
180
|
+
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
|
181
|
+
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
|
182
|
+
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
|
183
|
+
else -> "unknown"
|
|
184
|
+
}
|
|
185
|
+
} catch (e: Exception) {
|
|
186
|
+
"unknown"
|
|
187
|
+
}
|
|
188
|
+
promise.resolve(type)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
override fun decompressFile(srcPath: String, destPath: String, format: String, promise: Promise) = io(promise) {
|
|
192
|
+
val dest = File(destPath)
|
|
193
|
+
val part = File("$destPath.part")
|
|
194
|
+
try {
|
|
195
|
+
when (format) {
|
|
196
|
+
"gzip" -> GZIPInputStream(FileInputStream(srcPath), IO_BUFFER_SIZE).use { input ->
|
|
197
|
+
FileOutputStream(part).use { output ->
|
|
198
|
+
input.copyTo(output, IO_BUFFER_SIZE)
|
|
199
|
+
output.fd.sync()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
"bz2" -> if (!nativeDecompressBz2(srcPath, part.path)) throw IOException("Corrupt or unreadable bz2 file: $srcPath")
|
|
203
|
+
else -> throw IllegalArgumentException("Unknown compression format: $format")
|
|
204
|
+
}
|
|
205
|
+
if (!part.renameTo(dest)) throw IOException("Rename to $destPath failed")
|
|
206
|
+
} catch (e: Exception) {
|
|
207
|
+
part.delete()
|
|
208
|
+
dest.delete()
|
|
209
|
+
throw e
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
override fun downloadFile(url: String, destPath: String, timeoutMs: Double, headersJson: String, promise: Promise) {
|
|
151
214
|
backgroundExecutor.execute {
|
|
152
215
|
val part = File("$destPath.part")
|
|
153
216
|
var conn: HttpURLConnection? = null
|
|
154
217
|
try {
|
|
218
|
+
val headers = JSONObject(headersJson)
|
|
219
|
+
// A .part left by a failed attempt is resumed with a Range request
|
|
220
|
+
val resumeFrom = if (part.exists()) part.length() else 0L
|
|
155
221
|
conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
|
156
222
|
connectTimeout = timeoutMs.toInt()
|
|
157
223
|
readTimeout = timeoutMs.toInt()
|
|
158
224
|
instanceFollowRedirects = true
|
|
159
225
|
setRequestProperty("Cache-Control", "no-cache")
|
|
226
|
+
headers.keys().forEach { setRequestProperty(it, headers.getString(it)) }
|
|
227
|
+
if (resumeFrom > 0) {
|
|
228
|
+
setRequestProperty("Range", "bytes=$resumeFrom-")
|
|
229
|
+
// Byte ranges of a transparently gunzipped body don't line up
|
|
230
|
+
setRequestProperty("Accept-Encoding", "identity")
|
|
231
|
+
}
|
|
160
232
|
}
|
|
161
233
|
val status = conn.responseCode
|
|
162
|
-
if (status !in 200..299)
|
|
234
|
+
if (status !in 200..299) {
|
|
235
|
+
part.delete() // a stale .part (e.g. 416) must not be resumed again
|
|
236
|
+
throw IOException("HTTP $status for $url")
|
|
237
|
+
}
|
|
238
|
+
val append = status == 206 && resumeFrom > 0
|
|
163
239
|
// -1 when unknown (also when the body is transparently gunzipped)
|
|
164
|
-
val
|
|
240
|
+
val length = conn.contentLengthLong
|
|
241
|
+
val total = if (append) {
|
|
242
|
+
conn.getHeaderField("Content-Range")?.substringAfter('/')?.toLongOrNull()
|
|
243
|
+
?: if (length >= 0) resumeFrom + length else -1L
|
|
244
|
+
} else length
|
|
165
245
|
part.parentFile?.mkdirs()
|
|
166
|
-
var received = 0L
|
|
246
|
+
var received = if (append) resumeFrom else 0L
|
|
167
247
|
var lastEmit = 0L
|
|
168
248
|
conn.inputStream.use { input ->
|
|
169
|
-
FileOutputStream(part).use { output ->
|
|
249
|
+
FileOutputStream(part, append).use { output ->
|
|
170
250
|
val buffer = ByteArray(IO_BUFFER_SIZE)
|
|
171
251
|
while (true) {
|
|
172
252
|
val n = input.read(buffer)
|
|
@@ -189,7 +269,7 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
|
|
|
189
269
|
if (!part.renameTo(File(destPath))) throw IOException("Rename to $destPath failed")
|
|
190
270
|
promise.resolve(received.toDouble())
|
|
191
271
|
} catch (e: Exception) {
|
|
192
|
-
part
|
|
272
|
+
// .part is kept for the next attempt to resume (deleted above on HTTP errors)
|
|
193
273
|
promise.reject("ERR_DOWNLOAD", e.message, e)
|
|
194
274
|
} finally {
|
|
195
275
|
conn?.disconnect()
|
|
@@ -233,3 +233,37 @@ int apply_bspatch(const char *oldfile, const char *newfile, const char *patchfil
|
|
|
233
233
|
|
|
234
234
|
return ret;
|
|
235
235
|
}
|
|
236
|
+
|
|
237
|
+
int decompress_bz2(const char *srcfile, const char *dstfile)
|
|
238
|
+
{
|
|
239
|
+
FILE *in, *out;
|
|
240
|
+
BZFILE *bz2 = NULL;
|
|
241
|
+
int bz2err = BZ_OK, n, rc = -1;
|
|
242
|
+
char buf[64 * 1024];
|
|
243
|
+
|
|
244
|
+
if ((in = fopen(srcfile, "rb")) == NULL) {
|
|
245
|
+
LOGE("decompress_bz2: cannot open %s", srcfile);
|
|
246
|
+
return -1;
|
|
247
|
+
}
|
|
248
|
+
if ((out = fopen(dstfile, "wb")) == NULL) {
|
|
249
|
+
LOGE("decompress_bz2: cannot create %s", dstfile);
|
|
250
|
+
fclose(in);
|
|
251
|
+
return -1;
|
|
252
|
+
}
|
|
253
|
+
if ((bz2 = BZ2_bzReadOpen(&bz2err, in, 0, 0, NULL, 0)) != NULL) {
|
|
254
|
+
while (bz2err == BZ_OK) {
|
|
255
|
+
n = BZ2_bzRead(&bz2err, bz2, buf, sizeof(buf));
|
|
256
|
+
if ((bz2err == BZ_OK || bz2err == BZ_STREAM_END) && n > 0 &&
|
|
257
|
+
fwrite(buf, 1, n, out) != (size_t)n) {
|
|
258
|
+
bz2err = BZ_IO_ERROR;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (bz2err == BZ_STREAM_END) rc = 0;
|
|
262
|
+
else LOGE("decompress_bz2: bzip2 error %d", bz2err);
|
|
263
|
+
BZ2_bzReadClose(&bz2err, bz2);
|
|
264
|
+
}
|
|
265
|
+
if (fclose(out) != 0) rc = -1;
|
|
266
|
+
fclose(in);
|
|
267
|
+
if (rc != 0) unlink(dstfile);
|
|
268
|
+
return rc;
|
|
269
|
+
}
|
|
@@ -2,5 +2,7 @@
|
|
|
2
2
|
#define BSPATCH_WRAPPER_H
|
|
3
3
|
|
|
4
4
|
int apply_bspatch(const char *oldfile, const char *newfile, const char *patchfile);
|
|
5
|
+
/* Streams a single-stream .bz2 file to dstfile. 0 on success; dstfile is removed on failure. */
|
|
6
|
+
int decompress_bz2(const char *srcfile, const char *dstfile);
|
|
5
7
|
|
|
6
8
|
#endif // BSPATCH_WRAPPER_H
|
package/cpp/bspatch_jni.cpp
CHANGED
|
@@ -38,4 +38,22 @@ Java_vn_reactnativebspatch_BsPatchModule_nativeApplyPatch(
|
|
|
38
38
|
|
|
39
39
|
return (result == 0) ? JNI_TRUE : JNI_FALSE;
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
extern "C" JNIEXPORT jboolean JNICALL
|
|
43
|
+
Java_vn_reactnativebspatch_BsPatchModule_nativeDecompressBz2(
|
|
44
|
+
JNIEnv* env,
|
|
45
|
+
jobject /* this */,
|
|
46
|
+
jstring srcPath,
|
|
47
|
+
jstring destPath) {
|
|
48
|
+
|
|
49
|
+
const char *src_file = env->GetStringUTFChars(srcPath, 0);
|
|
50
|
+
const char *dest_file = env->GetStringUTFChars(destPath, 0);
|
|
51
|
+
|
|
52
|
+
int result = decompress_bz2(src_file, dest_file);
|
|
53
|
+
|
|
54
|
+
env->ReleaseStringUTFChars(srcPath, src_file);
|
|
55
|
+
env->ReleaseStringUTFChars(destPath, dest_file);
|
|
56
|
+
|
|
57
|
+
return (result == 0) ? JNI_TRUE : JNI_FALSE;
|
|
58
|
+
}
|
|
41
59
|
#endif
|