react-native-bspatch 1.1.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +22 -32
  2. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +15 -102
  3. package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +21 -1
  4. package/cpp/bsdiff/bspatch.c +1 -0
  5. package/ios/OtaBundleResolver.swift +16 -1
  6. package/ios/RNBsPatch.mm +61 -154
  7. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  8. package/lib/commonjs/index.js +4 -4
  9. package/lib/commonjs/index.js.map +1 -1
  10. package/lib/commonjs/ota/OtaProvider.js +26 -22
  11. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  12. package/lib/commonjs/ota/otaAssetResolver.js +8 -2
  13. package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
  14. package/lib/commonjs/ota/otaCore.js +89 -141
  15. package/lib/commonjs/ota/otaCore.js.map +1 -1
  16. package/lib/commonjs/ota/otaLogger.js +0 -2
  17. package/lib/commonjs/ota/otaLogger.js.map +1 -1
  18. package/lib/commonjs/ota/otaMetadata.js +18 -7
  19. package/lib/commonjs/ota/otaMetadata.js.map +1 -1
  20. package/lib/commonjs/ota/storage/GitHubRawStorage.js +3 -1
  21. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
  22. package/lib/commonjs/ota/types.js +1 -10
  23. package/lib/commonjs/ota/types.js.map +1 -1
  24. package/lib/module/NativeBsPatch.js.map +1 -1
  25. package/lib/module/index.js +1 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/ota/OtaProvider.js +26 -22
  28. package/lib/module/ota/OtaProvider.js.map +1 -1
  29. package/lib/module/ota/otaAssetResolver.js +8 -2
  30. package/lib/module/ota/otaAssetResolver.js.map +1 -1
  31. package/lib/module/ota/otaCore.js +88 -139
  32. package/lib/module/ota/otaCore.js.map +1 -1
  33. package/lib/module/ota/otaLogger.js +0 -2
  34. package/lib/module/ota/otaLogger.js.map +1 -1
  35. package/lib/module/ota/otaMetadata.js +18 -7
  36. package/lib/module/ota/otaMetadata.js.map +1 -1
  37. package/lib/module/ota/storage/GitHubRawStorage.js +3 -1
  38. package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
  39. package/lib/module/ota/types.js +0 -9
  40. package/lib/module/ota/types.js.map +1 -1
  41. package/lib/typescript/NativeBsPatch.d.ts +3 -15
  42. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  43. package/lib/typescript/index.d.ts +1 -1
  44. package/lib/typescript/index.d.ts.map +1 -1
  45. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  46. package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -1
  47. package/lib/typescript/ota/otaCore.d.ts +5 -42
  48. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  49. package/lib/typescript/ota/otaLogger.d.ts +1 -3
  50. package/lib/typescript/ota/otaLogger.d.ts.map +1 -1
  51. package/lib/typescript/ota/otaMetadata.d.ts +2 -2
  52. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
  53. package/lib/typescript/ota/types.d.ts +18 -54
  54. package/lib/typescript/ota/types.d.ts.map +1 -1
  55. package/package.json +3 -6
  56. package/react-native-bspatch.podspec +0 -1
  57. package/src/NativeBsPatch.ts +3 -16
  58. package/src/index.ts +1 -1
  59. package/src/ota/OtaProvider.tsx +24 -23
  60. package/src/ota/otaAssetResolver.ts +5 -2
  61. package/src/ota/otaCore.ts +118 -176
  62. package/src/ota/otaLogger.ts +0 -2
  63. package/src/ota/otaMetadata.ts +18 -8
  64. package/src/ota/storage/GitHubRawStorage.ts +1 -1
  65. package/src/ota/types.ts +18 -57
  66. package/tools/build_ota.sh +101 -174
  67. package/tools/setup_project.js +33 -14
  68. package/tools/update_registry.js +24 -28
  69. package/tools/{upload_ota_custom.sh → upload_ota.sh} +33 -114
  70. package/lib/commonjs/crypto-js.d.js +0 -2
  71. package/lib/commonjs/crypto-js.d.js.map +0 -1
  72. package/lib/commonjs/ota/storage/CustomStorage.js +0 -160
  73. package/lib/commonjs/ota/storage/CustomStorage.js.map +0 -1
  74. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js +0 -99
  75. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js.map +0 -1
  76. package/lib/module/crypto-js.d.js +0 -2
  77. package/lib/module/crypto-js.d.js.map +0 -1
  78. package/lib/module/ota/storage/CustomStorage.js +0 -154
  79. package/lib/module/ota/storage/CustomStorage.js.map +0 -1
  80. package/lib/module/ota/storage/GitHubReleaseStorage.js +0 -93
  81. package/lib/module/ota/storage/GitHubReleaseStorage.js.map +0 -1
  82. package/lib/typescript/ota/storage/CustomStorage.d.ts +0 -12
  83. package/lib/typescript/ota/storage/CustomStorage.d.ts.map +0 -1
  84. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts +0 -20
  85. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts.map +0 -1
  86. package/src/crypto-js.d.ts +0 -1
  87. package/src/ota/storage/CustomStorage.ts +0 -166
  88. package/src/ota/storage/GitHubReleaseStorage.ts +0 -103
  89. package/tools/bsdiff +0 -0
package/README.md CHANGED
@@ -17,7 +17,6 @@ Premium OTA update solution for React Native using **TurboModules** + **bsdiff b
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
- - **Gzip decompression** — native 64KB-buffered gzip decompression on both Android and iOS
21
20
  - **Content-addressed asset store** — images/assets downloaded per-version and redirected via `setCustomSourceTransformer`
22
21
  - **Structured telemetry** — all OTA events forwarded to your analytics via `onEvent` callback
23
22
  - **New Architecture ready** — built with TurboModules (no legacy Bridge)
@@ -71,12 +70,9 @@ const config = {
71
70
  updateMode: OtaUpdateMode.BACKGROUND_DOWNLOAD, // Silent download without interrupting user
72
71
  allowDebugUpdates: false, // default: OTA is blocked in __DEV__
73
72
  customServer: {
74
- baseUrl: 'https://ota.yourcompany.com',
75
- username: 'your-user',
76
- password: 'your-pass',
73
+ baseUrl: 'https://raw.githubusercontent.com/<owner>/<storage-repo>/main',
77
74
  channel: 'production',
78
- baseFolder: 'bspatch',
79
- useMinio: false,
75
+ baseFolder: 'ota',
80
76
  },
81
77
  };
82
78
 
@@ -176,22 +172,20 @@ type OtaConfig = {
176
172
  onEvent?: (entry: OtaLogEntry) => void;
177
173
 
178
174
  /**
179
- * Base64-encoded public key for HMAC-SHA256 patch signature verification.
175
+ * SPKI public key (PEM or base64, RSA or EC P-256) for signature verification.
176
+ * When set, unsigned or wrongly signed patches/bundles are rejected.
180
177
  * Recommended for production to prevent MITM attacks.
181
178
  */
182
179
  publicKey?: string;
183
180
 
184
- /** Custom storage provider. Omit to use CustomStorageProvider. */
181
+ /** Custom storage provider. Omit to use GitHubRawStorageProvider. */
185
182
  storage?: OtaStorageProvider;
186
183
 
187
- /** Custom server configuration (used by CustomStorageProvider) */
184
+ /** Static storage layout read by GITHUB_RAW (public, read-only — no credentials) */
188
185
  customServer?: {
189
- baseUrl: string;
190
- username: string;
191
- password: string;
186
+ baseUrl: string; // https://raw.githubusercontent.com/<owner>/<repo>/<branch>
192
187
  channel: string;
193
- baseFolder?: string; // default: 'bspatch'
194
- useMinio?: boolean;
188
+ baseFolder?: string; // default: 'ota'
195
189
  };
196
190
  };
197
191
  ```
@@ -254,7 +248,6 @@ Your OTA server must expose a `ota_registry.json` at the configured path (e.g. `
254
248
  "baseHash": "sha256_of_v1_bundle",
255
249
  "bundleHash": "sha256_of_v2_bundle",
256
250
  "platform": "android",
257
- "compressed": false,
258
251
  "mandatory": false,
259
252
  "fullBundleUrl": "v2-full.zip"
260
253
  }
@@ -292,7 +285,7 @@ This means crash protection is **native-side and zero-cost** — it doesn't requ
292
285
 
293
286
  ## Custom Storage Provider
294
287
 
295
- The library ships with `CustomStorageProvider` for MinIO-based private servers. To use a different backend, implement the interface:
288
+ The library ships with `GitHubRawStorageProvider` (default). To use a different backend (e.g. Cloudflare), implement the interface:
296
289
 
297
290
  ```ts
298
291
  interface OtaStorageProvider {
@@ -434,7 +427,7 @@ yarn ota:build
434
427
 
435
428
  ### Environment Variables
436
429
 
437
- The build script requires server credentials. You can provide them via:
430
+ Builds publish to a GitHub storage repository. Authentication uses `gh auth token` (or `GH_TOKEN` / `GITHUB_TOKEN`), so run `gh auth login` once.
438
431
 
439
432
  **Option 1: `.env` file (recommended)**
440
433
 
@@ -442,8 +435,8 @@ Create a `.env` file in your **React Native project root** (not in the module di
442
435
 
443
436
  ```bash
444
437
  # In your project root (e.g., /path/to/YourApp/)
445
- OTA_USER=your-username
446
- OTA_PASSWORD=your-password
438
+ OTA_BASE_URL=https://raw.githubusercontent.com/<owner>/<storage-repo>/main
439
+ OTA_FOLDER=ota
447
440
  ```
448
441
 
449
442
  The build script automatically loads `.env` from the project root.
@@ -451,17 +444,15 @@ The build script automatically loads `.env` from the project root.
451
444
  **Option 2: Export environment variables**
452
445
 
453
446
  ```bash
454
- export OTA_USER="your-username"
455
- export OTA_PASSWORD="your-password"
447
+ export OTA_BASE_URL="https://raw.githubusercontent.com/<owner>/<storage-repo>/main"
456
448
  yarn ota:build
457
449
  ```
458
450
 
459
451
  | Variable | Required | Default | Description |
460
452
  |----------|----------|---------|-------------|
461
- | `OTA_USER` | Yes | - | Server username |
462
- | `OTA_PASSWORD` | Yes | - | Server password |
463
- | `OTA_BASE_URL` | No | `https://sync-server.osp.com.vn` | Server base URL |
464
- | `OTA_FOLDER` | No | `congplqg` | Base folder path |
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) |
465
456
 
466
457
  > **Security Warning:** Never commit credentials to source control. The `.env` file is gitignored.
467
458
 
@@ -497,12 +488,12 @@ const otaConfig: OtaConfig = {
497
488
 
498
489
  ### 2. Automated Signing at Build Time
499
490
 
500
- 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 **cryptographically signs each binary patch file using SHA-256** and injects the signatures into `ota_registry.json`.
491
+ 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`) and the resulting bundle (`bundleSignature`) 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.
501
492
 
502
493
  On the client device:
503
494
  - **Android**: Uses Android Security Provider (`SHA256withRSA` / `SHA256withECDSA`).
504
495
  - **iOS**: Uses Apple Security framework (`SecKeyVerifySignature`).
505
- - **Backward Compatibility**: Automatically falls back to HMAC-SHA256 if a symmetric key is passed.
496
+ - **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).
506
497
 
507
498
  ---
508
499
 
@@ -547,7 +538,7 @@ When executing `tools/build_ota.sh`, the script auto-detects the version from `b
547
538
 
548
539
  - **POSIX `mmap()` Zero-Copy Patching**: Unlike standard bspatch which allocates heap memory with `malloc()`, `react-native-bspatch` memory-maps the input and output files using `mmap()` and `MADV_SEQUENTIAL`. Memory overhead is reduced by **~85–90%**, virtually eliminating out-of-memory crashes on low-end devices.
549
540
  - **SIMD Vectorization**: The inner patching loop includes `#pragma clang loop vectorize(enable)` for Clang ARM NEON 128-bit vectorization.
550
- - **64KB Streaming Buffers**: File hashing, gzip decompression, and stream copying are upgraded to 64KB chunk sizes across Android and iOS.
541
+ - **64KB Streaming Buffers**: File hashing and stream copying are upgraded to 64KB chunk sizes across Android and iOS.
551
542
  - **Disk Garbage Collection**: Calling `markHealthy()` automatically deletes obsolete bundle versions and dangling `.tmp` files from disk, keeping app storage minimal.
552
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.
553
544
 
@@ -566,13 +557,12 @@ src/native_modules/react-native-bspatch/
566
557
  │ ├── OtaProvider.tsx # React Context provider + auto-update logic
567
558
  │ ├── types.ts # All TypeScript type definitions
568
559
  │ └── storage/
569
- │ ├── CustomStorage.ts # MinIO-based storage implementation
570
- │ └── GitHubReleaseStorage.ts # GitHub Releases storage (public / token-auth)
560
+ │ └── GitHubRawStorage.ts # GitHub raw repository storage (default)
571
561
  ├── android/src/main/java/vn/reactnativebspatch/
572
- │ ├── BsPatchModule.kt # Native: applyPatch, unzip, extract, gzip
562
+ │ ├── BsPatchModule.kt # Native: applyPatch, extract, verifySignature
573
563
  │ └── OtaBundleResolver.kt # Native: bundle resolution + rollback
574
564
  ├── ios/
575
- │ ├── RNBsPatch.mm # Native iOS bridge + gzip decompress
565
+ │ ├── RNBsPatch.mm # Native iOS bridge + signature verify
576
566
  │ └── OtaBundleResolver.swift # Native iOS bundle resolution + rollback
577
567
  ├── cpp/
578
568
  │ ├── CMakeLists.txt # CMake build for bspatch C library
@@ -11,8 +11,6 @@ import java.io.FileInputStream
11
11
  import java.io.FileOutputStream
12
12
  import java.io.BufferedInputStream
13
13
  import java.io.BufferedOutputStream
14
- import java.util.zip.ZipInputStream
15
- import java.util.zip.GZIPInputStream
16
14
  import java.util.concurrent.Executors
17
15
 
18
16
  class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeBsPatchSpec(reactContext) {
@@ -44,36 +42,6 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
44
42
  }
45
43
  }
46
44
 
47
- override fun unzip(source: String, targetDir: String, promise: Promise) {
48
- backgroundExecutor.execute {
49
- try {
50
- val sourceFile = File(source)
51
- val destDir = File(targetDir)
52
- if (!destDir.exists()) destDir.mkdirs()
53
-
54
- ZipInputStream(BufferedInputStream(FileInputStream(sourceFile), IO_BUFFER_SIZE)).use { zis ->
55
- var entry = zis.nextEntry
56
- while (entry != null) {
57
- val file = File(destDir, entry.name)
58
- if (entry.isDirectory) {
59
- file.mkdirs()
60
- } else {
61
- file.parentFile?.mkdirs()
62
- BufferedOutputStream(FileOutputStream(file), IO_BUFFER_SIZE).use { bos ->
63
- zis.copyTo(bos, bufferSize = IO_BUFFER_SIZE)
64
- }
65
- }
66
- zis.closeEntry()
67
- entry = zis.nextEntry
68
- }
69
- }
70
- promise.resolve(true)
71
- } catch (e: Exception) {
72
- promise.reject("ERR_UNZIP", e.message, e)
73
- }
74
- }
75
- }
76
-
77
45
  /**
78
46
  * Extracts the JS bundle from APK assets using Android AssetManager.
79
47
  * Guarantees byte-exact copy — RNFS.copyFileAssets can produce different
@@ -119,25 +87,6 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
119
87
  android.util.Log.d("JS-OTA", message ?: "")
120
88
  }
121
89
 
122
- override fun decompressGzip(sourcePath: String, destPath: String, promise: Promise) {
123
- backgroundExecutor.execute {
124
- try {
125
- val source = File(sourcePath)
126
- val dest = File(destPath)
127
- dest.parentFile?.mkdirs()
128
-
129
- GZIPInputStream(BufferedInputStream(FileInputStream(source), IO_BUFFER_SIZE), IO_BUFFER_SIZE).use { gis ->
130
- BufferedOutputStream(FileOutputStream(dest), IO_BUFFER_SIZE).use { bos ->
131
- gis.copyTo(bos, bufferSize = IO_BUFFER_SIZE)
132
- }
133
- }
134
- promise.resolve(true)
135
- } catch (e: Exception) {
136
- promise.reject("ERR_GZIP_DECOMPRESS", e.message, e)
137
- }
138
- }
139
- }
140
-
141
90
  override fun verifySignature(filePath: String, signature: String, publicKey: String, promise: Promise) {
142
91
  backgroundExecutor.execute {
143
92
  try {
@@ -157,65 +106,29 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
157
106
  val keyBytes = android.util.Base64.decode(cleanKey, android.util.Base64.DEFAULT)
158
107
  val expectedSigBytes = android.util.Base64.decode(signature.trim(), android.util.Base64.DEFAULT)
159
108
 
160
- // 1. Try RSA Public Key (SHA256withRSA)
161
- try {
162
- val keySpec = java.security.spec.X509EncodedKeySpec(keyBytes)
163
- val keyFactory = java.security.KeyFactory.getInstance("RSA")
164
- val pubKey = keyFactory.generatePublic(keySpec)
165
- val sig = java.security.Signature.getInstance("SHA256withRSA")
166
- sig.initVerify(pubKey)
167
-
168
- val buffer = ByteArray(IO_BUFFER_SIZE)
169
- FileInputStream(file).use { fis ->
170
- var read: Int
171
- while (fis.read(buffer).also { read = it } != -1) {
172
- sig.update(buffer, 0, read)
173
- }
174
- }
175
- val isValid = sig.verify(expectedSigBytes)
176
- promise.resolve(isValid)
177
- return@execute
178
- } catch (rsaEx: Exception) {
179
- // Not RSA, continue
109
+ // Pick the algorithm from the key itself; never fall back to another scheme
110
+ val keySpec = java.security.spec.X509EncodedKeySpec(keyBytes)
111
+ val pubKey = try {
112
+ java.security.KeyFactory.getInstance("RSA").generatePublic(keySpec)
113
+ } catch (e: Exception) {
114
+ java.security.KeyFactory.getInstance("EC").generatePublic(keySpec)
180
115
  }
181
-
182
- // 2. Try ECDSA Public Key (SHA256withECDSA)
183
- try {
184
- val keySpec = java.security.spec.X509EncodedKeySpec(keyBytes)
185
- val keyFactory = java.security.KeyFactory.getInstance("EC")
186
- val pubKey = keyFactory.generatePublic(keySpec)
187
- val sig = java.security.Signature.getInstance("SHA256withECDSA")
188
- sig.initVerify(pubKey)
189
-
190
- val buffer = ByteArray(IO_BUFFER_SIZE)
191
- FileInputStream(file).use { fis ->
192
- var read: Int
193
- while (fis.read(buffer).also { read = it } != -1) {
194
- sig.update(buffer, 0, read)
195
- }
196
- }
197
- val isValid = sig.verify(expectedSigBytes)
198
- promise.resolve(isValid)
199
- return@execute
200
- } catch (ecEx: Exception) {
201
- // Not EC, continue
202
- }
203
-
204
- // 3. Fallback: HMAC-SHA256
205
- val mac = javax.crypto.Mac.getInstance("HmacSHA256")
206
- val secretKey = javax.crypto.spec.SecretKeySpec(keyBytes, "HmacSHA256")
207
- mac.init(secretKey)
116
+ val sig = java.security.Signature.getInstance(
117
+ if (pubKey.algorithm == "RSA") "SHA256withRSA" else "SHA256withECDSA"
118
+ )
119
+ sig.initVerify(pubKey)
208
120
 
209
121
  val buffer = ByteArray(IO_BUFFER_SIZE)
210
122
  FileInputStream(file).use { fis ->
211
123
  var read: Int
212
124
  while (fis.read(buffer).also { read = it } != -1) {
213
- mac.update(buffer, 0, read)
125
+ sig.update(buffer, 0, read)
214
126
  }
215
127
  }
216
- val computedSigBytes = mac.doFinal()
217
- val isValid = java.security.MessageDigest.isEqual(expectedSigBytes, computedSigBytes)
218
- promise.resolve(isValid)
128
+ promise.resolve(sig.verify(expectedSigBytes))
129
+ } catch (e: java.security.GeneralSecurityException) {
130
+ // Malformed key/signature → invalid (fail closed)
131
+ promise.resolve(false)
219
132
  } catch (e: Exception) {
220
133
  promise.reject("ERR_VERIFY_SIG", e.message, e)
221
134
  }
@@ -55,6 +55,18 @@ object OtaBundleResolver {
55
55
  }
56
56
 
57
57
  var metadata = JSONObject(metadataFile.readText())
58
+
59
+ // ── Step 0: Store update → OTA bundles were built for another native version ──
60
+ val otaAppVersion = metadata.optString("appVersion", "")
61
+ val installedAppVersion = context.packageManager
62
+ .getPackageInfo(context.packageName, 0).versionName ?: ""
63
+ if (otaAppVersion.isNotEmpty() && otaAppVersion != installedAppVersion) {
64
+ Log.i(TAG, "Native app $otaAppVersion → $installedAppVersion: discarding OTA state")
65
+ // JS bootstrap deletes ota_trash_* in the background
66
+ otaDir.renameTo(File(context.filesDir, "ota_trash_${System.currentTimeMillis()}"))
67
+ return null
68
+ }
69
+
58
70
  val currentVersion = metadata.optInt("currentVersion", baseVersion)
59
71
  val lastLaunchOk = metadata.optBoolean("lastLaunchSuccessful", true)
60
72
  val rollbackCount = metadata.optInt("rollbackCount", 0)
@@ -62,7 +74,12 @@ object OtaBundleResolver {
62
74
  // ── Step 1: Crash detection & rollback ──────────────────────────
63
75
  if (!lastLaunchOk && currentVersion > baseVersion) {
64
76
  Log.w(TAG, "Previous launch failed! Rolling back from V$currentVersion...")
77
+ // Remember the crashing version so JS never re-applies it
78
+ val failed = metadata.optJSONArray("failedVersions") ?: JSONArray()
79
+ failed.put(currentVersion)
65
80
  metadata = performRollback(metadata, baseVersion, rollbackCount, otaDir)
81
+ metadata.put("failedVersions", failed)
82
+ metadata.put("appVersion", otaAppVersion)
66
83
  writeMetadata(metadataFile, metadata)
67
84
 
68
85
  val newVersion = metadata.optInt("currentVersion", baseVersion)
@@ -219,7 +236,10 @@ object OtaBundleResolver {
219
236
  private fun writeMetadata(file: File, metadata: JSONObject) {
220
237
  try {
221
238
  file.parentFile?.mkdirs()
222
- file.writeText(metadata.toString(2))
239
+ // Write-then-rename: a crash mid-write never leaves truncated JSON
240
+ val tmp = File(file.parentFile, "${file.name}.tmp")
241
+ tmp.writeText(metadata.toString(2))
242
+ if (!tmp.renameTo(file)) throw java.io.IOException("rename failed")
223
243
  } catch (e: Exception) {
224
244
  Log.e(TAG, "Failed to write metadata: ${e.message}")
225
245
  }
@@ -65,6 +65,7 @@ int bspatch(const uint8_t* old, int64_t oldsize, uint8_t* new, int64_t newsize,
65
65
  /* Sanity-check */
66
66
  if (ctrl[0]<0 || ctrl[0]>INT_MAX ||
67
67
  ctrl[1]<0 || ctrl[1]>INT_MAX ||
68
+ ctrl[2]<-INT_MAX || ctrl[2]>INT_MAX ||
68
69
  newpos+ctrl[0]>newsize)
69
70
  return -1;
70
71
 
@@ -62,6 +62,17 @@ public class OtaBundleResolver: NSObject {
62
62
  return nil
63
63
  }
64
64
 
65
+ // ── Step 0: Store update → OTA bundles were built for another native version ──
66
+ let otaAppVersion = metadata["appVersion"] as? String ?? ""
67
+ let installedAppVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
68
+ if !otaAppVersion.isEmpty && otaAppVersion != installedAppVersion {
69
+ NSLog("[OtaBundleResolver] Native app \(otaAppVersion) → \(installedAppVersion): discarding OTA state")
70
+ // JS bootstrap deletes ota_trash_* in the background
71
+ let trash = documentsDir.appendingPathComponent("ota_trash_\(Int(Date().timeIntervalSince1970 * 1000))")
72
+ try? FileManager.default.moveItem(at: otaDir, to: trash)
73
+ return nil
74
+ }
75
+
65
76
  let currentVersion = metadata["currentVersion"] as? Int ?? baseVersion
66
77
  let lastLaunchOk = metadata["lastLaunchSuccessful"] as? Bool ?? true
67
78
  let rollbackCount = metadata["rollbackCount"] as? Int ?? 0
@@ -70,7 +81,11 @@ public class OtaBundleResolver: NSObject {
70
81
  if !lastLaunchOk && currentVersion > baseVersion {
71
82
  NSLog("[OtaBundleResolver] Previous launch failed! Rolling back from V\(currentVersion)...")
72
83
 
84
+ // Remember the crashing version so JS never re-applies it
85
+ let failed = (metadata["failedVersions"] as? [Int] ?? []) + [currentVersion]
73
86
  metadata = performRollback(metadata: metadata, baseVersion: baseVersion, currentRollbackCount: rollbackCount, otaDir: otaDir)
87
+ metadata["failedVersions"] = failed
88
+ metadata["appVersion"] = otaAppVersion
74
89
  writeMetadata(url: metadataURL, metadata: metadata)
75
90
 
76
91
  let newVersion = metadata["currentVersion"] as? Int ?? baseVersion
@@ -233,7 +248,7 @@ public class OtaBundleResolver: NSObject {
233
248
  private static func writeMetadata(url: URL, metadata: [String: Any]) {
234
249
  do {
235
250
  let data = try JSONSerialization.data(withJSONObject: metadata, options: .prettyPrinted)
236
- try data.write(to: url)
251
+ try data.write(to: url, options: .atomic)
237
252
  } catch {
238
253
  NSLog("[OtaBundleResolver] Failed to write metadata: \(error.localizedDescription)")
239
254
  }