pulse-updates 1.0.10 → 1.0.12

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pulse Updates
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,12 @@
1
- # Pulse Updates
1
+ <p align="center"><img src="logo.png" width="88" alt="Pulse Updates" /></p>
2
+ <h1 align="center">Pulse Updates — React Native SDK</h1>
3
+ <p align="center">
4
+ <b>Lightweight over-the-air (OTA) updates for React Native.</b><br/>
5
+ Ship JavaScript &amp; assets instantly — a simpler, self-hostable alternative to expo-updates,
6
+ with full New Architecture (Fabric/Bridgeless) support and Ed25519 signature verification.
7
+ </p>
2
8
 
3
- Lightweight OTA (Over-The-Air) updates for React Native apps. A simpler alternative to expo-updates with full support for React Native's New Architecture (Fabric/Bridgeless).
9
+ ---
4
10
 
5
11
  ## Features
6
12
 
@@ -45,8 +51,17 @@ Add these keys to your `Info.plist`:
45
51
  <string>$(MARKETING_VERSION)</string>
46
52
  <key>PulseUpdatesCheckOnLaunch</key>
47
53
  <string>ALWAYS</string>
54
+ <!-- Code signing (REQUIRED in release builds) -->
55
+ <key>PulseUpdatesRequireSignature</key>
56
+ <true/>
57
+ <key>PulseUpdatesSigningKeyId</key>
58
+ <string>your-key-id</string>
59
+ <key>PulseUpdatesSigningPublicKey</key>
60
+ <string>base64-ed25519-public-key</string>
48
61
  ```
49
62
 
63
+ > **Security:** In release builds `PulseUpdatesRequireSignature` defaults to `true`. When it is on, both `PulseUpdatesSigningKeyId` and `PulseUpdatesSigningPublicKey` are **required** — if the public key is missing or a manifest's Ed25519 signature does not verify, the update is rejected (fail-closed) and the embedded bundle is used. See [SECURITY.md](./SECURITY.md).
64
+
50
65
  ### Android Setup
51
66
 
52
67
  Add to your `android/app/build.gradle`:
@@ -64,8 +79,14 @@ Add metadata to `AndroidManifest.xml` inside `<application>`:
64
79
  <meta-data android:name="PulseUpdatesURL" android:value="https://your-update-server.com" />
65
80
  <meta-data android:name="PulseUpdatesRuntimeVersion" android:value="@string/app_version" />
66
81
  <meta-data android:name="PulseUpdatesCheckOnLaunch" android:value="ALWAYS" />
82
+ <!-- Code signing (REQUIRED in release builds) -->
83
+ <meta-data android:name="PulseUpdatesRequireSignature" android:value="true" />
84
+ <meta-data android:name="PulseUpdatesSigningKeyId" android:value="your-key-id" />
85
+ <meta-data android:name="PulseUpdatesSigningPublicKey" android:value="base64-ed25519-public-key" />
67
86
  ```
68
87
 
88
+ > **Security:** In release builds `PulseUpdatesRequireSignature` defaults to `true`. When it is on, both `PulseUpdatesSigningKeyId` and `PulseUpdatesSigningPublicKey` are **required** — if the public key is missing or a manifest's Ed25519 signature does not verify, the update is rejected (fail-closed) and the embedded bundle is used. See [SECURITY.md](./SECURITY.md).
89
+
69
90
  #### New Architecture (Bridgeless) Setup
70
91
 
71
92
  For React Native 0.76+ with New Architecture enabled, update your `MainApplication.kt`:
@@ -119,6 +140,9 @@ class MainApplication : Application(), ReactApplication {
119
140
  | `PulseUpdatesCheckOnLaunch` | string | `ALWAYS` | When to check: `ALWAYS`, `WIFI_ONLY`, `NEVER` |
120
141
  | `PulseUpdatesChannel` | string | `production` | Update channel |
121
142
  | `PulseUpdatesLaunchWaitMs` | number | `0` | Wait time for update check on launch |
143
+ | `PulseUpdatesRequireSignature` | boolean | `true` in release, `false` in debug | Reject manifests without a valid Ed25519 signature (fail-closed). When `true`, the signing keys below are **required** |
144
+ | `PulseUpdatesSigningKeyId` | string | required in release | Key id that must match the manifest's `signature.keyId` |
145
+ | `PulseUpdatesSigningPublicKey` | string | required in release | Base64 Ed25519 public key used to verify the manifest signature |
122
146
 
123
147
  ## JavaScript API
124
148
 
@@ -263,13 +287,38 @@ npx pulse-updates publish \
263
287
  | Option | Description |
264
288
  |--------|-------------|
265
289
  | `--platform` | Target platform: `ios` or `android` (required) |
266
- | `--build` | Build number for this update (required) |
290
+ | `--build` | Build number for this update (optional metadata) |
291
+ | `--dry-run` | Build + validate locally without creating a release on the server |
267
292
  | `--channel` | Update channel (default: `production`) |
268
293
  | `--api-key` | Server API key (or set in `pulse.config.json`) |
269
294
  | `--api-url` | Server URL (auto-detected from native config) |
270
295
  | `--runtime-version` | Runtime version (auto-detected from native config) |
271
296
  | `--message` | Release notes |
272
297
  | `--skip-bundle` | Skip bundle creation (use existing) |
298
+ | `--strict` | Abort the publish if new native modules are detected (default: warn only) |
299
+
300
+ ### Generating Signing Keys
301
+
302
+ Manifest signing uses Ed25519. Generate a matched keypair and paste each half into the right place:
303
+
304
+ ```bash
305
+ npx pulse-updates keygen [--key-id <id>]
306
+ ```
307
+
308
+ It prints the **private** seed for the server (`Pulse:SigningKey` / `SIGNING_KEY`) and the **public**
309
+ key for the app (`PulseUpdatesSigningPublicKey` in Info.plist / AndroidManifest). The server's
310
+ per-app Setup panel and `GET /api/signing` expose the public key too.
311
+
312
+ ### Registering Native Capabilities
313
+
314
+ For capability-based crash-prediction, record what native modules the shipped binary actually provides:
315
+
316
+ ```bash
317
+ npx pulse-updates register-capabilities --platform <ios|android> [--api-key <key>]
318
+ ```
319
+
320
+ Run it at build time (after a native build) so the server can warn when a future JS-only update
321
+ references native modules the installed binary doesn't have.
273
322
 
274
323
  ### Configuration File
275
324
 
@@ -287,11 +336,11 @@ Create `pulse.config.json` in your project root:
287
336
 
288
337
  Pulse Updates requires a compatible server. The server must implement:
289
338
 
290
- - `POST /api/releases` - Create new release
291
- - `POST /api/assets/check` - Check which assets exist
292
- - `POST /api/assets/upload` - Upload assets
293
- - `POST /api/releases/:id/finalize` - Finalize release
294
- - `GET /api/manifest/:appId` - Get latest manifest
339
+ - `POST /api/releases` create a release (CLI, `X-API-Key`)
340
+ - `POST /api/releases/:id/assets/check` check which assets already exist (dedup)
341
+ - `POST /api/releases/:id/assets` upload an asset
342
+ - `POST /api/releases/:id/finalize` finalize the release
343
+ - `GET /pulse/manifest/:appSlug` public signed manifest the on-device SDK fetches
295
344
 
296
345
  ## Embedded Manifest
297
346
 
@@ -99,6 +99,32 @@ class PulseController private constructor() {
99
99
  private var embeddedManifest: EmbeddedManifest? = null
100
100
  private val executor = Executors.newSingleThreadExecutor()
101
101
 
102
+ // Stable, anonymous per-install device id (persisted in the pulse dir). Lazily generated.
103
+ // Used to (a) bucket staged rollouts server-side via the Pulse-Device-Id header and
104
+ // (b) attribute device telemetry events. Failures to read/persist are logged, never silent.
105
+ private var _deviceId: String? = null
106
+ val deviceId: String
107
+ get() {
108
+ _deviceId?.let { return it }
109
+ val dir = directory
110
+ val resolved = if (dir != null) {
111
+ val f = File(dir, "device_id")
112
+ var existing: String? = null
113
+ if (f.exists()) {
114
+ try { existing = f.readText().trim().ifEmpty { null } }
115
+ catch (e: Exception) { pulseLogWarn(TAG, "deviceId: read failed (${e.message}); regenerating") }
116
+ }
117
+ existing ?: java.util.UUID.randomUUID().toString().also { id ->
118
+ try { f.writeText(id) }
119
+ catch (e: Exception) { pulseLogWarn(TAG, "deviceId: persist failed (${e.message}); a new id will be used next launch") }
120
+ }
121
+ } else {
122
+ java.util.UUID.randomUUID().toString()
123
+ }
124
+ _deviceId = resolved
125
+ return resolved
126
+ }
127
+
102
128
  // Cached manifest from last check (to avoid duplicate requests in fetch)
103
129
  // Only valid for the immediate check->fetch sequence, cleared on use or new check
104
130
  private var lastCheckManifest: ManifestModel? = null
@@ -168,7 +194,8 @@ class PulseController private constructor() {
168
194
  launchWaitMs = metadata.getInt("PulseUpdatesLaunchWaitMs", 0),
169
195
  channel = metadata.getString("PulseUpdatesChannel"),
170
196
  signingKeyId = metadata.getString("PulseUpdatesSigningKeyId"),
171
- signingPublicKey = metadata.getString("PulseUpdatesSigningPublicKey")
197
+ signingPublicKey = metadata.getString("PulseUpdatesSigningPublicKey"),
198
+ requireSignature = metadata.getBoolean("PulseUpdatesRequireSignature", true)
172
199
  )
173
200
  } catch (e: Exception) {
174
201
  pulseLogWarn(TAG, "Failed to load native config: ${e.message}")
@@ -220,6 +247,20 @@ class PulseController private constructor() {
220
247
 
221
248
  // Synchronously select the best update for launch
222
249
  selectBestUpdateSync()
250
+
251
+ // Arm crash-rollback on the cold-start path too (parity with start() and iOS). The documented
252
+ // getBundleFile integration goes through here, and initializeWithoutStarting() sets
253
+ // isStarted=true, so a later start() early-returns and would never install recovery — it must
254
+ // be done here or a bad OTA could never auto-roll-back on the documented Android path.
255
+ if (errorRecovery == null) {
256
+ errorRecovery = PulseErrorRecovery().also {
257
+ it.initialize(errorRecoveryDelegate)
258
+ it.startMonitoring()
259
+ }
260
+ }
261
+ // Mark the launched OTA update as "launch in progress" so the health/recovery bookkeeping
262
+ // (consecutive failures, success/failure counts) is correct on this path.
263
+ launchedUpdate?.takeIf { !isEmbeddedLaunch }?.let { database?.recordLaunchStart(it.updateId) }
223
264
  }
224
265
 
225
266
  // Synchronously select the best update (used for initial launch before JS starts)
@@ -520,6 +561,21 @@ class PulseController private constructor() {
520
561
  return null
521
562
  }
522
563
 
564
+ // True when the host app is built debuggable. This is the runtime equivalent of iOS #if DEBUG:
565
+ // this module is an android-library, so its own BuildConfig.DEBUG reflects the library build
566
+ // type, not the host app. FLAG_DEBUGGABLE on the host context is authoritative.
567
+ private fun isHostDebuggable(): Boolean {
568
+ val ctx = context ?: return false
569
+ return (ctx.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
570
+ }
571
+
572
+ // Refuse remote updates when a release build requires signing but no public key is configured.
573
+ // Debug builds are allowed to run unsigned remote updates for local iteration.
574
+ private fun shouldRefuseRemoteUpdates(cfg: PulseUpdatesConfig): Boolean {
575
+ if (isHostDebuggable()) return false
576
+ return cfg.requireSignature && cfg.signingPublicKey.isNullOrEmpty()
577
+ }
578
+
523
579
  fun checkForUpdate(callback: (Result<CheckResult>) -> Unit) {
524
580
  val cfg = config
525
581
  if (cfg == null) {
@@ -532,6 +588,15 @@ class PulseController private constructor() {
532
588
  return
533
589
  }
534
590
 
591
+ // Fail-closed: in non-debug builds, refuse remote updates when signing is not
592
+ // configured. Falls back to the embedded bundle (no remote install).
593
+ if (shouldRefuseRemoteUpdates(cfg)) {
594
+ pulseLogError(TAG, "checkForUpdate: signing required but not configured in a release build; " +
595
+ "refusing remote updates and using embedded bundle")
596
+ callback(Result.failure(PulseUpdatesException("Signing required but not configured")))
597
+ return
598
+ }
599
+
535
600
  executor.execute {
536
601
  // Clear any previous cached manifest before new check
537
602
  lastCheckManifest = null
@@ -552,6 +617,7 @@ class PulseController private constructor() {
552
617
  }
553
618
 
554
619
  lastCheckManifest = checkResult.manifest
620
+ reportEvent("check", checkResult.manifest.updateId)
555
621
  pulseLog(TAG, "checkForUpdate: cached manifest for fetch (ota=$otaCommitTime > embedded=$embeddedCommitTime)")
556
622
  }
557
623
  },
@@ -577,6 +643,15 @@ class PulseController private constructor() {
577
643
  return
578
644
  }
579
645
 
646
+ // Fail-closed: never download/install a remote update in a release build that
647
+ // lacks signing configuration (covers the cachedManifest fast-path that skips check).
648
+ if (shouldRefuseRemoteUpdates(cfg)) {
649
+ pulseLogError(TAG, "fetchUpdate: signing required but not configured in a release build; " +
650
+ "refusing remote updates and using embedded bundle")
651
+ callback(Result.failure(PulseUpdatesException("Signing required but not configured")))
652
+ return
653
+ }
654
+
580
655
  // Use cached manifest from recent check if available
581
656
  var manifestToUse = cachedManifest
582
657
  if (manifestToUse == null && lastCheckManifest != null) {
@@ -588,7 +663,12 @@ class PulseController private constructor() {
588
663
  lastCheckManifest = null
589
664
 
590
665
  executor.execute {
591
- PulseRemoteLoader.fetchUpdate(cfg, database, dir, manifestToUse, callback)
666
+ PulseRemoteLoader.fetchUpdate(cfg, database, dir, manifestToUse) { result ->
667
+ // A freshly downloaded update marks this device as "served" on the server, which is
668
+ // what gates its launch outcomes into the crash-rate auto-rollback.
669
+ result.onSuccess { fr -> if (fr.isNew) reportEvent("download", fr.manifest?.updateId) }
670
+ callback(result)
671
+ }
592
672
  }
593
673
  }
594
674
 
@@ -607,6 +687,7 @@ class PulseController private constructor() {
607
687
  } catch (e: Exception) {
608
688
  pulseLogWarn(TAG, "Failed to record successful launch: ${e.message}")
609
689
  }
690
+ if (!isEmbeddedLaunch) reportEvent("launch_success", update.updateId)
610
691
  }
611
692
 
612
693
  // Run reaper after successful launch
@@ -620,7 +701,52 @@ class PulseController private constructor() {
620
701
  } catch (e: Exception) {
621
702
  pulseLogWarn(TAG, "Failed to record launch failure: ${e.message}")
622
703
  }
704
+ if (!isEmbeddedLaunch) reportEvent("launch_failure", update.updateId)
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Fire-and-forget device telemetry to the server's /pulse/events endpoint, on a daemon thread so
710
+ * it never blocks update work. Best-effort: a failure is logged, never silently swallowed, and
711
+ * never affects the app. The server marks a device "served" on the "download" event — only served
712
+ * devices count toward crash-rate auto-rollback — and uses launch_success/launch_failure to drive
713
+ * that rate. appSlug + base are derived from the manifest URL (.../pulse/manifest/{appSlug}).
714
+ */
715
+ private fun reportEvent(type: String, updateId: String?) {
716
+ if (updateId.isNullOrEmpty()) return
717
+ val cfg = config ?: return
718
+ val marker = "/pulse/manifest/"
719
+ val idx = cfg.updateUrl.indexOf(marker)
720
+ if (idx < 0) {
721
+ pulseLogWarn(TAG, "reportEvent($type): updateUrl '${cfg.updateUrl}' has no $marker — cannot derive events endpoint, skipping")
722
+ return
623
723
  }
724
+ val base = cfg.updateUrl.substring(0, idx)
725
+ val appSlug = cfg.updateUrl.substring(idx + marker.length).substringBefore('/').substringBefore('?')
726
+ val eventsUrl = "$base/pulse/events"
727
+ val device = deviceId
728
+ Thread({
729
+ try {
730
+ val conn = URL(eventsUrl).openConnection() as java.net.HttpURLConnection
731
+ conn.requestMethod = "POST"
732
+ conn.setRequestProperty("Content-Type", "application/json")
733
+ conn.connectTimeout = 5000
734
+ conn.readTimeout = 5000
735
+ conn.doOutput = true
736
+ val body = JSONObject()
737
+ .put("appSlug", appSlug)
738
+ .put("updateId", updateId)
739
+ .put("deviceId", device)
740
+ .put("type", type)
741
+ .toString()
742
+ conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
743
+ val code = conn.responseCode
744
+ if (code !in 200..299) pulseLogWarn(TAG, "reportEvent($type) -> HTTP $code")
745
+ conn.disconnect()
746
+ } catch (e: Exception) {
747
+ pulseLogWarn(TAG, "reportEvent($type) failed: ${e.message}")
748
+ }
749
+ }, "pulse-telemetry").apply { isDaemon = true }.start()
624
750
  }
625
751
 
626
752
  // MARK: - Private Methods
@@ -831,6 +957,13 @@ class PulseController private constructor() {
831
957
  launchedUpdate?.updateId?.let { updateId ->
832
958
  try {
833
959
  database?.recordFailedLaunch(updateId)
960
+ // Exclude this update from future launch selection: launchableUpdates() only
961
+ // returns status IN ('ready','embedded'), so flipping a crashing OTA update to
962
+ // FAILED makes the launcher (and selectBestUpdateSync) skip it and fall back to
963
+ // the previous good/embedded update. Never demote the embedded update itself.
964
+ if (launchedUpdate?.isEmbedded != true) {
965
+ database?.setStatus(updateId, PulseUpdateStatus.FAILED)
966
+ }
834
967
  } catch (e: Exception) {
835
968
  pulseLogWarn(TAG, "Failed to record failed launch: ${e.message}")
836
969
  }
@@ -871,7 +1004,10 @@ data class PulseUpdatesConfig(
871
1004
  val launchWaitMs: Int = 0,
872
1005
  val channel: String? = null,
873
1006
  val signingKeyId: String? = null,
874
- val signingPublicKey: String? = null
1007
+ val signingPublicKey: String? = null,
1008
+ // Fail-closed by default: require a valid Ed25519 signature on remote manifests.
1009
+ // Only meaningful for non-debug builds; debug builds always allow unsigned updates.
1010
+ val requireSignature: Boolean = true
875
1011
  ) {
876
1012
  val scopeKey: String
877
1013
  get() = try {
@@ -1107,6 +1243,9 @@ object PulseRemoteLoader {
1107
1243
  connection.setRequestProperty("Pulse-Protocol-Version", "2")
1108
1244
  connection.setRequestProperty("X-Pulse-Platform", "android")
1109
1245
  connection.setRequestProperty("X-Pulse-Runtime-Version", config.runtimeVersion)
1246
+ // Stable device id so the server can bucket this device into staged rollouts (without it,
1247
+ // a device is held back from any partial rollout until it reaches 100%).
1248
+ connection.setRequestProperty("Pulse-Device-Id", PulseController.getInstance().deviceId)
1110
1249
 
1111
1250
  pulseLog(TAG, "checkForUpdate: url=${config.updateUrl}")
1112
1251
  pulseLog(TAG, "checkForUpdate: runtimeVersion=${config.runtimeVersion}")
@@ -1158,8 +1297,15 @@ object PulseRemoteLoader {
1158
1297
  return
1159
1298
  }
1160
1299
 
1161
- // Verify signature if required
1162
- if (config.signingPublicKey != null) {
1300
+ // Verify signature. When requireSignature is set we MUST have a configured key and
1301
+ // a valid signature (verifyManifestSignature returns false on a missing signature),
1302
+ // so an unsigned/misconfigured release fails closed instead of installing.
1303
+ if (config.requireSignature) {
1304
+ if (config.signingPublicKey.isNullOrEmpty()) {
1305
+ pulseLogError(TAG, "checkForUpdate: requireSignature is set but no signingPublicKey configured; refusing update")
1306
+ callback(Result.failure(PulseUpdatesException("Signature required but no public key configured")))
1307
+ return
1308
+ }
1163
1309
  pulseLog(TAG, "checkForUpdate: verifying signature...")
1164
1310
  if (!verifyManifestSignature(manifest, response, config)) {
1165
1311
  pulseLogWarn(TAG, "checkForUpdate: signature verification failed")
@@ -1167,6 +1313,17 @@ object PulseRemoteLoader {
1167
1313
  return
1168
1314
  }
1169
1315
  pulseLog(TAG, "checkForUpdate: signature valid")
1316
+ } else if (config.signingPublicKey != null && manifest.signature != null) {
1317
+ // Optional mode (e.g. DEBUG): verify only WHEN a signature is present.
1318
+ // verifyManifestSignature returns false on a null signature, so verifying
1319
+ // unconditionally here would wrongly reject legitimately-unsigned DEBUG updates.
1320
+ pulseLog(TAG, "checkForUpdate: verifying signature (optional)...")
1321
+ if (!verifyManifestSignature(manifest, response, config)) {
1322
+ pulseLogWarn(TAG, "checkForUpdate: signature verification failed")
1323
+ callback(Result.failure(PulseUpdatesException("Signature verification failed")))
1324
+ return
1325
+ }
1326
+ pulseLog(TAG, "checkForUpdate: signature valid")
1170
1327
  }
1171
1328
 
1172
1329
  // Check if same update
@@ -1289,19 +1446,26 @@ object PulseRemoteLoader {
1289
1446
 
1290
1447
  // Download bundle (store in assets directory, same as other assets)
1291
1448
  val bundleDest = File(directory, "assets/sha256/$bundleHash")
1292
- if (!bundleDest.exists()) {
1449
+ // Never trust a pre-existing content-addressed file blindly: re-hash it, and if it does not
1450
+ // match (corruption / partial prior write) delete it and re-download. Only skip the download
1451
+ // when the existing file verifies against the manifest hash.
1452
+ var needBundleDownload = true
1453
+ if (bundleDest.exists()) {
1454
+ val existingHash = try { sha256Hex(bundleDest) } catch (e: Exception) { null }
1455
+ if (existingHash == bundleHash) {
1456
+ needBundleDownload = false
1457
+ } else {
1458
+ pulseLogWarn(TAG, "Pre-existing bundle failed re-hash (got=${existingHash?.take(16)} want=${bundleHash.take(16)}), re-downloading")
1459
+ bundleDest.delete()
1460
+ }
1461
+ }
1462
+ if (needBundleDownload) {
1293
1463
  try {
1294
- downloadFile(manifest.bundle.url, bundleDest, stagingDir)
1295
-
1296
- // Verify hash
1297
- val actualHash = sha256Hex(bundleDest)
1298
- if (actualHash != bundleHash) {
1299
- bundleDest.delete()
1300
- database?.setStatus(updateId, PulseUpdateStatus.FAILED)
1301
- callback(Result.failure(PulseUpdatesException("Bundle hash mismatch")))
1302
- return
1303
- }
1464
+ // downloadFile now verifies the SHA-256 on the staging temp BEFORE the atomic move,
1465
+ // so a hash mismatch never produces a file at bundleDest.
1466
+ downloadFile(manifest.bundle.url, bundleDest, stagingDir, bundleHash)
1304
1467
  } catch (e: Exception) {
1468
+ bundleDest.delete()
1305
1469
  database?.setStatus(updateId, PulseUpdateStatus.FAILED)
1306
1470
  stagingDir.deleteRecursively()
1307
1471
  callback(Result.failure(e))
@@ -1335,29 +1499,40 @@ object PulseRemoteLoader {
1335
1499
  val assetHash = asset.hash.lowercase()
1336
1500
  val assetDest = File(directory, "assets/sha256/$assetHash")
1337
1501
 
1338
- // Skip download if already cached
1502
+ // Skip download only if a pre-existing cached file actually re-hashes correctly;
1503
+ // otherwise treat it as missing and (re)download. Never reuse a file without re-hash.
1339
1504
  if (assetDest.exists()) {
1340
- // Asset already in cache, just link it
1505
+ val existingHash = try { sha256Hex(assetDest) } catch (e: Exception) { null }
1506
+ if (existingHash != assetHash) {
1507
+ pulseLogWarn(TAG, "Pre-existing asset failed re-hash for ${asset.key}, re-downloading")
1508
+ assetDest.delete()
1509
+ try {
1510
+ downloadFile(asset.url, assetDest, stagingDir, assetHash)
1511
+ } catch (e: Exception) {
1512
+ assetDest.delete()
1513
+ database?.setStatus(updateId, PulseUpdateStatus.FAILED)
1514
+ stagingDir.deleteRecursively()
1515
+ callback(Result.failure(PulseUpdatesException("Asset download failed for ${asset.key}: ${e.message}")))
1516
+ return
1517
+ }
1518
+ }
1341
1519
  }
1342
- // Skip download if exists in embedded assets
1520
+ // Use embedded fallback at runtime - nothing to download, just link below
1343
1521
  else if (embeddedHashes.contains(assetHash)) {
1344
1522
  pulseLog(TAG, "Asset ${assetHash.take(16)}... exists in embedded, skipping download")
1345
- // Don't download - will use embedded fallback at runtime
1346
1523
  }
1347
- // Download if not exists anywhere
1524
+ // Not present anywhere: download, verifying hash on the staging temp before the move.
1525
+ // ALL-OR-NOTHING: any failure aborts the whole update instead of leaving it READY with a
1526
+ // missing/corrupt asset (matches iOS downloadAssets which fails the batch on first error).
1348
1527
  else {
1349
1528
  try {
1350
- downloadFile(asset.url, assetDest, stagingDir)
1351
-
1352
- val actualHash = sha256Hex(assetDest)
1353
- if (actualHash != assetHash) {
1354
- assetDest.delete()
1355
- pulseLogWarn(TAG, "Asset hash mismatch for ${asset.key}")
1356
- continue
1357
- }
1529
+ downloadFile(asset.url, assetDest, stagingDir, assetHash)
1358
1530
  } catch (e: Exception) {
1359
- pulseLogWarn(TAG, "Failed to download asset: ${e.message}")
1360
- continue
1531
+ assetDest.delete()
1532
+ database?.setStatus(updateId, PulseUpdateStatus.FAILED)
1533
+ stagingDir.deleteRecursively()
1534
+ callback(Result.failure(PulseUpdatesException("Asset download failed for ${asset.key}: ${e.message}")))
1535
+ return
1361
1536
  }
1362
1537
  }
1363
1538
 
@@ -1389,17 +1564,39 @@ object PulseRemoteLoader {
1389
1564
  callback(Result.success(Unit))
1390
1565
  }
1391
1566
 
1392
- private fun downloadFile(url: String, destination: File, stagingDir: File) {
1567
+ private fun downloadFile(url: String, destination: File, stagingDir: File, expectedHash: String? = null) {
1393
1568
  destination.parentFile?.mkdirs()
1394
1569
  val tempFile = File(stagingDir, "temp_${System.currentTimeMillis()}")
1395
1570
 
1396
- URL(url).openStream().use { input ->
1397
- tempFile.outputStream().use { output ->
1398
- input.copyTo(output)
1571
+ try {
1572
+ URL(url).openStream().use { input ->
1573
+ tempFile.outputStream().use { output ->
1574
+ input.copyTo(output)
1575
+ }
1576
+ }
1577
+
1578
+ // Verify SHA-256 on the STAGING TEMP before it ever lands at the content-addressed
1579
+ // destination. A mismatch must never produce a file at `destination`.
1580
+ if (expectedHash != null) {
1581
+ val actualHash = sha256Hex(tempFile)
1582
+ if (actualHash != expectedHash) {
1583
+ throw PulseUpdatesException("Hash mismatch: expected ${expectedHash.take(16)}, got ${actualHash.take(16)}")
1584
+ }
1585
+ }
1586
+
1587
+ // Atomic move into place. rename can fail across boundaries or if the target exists;
1588
+ // delete any stale target first and treat a failed rename as a hard error so callers
1589
+ // can abort the all-or-nothing apply.
1590
+ destination.delete()
1591
+ if (!tempFile.renameTo(destination)) {
1592
+ throw PulseUpdatesException("Failed to move verified file into place: ${destination.name}")
1593
+ }
1594
+ } finally {
1595
+ // Never leave a partial/temp file behind, regardless of success or failure.
1596
+ if (tempFile.exists()) {
1597
+ tempFile.delete()
1399
1598
  }
1400
1599
  }
1401
-
1402
- tempFile.renameTo(destination)
1403
1600
  }
1404
1601
 
1405
1602
  private fun sha256Hex(file: File): String {
@@ -238,6 +238,13 @@ class PulseDatabase(context: Context, directory: File) : SQLiteOpenHelper(
238
238
 
239
239
  fun recordSuccessfulLaunch(updateId: String) {
240
240
  val now = System.currentTimeMillis()
241
+ // Upsert: on Android recordLaunchStart() is never called in the launch path, so the health
242
+ // row may not exist yet. A bare UPDATE would be a silent no-op and successful_launch_count
243
+ // would stay 0, which the error-recovery pipeline reads to decide whether to roll back.
244
+ writableDatabase.execSQL(
245
+ "INSERT OR IGNORE INTO health (update_id) VALUES (?)",
246
+ arrayOf(updateId)
247
+ )
241
248
  writableDatabase.execSQL("""
242
249
  UPDATE health SET
243
250
  app_ready_at = ?,
@@ -249,6 +256,13 @@ class PulseDatabase(context: Context, directory: File) : SQLiteOpenHelper(
249
256
  }
250
257
 
251
258
  fun recordFailedLaunch(updateId: String) {
259
+ // Upsert (see recordSuccessfulLaunch): the health row may not exist because recordLaunchStart
260
+ // is not wired into the Android launch path. Without the INSERT this UPDATE is a silent
261
+ // no-op and failed_launch_count never increments, defeating crash-rollback bookkeeping.
262
+ writableDatabase.execSQL(
263
+ "INSERT OR IGNORE INTO health (update_id) VALUES (?)",
264
+ arrayOf(updateId)
265
+ )
252
266
  writableDatabase.execSQL("""
253
267
  UPDATE health SET
254
268
  failed_launch_count = failed_launch_count + 1,
@@ -29,6 +29,11 @@ class PulseErrorRecovery {
29
29
  private var delegate: PulseErrorRecoveryDelegate? = null
30
30
  private var shouldHandleException = false
31
31
 
32
+ // Previous JVM uncaught-exception handler, so we can chain/restore it (Android analog of
33
+ // iOS RCTGet/SetFatalHandler). Captured when we install ours in startMonitoring().
34
+ private var previousUncaughtHandler: Thread.UncaughtExceptionHandler? = null
35
+ private var pulseUncaughtHandler: Thread.UncaughtExceptionHandler? = null
36
+
32
37
  fun initialize(delegate: PulseErrorRecoveryDelegate) {
33
38
  this.delegate = delegate
34
39
  if (!::handler.isInitialized) {
@@ -40,6 +45,43 @@ class PulseErrorRecovery {
40
45
  fun startMonitoring() {
41
46
  registerContentAppearedListener()
42
47
  shouldHandleException = true
48
+ installUncaughtExceptionHandler()
49
+ }
50
+
51
+ /**
52
+ * Install a JVM-level uncaught exception handler that routes crashes into the error recovery
53
+ * pipeline before delegating to the previously installed handler. This is the Android analog of
54
+ * iOS setRCTErrorHandlers(): without it, PulseErrorRecoveryHandler has no entry point and bad
55
+ * updates can brick the app.
56
+ */
57
+ private fun installUncaughtExceptionHandler() {
58
+ if (pulseUncaughtHandler != null) return
59
+
60
+ previousUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler()
61
+ val handler = Thread.UncaughtExceptionHandler { thread, throwable ->
62
+ try {
63
+ if (shouldHandleException) {
64
+ handleException(throwable as? Exception ?: RuntimeException(throwable))
65
+ }
66
+ } catch (e: Exception) {
67
+ pulseLogError(TAG, "ErrorRecovery: failed to handle uncaught exception: ${e.message}")
68
+ } finally {
69
+ previousUncaughtHandler?.uncaughtException(thread, throwable)
70
+ }
71
+ }
72
+ pulseUncaughtHandler = handler
73
+ Thread.setDefaultUncaughtExceptionHandler(handler)
74
+ }
75
+
76
+ private fun uninstallUncaughtExceptionHandler() {
77
+ // Only restore if our handler is still the active one (avoid clobbering a handler
78
+ // installed after us).
79
+ if (pulseUncaughtHandler != null &&
80
+ Thread.getDefaultUncaughtExceptionHandler() === pulseUncaughtHandler) {
81
+ Thread.setDefaultUncaughtExceptionHandler(previousUncaughtHandler)
82
+ }
83
+ pulseUncaughtHandler = null
84
+ previousUncaughtHandler = null
43
85
  }
44
86
 
45
87
  /**
@@ -65,6 +107,7 @@ class PulseErrorRecovery {
65
107
  // wait 10s before unsetting error handlers
66
108
  handler.postDelayed({
67
109
  shouldHandleException = false
110
+ uninstallUncaughtExceptionHandler()
68
111
  handler.postDelayed({ handlerThread.quitSafely() }, 10000)
69
112
  }, 10000)
70
113
  }
@@ -519,15 +519,22 @@ class PulseDefaultSelectionPolicy : PulseSelectionPolicy {
519
519
  * Priority: 1) Ready updates (newest by commitTime), 2) Embedded updates (fallback)
520
520
  */
521
521
  override fun selectUpdateToLaunch(updates: List<PulseUpdate>): PulseUpdate? {
522
- // First, try to find the newest ready (downloaded) update
523
- val readyUpdates = updates.filter { it.status == PulseUpdateStatus.READY }
522
+ // Exclude updates that failed to launch and were never confirmed good
523
+ // (failedLaunchCount > 0 && successfulLaunchCount == 0) so a bad update can't be
524
+ // re-selected on every launch — a crash loop that can effectively brick the app.
525
+ // Mirrors iOS PulseDefaultSelectionPolicy.selectUpdateToLaunch.
526
+ val healthy = updates.filter { !(it.failedLaunchCount > 0 && it.successfulLaunchCount == 0) }
527
+
528
+ // First, try to find the newest ready (downloaded) healthy update
529
+ val readyUpdates = healthy.filter { it.status == PulseUpdateStatus.READY }
524
530
  val newestReady = readyUpdates.maxByOrNull { it.commitTime.time }
525
531
  if (newestReady != null) {
526
532
  return newestReady
527
533
  }
528
534
 
529
- // Fall back to embedded update
530
- return updates.find { it.status == PulseUpdateStatus.EMBEDDED }
535
+ // Fall back to embedded update (embedded is always launchable).
536
+ return healthy.find { it.status == PulseUpdateStatus.EMBEDDED }
537
+ ?: updates.find { it.status == PulseUpdateStatus.EMBEDDED }
531
538
  }
532
539
 
533
540
  override fun selectUpdatesToDelete(launchedUpdate: PulseUpdateInfo, allUpdates: List<PulseUpdateInfo>): List<PulseUpdateInfo> {