pulse-updates 1.5.1 → 1.5.2

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.
@@ -3,6 +3,7 @@
3
3
  // Handles launching updates with asset fallback support
4
4
 
5
5
  import Foundation
6
+ import CryptoKit
6
7
 
7
8
  // MARK: - Launcher Protocol
8
9
 
@@ -132,7 +133,8 @@ final class PulseAppLauncher {
132
133
  if assets.isEmpty {
133
134
  // No assets to verify, just find the bundle
134
135
  if let bundleHash = update.bundleHash {
135
- launchAssetUrl = bundleStorePath(hash: bundleHash)
136
+ let candidate = bundleStorePath(hash: bundleHash)
137
+ launchAssetUrl = bundleIsIntact(at: candidate, expectedHash: bundleHash) ? candidate : nil
136
138
  }
137
139
  completion(launchAssetUrl != nil, launchAssetUrl == nil ? PulseLauncherError.bundleNotFound : nil)
138
140
  return
@@ -171,7 +173,8 @@ final class PulseAppLauncher {
171
173
  // All assets accounted for, but we need the bundle
172
174
  if let bundleHash = update.bundleHash {
173
175
  launchAssetUrl = bundleStorePath(hash: bundleHash)
174
- if !FileManager.default.fileExists(atPath: launchAssetUrl!.path) {
176
+ if !bundleIsIntact(at: launchAssetUrl, expectedHash: bundleHash) {
177
+ launchAssetUrl = nil
175
178
  completion(false, PulseLauncherError.bundleNotFound)
176
179
  return
177
180
  }
@@ -374,6 +377,34 @@ final class PulseAppLauncher {
374
377
  .appendingPathComponent(hash.lowercased())
375
378
  }
376
379
 
380
+ /// Whether the launch bundle is not just present but whole.
381
+ ///
382
+ /// `fileExists` says a file is there, not that it is the file we downloaded. A write cut short
383
+ /// by a kill or a full disk leaves a short one at the right path, and React Native does not
384
+ /// survive being handed it: it throws while loading the bundle before any JS runs, so no
385
+ /// recovery hook fires and the same file is picked again on the next launch. The store is
386
+ /// content-addressed — the name IS the sha256 of the bytes — so compare it.
387
+ ///
388
+ /// `PulseController.selectBestUpdateSync` already does this on the primary launch path; this
389
+ /// closes the same gap here, and matches `PulseAppLauncher.bundleIsIntact` on Android.
390
+ private func bundleIsIntact(at url: URL?, expectedHash: String) -> Bool {
391
+ guard let url, let handle = try? FileHandle(forReadingFrom: url) else { return false }
392
+ defer { try? handle.close() }
393
+
394
+ var hasher = SHA256()
395
+ var sawBytes = false
396
+ while true {
397
+ let chunk = try? handle.read(upToCount: 1024 * 1024)
398
+ guard let data = chunk, !data.isEmpty else { break }
399
+ sawBytes = true
400
+ hasher.update(data: data)
401
+ }
402
+ guard sawBytes else { return false }
403
+
404
+ let actual = hasher.finalize().map { String(format: "%02x", $0) }.joined()
405
+ return actual == expectedHash.lowercased()
406
+ }
407
+
377
408
  private func bundleStorePath(hash: String) -> URL {
378
409
  // Bundle is stored in assets directory (same location as other assets)
379
410
  return directory
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-updates",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
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",