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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
169
|
-
|
|
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.
|
|
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",
|