pulse-updates 1.4.0 → 1.4.1

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.
@@ -40,6 +40,25 @@ internal fun pulseLogError(tag: String, message: String) {
40
40
  println("[$tag] ERROR: $message")
41
41
  }
42
42
 
43
+ /**
44
+ * Streaming sha256 of a file, lowercase hex.
45
+ *
46
+ * File-level because the asset store is content-addressed, so both halves of this file need it:
47
+ * PulseRemoteLoader to check what it just downloaded, PulseController to check what it is about to
48
+ * launch. It used to be private to the loader, which is why nothing verified the launch path.
49
+ */
50
+ internal fun sha256Hex(file: File): String {
51
+ val digest = MessageDigest.getInstance("SHA-256")
52
+ file.inputStream().use { fis ->
53
+ val buffer = ByteArray(8192)
54
+ var bytesRead: Int
55
+ while (fis.read(buffer).also { bytesRead = it } != -1) {
56
+ digest.update(buffer, 0, bytesRead)
57
+ }
58
+ }
59
+ return digest.digest().joinToString("") { "%02x".format(it) }
60
+ }
61
+
43
62
  /**
44
63
  * PulseUpdates Controller
45
64
  * Based on expo-updates AppController
@@ -373,10 +392,34 @@ class PulseController private constructor() {
373
392
  pulseLog(TAG, "selectBestUpdateSync: using embedded bundle (default RN loading)")
374
393
  } else if (selectedUpdate.bundleHash != null) {
375
394
  val bundlePath = File(directory, "assets/sha256/${selectedUpdate.bundleHash.lowercase()}")
376
- val exists = bundlePath.exists()
377
- pulseLog(TAG, "selectBestUpdateSync: bundlePath exists=$exists")
378
-
379
- if (exists) {
395
+ // Hash it on the launches that can actually be holding a broken file: one that has
396
+ // never started successfully. A download cut short is broken from the moment it
397
+ // lands, so the first launch is where the check pays; after an update has run once
398
+ // the bytes were proven good, and re-hashing megabytes on every cold start after
399
+ // that would be a startup cost with nothing to find.
400
+ val usable = if (selectedUpdate.successfulLaunchCount > 0) {
401
+ bundlePath.isFile && bundlePath.length() > 0L
402
+ } else {
403
+ isBundleIntact(bundlePath, selectedUpdate.bundleHash)
404
+ }
405
+ pulseLog(TAG, "selectBestUpdateSync: bundlePath usable=$usable")
406
+
407
+ if (!usable) {
408
+ // A bundle that is present but not intact is worse than a missing one: it
409
+ // passes an existence check, reaches JSBundleLoader.createFileLoader(), and
410
+ // React Native throws out of loadJSBundleFromFile() before any JS runs — so
411
+ // nothing marks the launch failed and the same file is chosen again on every
412
+ // start. That is a permanently unlaunchable app, fixable only by reinstalling.
413
+ // Retire the update instead: 'failed' is excluded from launchableUpdates(), so
414
+ // the next selection falls through to the embedded bundle.
415
+ pulseLogError(
416
+ TAG,
417
+ "selectBestUpdateSync: bundle for ${selectedUpdate.updateId} is missing or " +
418
+ "corrupt, marking failed and falling back to the embedded bundle"
419
+ )
420
+ db.recordFailedLaunch(selectedUpdate.updateId)
421
+ db.setStatus(selectedUpdate.updateId, PulseUpdateStatus.FAILED)
422
+ } else {
380
423
  launchAssetFile = bundlePath
381
424
  isEmbeddedLaunch = false
382
425
 
@@ -625,13 +668,53 @@ class PulseController private constructor() {
625
668
  // For embedded bundles, return null to let React Native use its default mechanism
626
669
  // This ensures images from drawable folders work correctly
627
670
  val launchFile = launchAssetFile
628
- if (launchFile != null && launchFile.exists()) {
671
+ // exists() is not enough here — an empty or truncated file exists. Handing one to
672
+ // JSBundleLoader.createFileLoader() throws out of ReactInstance.loadJSBundleFromFile()
673
+ // before JS starts, which no recovery path can catch. Returning null costs the OTA
674
+ // update; returning a broken path costs the app.
675
+ if (launchFile != null && launchFile.isFile && launchFile.length() > 0L) {
629
676
  return launchFile
630
677
  }
678
+ if (launchFile != null) {
679
+ pulseLogError(TAG, "getBundleFile: ${launchFile.absolutePath} is not a usable bundle, using the embedded one")
680
+ }
631
681
  // Return null to use default bundle loading (from APK assets)
632
682
  return null
633
683
  }
634
684
 
685
+ // Remembers the verdict for a bundle file so a relaunch (jsBundleLoader is re-queried on every
686
+ // reload) does not re-hash several megabytes on the launch path. Keyed by identity AND by the
687
+ // file's size and mtime, so a file replaced underneath us is hashed again.
688
+ private var intactBundleKey: String? = null
689
+
690
+ /**
691
+ * Whether [file] is a bundle we can actually hand to React Native.
692
+ *
693
+ * The store is content-addressed — the file name IS the sha256 of the bytes — so an exact
694
+ * check is available and cheap, and nothing was doing it. A download interrupted by a kill or
695
+ * a full disk leaves a short file at the right path; it satisfies exists(), and every launch
696
+ * afterwards dies in loadJSBundleFromFile().
697
+ */
698
+ private fun isBundleIntact(file: File, expectedHash: String): Boolean {
699
+ if (!file.isFile || file.length() == 0L) return false
700
+
701
+ val key = "${file.absolutePath}:${file.length()}:${file.lastModified()}"
702
+ if (key == intactBundleKey) return true
703
+
704
+ val actual = try {
705
+ sha256Hex(file)
706
+ } catch (e: Exception) {
707
+ pulseLogError(TAG, "isBundleIntact: could not read ${file.absolutePath}: ${e.message}")
708
+ return false
709
+ }
710
+ if (!actual.equals(expectedHash, ignoreCase = true)) {
711
+ pulseLogError(TAG, "isBundleIntact: ${file.name} hashes to $actual, expected $expectedHash")
712
+ return false
713
+ }
714
+ intactBundleKey = key
715
+ return true
716
+ }
717
+
635
718
  // True when the host app is built debuggable. This is the runtime equivalent of iOS #if DEBUG:
636
719
  // this module is an android-library, so its own BuildConfig.DEBUG reflects the library build
637
720
  // type, not the host app. FLAG_DEBUGGABLE on the host context is authoritative.
@@ -1721,18 +1804,6 @@ object PulseRemoteLoader {
1721
1804
  }
1722
1805
  }
1723
1806
 
1724
- private fun sha256Hex(file: File): String {
1725
- val digest = MessageDigest.getInstance("SHA-256")
1726
- file.inputStream().use { fis ->
1727
- val buffer = ByteArray(8192)
1728
- var bytesRead: Int
1729
- while (fis.read(buffer).also { bytesRead = it } != -1) {
1730
- digest.update(buffer, 0, bytesRead)
1731
- }
1732
- }
1733
- return digest.digest().joinToString("") { "%02x".format(it) }
1734
- }
1735
-
1736
1807
  // MARK: - Signature Verification
1737
1808
 
1738
1809
  private fun verifyManifestSignature(
@@ -122,10 +122,13 @@ class PulseAppLauncher(
122
122
 
123
123
  if (assets.isEmpty()) {
124
124
  // No assets to verify, just find the bundle
125
+ var intact = false
125
126
  update.bundleHash?.let { hash ->
126
127
  launchAssetFile = bundleStorePath(hash)
128
+ intact = bundleIsIntact(launchAssetFile, hash)
127
129
  }
128
- callback(launchAssetFile?.exists() == true, if (launchAssetFile?.exists() != true) LauncherException("Bundle not found") else null)
130
+ if (!intact) launchAssetFile = null
131
+ callback(intact, if (intact) null else LauncherException("Bundle missing or corrupt"))
129
132
  return
130
133
  }
131
134
 
@@ -165,8 +168,9 @@ class PulseAppLauncher(
165
168
  // All assets accounted for, but we need the bundle
166
169
  update.bundleHash?.let { hash ->
167
170
  launchAssetFile = bundleStorePath(hash)
168
- if (launchAssetFile?.exists() != true) {
169
- callback(false, LauncherException("Bundle not found"))
171
+ if (!bundleIsIntact(launchAssetFile, hash)) {
172
+ launchAssetFile = null
173
+ callback(false, LauncherException("Bundle missing or corrupt"))
170
174
  return
171
175
  }
172
176
  }
@@ -328,6 +332,25 @@ class PulseAppLauncher(
328
332
  return File(directory, "assets/sha256/${hash.lowercase()}")
329
333
  }
330
334
 
335
+ /**
336
+ * Whether the launch bundle is not just present but whole.
337
+ *
338
+ * exists() says a file is there, not that it is the file we downloaded. A write cut short by a
339
+ * kill or a full disk leaves a short one at the right path, and React Native does not survive
340
+ * being handed it: loadJSBundleFromFile() throws before any JS runs, so no recovery hook fires
341
+ * and the same file is picked again on the next launch. The store is content-addressed, so the
342
+ * name is the answer — compare it.
343
+ */
344
+ private fun bundleIsIntact(file: File?, expectedHash: String): Boolean {
345
+ if (file == null || !file.isFile || file.length() == 0L) return false
346
+ return try {
347
+ sha256Hex(file).equals(expectedHash, ignoreCase = true)
348
+ } catch (e: Exception) {
349
+ pulseLogError(TAG, "Could not hash ${file.absolutePath}: ${e.message}")
350
+ false
351
+ }
352
+ }
353
+
331
354
  // MARK: - Embedded Assets Map
332
355
 
333
356
  private fun buildEmbeddedAssetsMap(): Map<String, String> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-updates",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Pulse app-experience SDK for React Native: updates, config, experiments, events, decisions and first-party links",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",