react-native-bspatch 4.1.0 → 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 +83 -27
- 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 +116 -20
- 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 +116 -20
- 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 +52 -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 +115 -24
- 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 +49 -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
|
@@ -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
|
};
|
|
@@ -151,12 +151,16 @@ type OtaConfig = {
|
|
|
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
|
```
|
|
@@ -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,11 +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
|
|
329
350
|
applyVersion, // (version) => Promise<void> — download and apply a specific version from the manifest
|
|
330
351
|
resetToFactory, // () => Promise<void> — wipe OTA state, reload APK/IPA
|
|
331
352
|
markAsHealthy, // () => Promise<void> — manually mark launch OK
|
|
332
|
-
checkUpdate, // () => Promise<void> —
|
|
353
|
+
checkUpdate, // ({ force }?) => Promise<void> — check now (throttled by minCheckIntervalMs unless force)
|
|
333
354
|
reloadBundle, // () => void — immediately reload JS bundle to apply ready update
|
|
334
355
|
} = useOta();
|
|
335
356
|
```
|
|
@@ -356,31 +377,53 @@ The library ships with an **automated build pipeline** (`tools/build_ota.sh`) th
|
|
|
356
377
|
### Commands
|
|
357
378
|
|
|
358
379
|
```bash
|
|
359
|
-
npx react-native-bspatch setup # once: wire OtaBundleResolver into the app
|
|
360
380
|
npx react-native-bspatch version 1.0.5 # set native version (package.json, Gradle, Xcode)
|
|
361
|
-
#
|
|
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) …
|
|
362
383
|
npx react-native-bspatch patch android # build + publish the next OTA
|
|
363
|
-
npx react-native-bspatch patch ios --flavor staging
|
|
364
|
-
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
|
|
365
395
|
npx react-native-bspatch reset --flavor staging --push # wipe this release's OTAs
|
|
366
396
|
```
|
|
367
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
|
+
|
|
368
400
|
| Option | Default | Description |
|
|
369
401
|
|---|---|---|
|
|
370
402
|
| `android` / `ios` | both | Platform to patch |
|
|
371
403
|
| `--flavor <name>` | `dev` | Channel (`dev`→`development`, `prod`→`production`) |
|
|
372
404
|
| `--release-version <x.y.z>` | read from `versionName` / `MARKETING_VERSION`, confirmed by prompt | Store release the OTA targets |
|
|
373
|
-
| `-
|
|
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 |
|
|
374
410
|
|
|
375
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.
|
|
376
412
|
|
|
377
413
|
### How It Works
|
|
378
414
|
|
|
379
|
-
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.
|
|
380
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).
|
|
381
417
|
3. **Patch** — `bsdiff` generates a delta from each of the last 5 OTA bundles (v2+) to the new one.
|
|
382
418
|
4. **Registry** — Updates `ota_registry.json` with new `baseHash`, `bundleHash`, and asset manifest URL for each platform/channel.
|
|
383
|
-
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.
|
|
384
427
|
|
|
385
428
|
### One-time Setup
|
|
386
429
|
|
|
@@ -416,9 +459,9 @@ customServer: {
|
|
|
416
459
|
},
|
|
417
460
|
```
|
|
418
461
|
|
|
419
|
-
Builds publish to **Cloudflare R2
|
|
462
|
+
Builds publish to **Cloudflare R2**, the only supported storage for now.
|
|
420
463
|
|
|
421
|
-
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.
|
|
422
465
|
|
|
423
466
|
```bash
|
|
424
467
|
# .env.<flavor>
|
|
@@ -433,9 +476,9 @@ OTA_R2_SECRET_ACCESS_KEY=...
|
|
|
433
476
|
|
|
434
477
|
| Variable | File | Required | Default | Description |
|
|
435
478
|
|----------|------|----------|---------|-------------|
|
|
436
|
-
| `OTA_BASE_URL` | `.env.<flavor>` | Yes | - |
|
|
437
|
-
| `OTA_FOLDER` | `.env.<flavor>` | No | `ota` | Folder prefix in storage; only set it to share one bucket
|
|
438
|
-
| `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` |
|
|
439
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) |
|
|
440
483
|
|
|
441
484
|
Keep both files out of git (`.env.*` in `.gitignore`).
|
|
@@ -470,14 +513,27 @@ const otaConfig: OtaConfig = {
|
|
|
470
513
|
};
|
|
471
514
|
```
|
|
472
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
|
+
|
|
473
525
|
### 2. Automated Signing at Build Time
|
|
474
526
|
|
|
475
|
-
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.
|
|
476
532
|
|
|
477
533
|
On the client device:
|
|
478
534
|
- **Android**: Uses Android Security Provider (`SHA256withRSA` / `SHA256withECDSA`).
|
|
479
535
|
- **iOS**: Uses Apple Security framework (`SecKeyVerifySignature`).
|
|
480
|
-
- **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).
|
|
481
537
|
|
|
482
538
|
---
|
|
483
539
|
|
|
@@ -531,7 +587,7 @@ react-native-bspatch/
|
|
|
531
587
|
│ ├── OtaProvider.tsx # React provider + update modes
|
|
532
588
|
│ ├── types.ts
|
|
533
589
|
│ └── storage/
|
|
534
|
-
│ └──
|
|
590
|
+
│ └── R2Storage.ts # Default provider: R2 public URL
|
|
535
591
|
├── android/src/main/java/vn/reactnativebspatch/
|
|
536
592
|
│ ├── BsPatchModule.kt # applyPatch, verifySignature, reload
|
|
537
593
|
│ └── OtaBundleResolver.kt # Bundle resolution + crash rollback (before JS)
|
|
@@ -542,7 +598,7 @@ react-native-bspatch/
|
|
|
542
598
|
└── tools/
|
|
543
599
|
├── cli.js # bin entry (Yarn Berry runs bins with node)
|
|
544
600
|
├── build_ota.sh # CLI: patch / reset / setup / version
|
|
545
|
-
├── upload_ota.sh # Upload to Cloudflare R2
|
|
601
|
+
├── upload_ota.sh # Upload to Cloudflare R2
|
|
546
602
|
├── update_registry.js # Writes ota_registry.json entries + signatures
|
|
547
603
|
├── setup_project.js # AppDelegate / MainApplication wiring
|
|
548
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
|