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.
@@ -284,33 +284,12 @@ final class PulseAppLauncher {
284
284
  // Get bundle path using FileManager (handles deep nested paths)
285
285
  DispatchQueue.global(qos: .userInitiated).async {
286
286
  let bundleRoot = Bundle.main.bundlePath
287
- let fileManager = FileManager.default
288
- let ext = assetType.components(separatedBy: "/").last ?? "png"
289
- var sourcePath: String? = nil
290
-
291
- // 1. Try in assets folder with subdirectory path
292
- if let dir = match.nsBundleDir, !dir.isEmpty {
293
- let path = "\(bundleRoot)/assets/\(dir)/\(bundleFilename).\(ext)"
294
- if fileManager.fileExists(atPath: path) {
295
- sourcePath = path
296
- }
297
- }
298
-
299
- // 2. Try without assets/ prefix
300
- if sourcePath == nil, let dir = match.nsBundleDir, !dir.isEmpty {
301
- let path = "\(bundleRoot)/\(dir)/\(bundleFilename).\(ext)"
302
- if fileManager.fileExists(atPath: path) {
303
- sourcePath = path
304
- }
305
- }
306
-
307
- // 3. Try at root
308
- if sourcePath == nil {
309
- let path = "\(bundleRoot)/\(bundleFilename).\(ext)"
310
- if fileManager.fileExists(atPath: path) {
311
- sourcePath = path
312
- }
313
- }
287
+ let sourcePath = findBundledAssetPath(
288
+ bundleRoot: bundleRoot,
289
+ filename: bundleFilename,
290
+ assetType: assetType,
291
+ nsBundleDir: match.nsBundleDir
292
+ )
314
293
 
315
294
  guard let finalSourcePath = sourcePath else {
316
295
  self.launcherQueue.async {
@@ -360,10 +339,10 @@ final class PulseAppLauncher {
360
339
  // Download file
361
340
  let data = try Data(contentsOf: remoteUrl)
362
341
 
363
- // Verify hash if expected
342
+ // Verify hash if expected (hex, matching the manifest/DB encoding).
364
343
  if let expectedHash = asset.expectedHash {
365
- let actualHash = PulseCrypto.sha256Base64(data)
366
- if actualHash != expectedHash {
344
+ let actualHash = PulseCrypto.sha256Hex(data)
345
+ if actualHash != expectedHash.lowercased() {
367
346
  self.launcherQueue.async {
368
347
  completion(false, PulseLauncherError.hashMismatch(expected: expectedHash, actual: actualHash))
369
348
  }
@@ -414,7 +393,6 @@ final class PulseAppLauncher {
414
393
 
415
394
  var map: [String: String] = [:]
416
395
  let bundleRoot = Bundle.main.bundlePath
417
- let fileManager = FileManager.default
418
396
 
419
397
  for asset in embedded.assets where !asset.isLaunchAsset {
420
398
  guard let filename = asset.nsBundleFilename,
@@ -422,33 +400,12 @@ final class PulseAppLauncher {
422
400
  continue
423
401
  }
424
402
 
425
- // Get file extension from type (e.g., "image/png" -> "png")
426
- let ext = assetType.components(separatedBy: "/").last ?? "png"
427
- var bundlePath: String? = nil
428
-
429
- // 1. Try in assets folder with subdirectory path (RN stores assets here)
430
- if let dir = asset.nsBundleDir, !dir.isEmpty {
431
- let path = "\(bundleRoot)/assets/\(dir)/\(filename).\(ext)"
432
- if fileManager.fileExists(atPath: path) {
433
- bundlePath = path
434
- }
435
- }
436
-
437
- // 2. Try without assets/ prefix
438
- if bundlePath == nil, let dir = asset.nsBundleDir, !dir.isEmpty {
439
- let path = "\(bundleRoot)/\(dir)/\(filename).\(ext)"
440
- if fileManager.fileExists(atPath: path) {
441
- bundlePath = path
442
- }
443
- }
444
-
445
- // 3. Try at root
446
- if bundlePath == nil {
447
- let path = "\(bundleRoot)/\(filename).\(ext)"
448
- if fileManager.fileExists(atPath: path) {
449
- bundlePath = path
450
- }
451
- }
403
+ let bundlePath = findBundledAssetPath(
404
+ bundleRoot: bundleRoot,
405
+ filename: filename,
406
+ assetType: assetType,
407
+ nsBundleDir: asset.nsBundleDir
408
+ )
452
409
 
453
410
  if let path = bundlePath {
454
411
  let url = URL(fileURLWithPath: path).absoluteString
@@ -621,15 +578,20 @@ struct PulseDefaultSelectionPolicy: PulseSelectionPolicy {
621
578
 
622
579
  /// Select the best update to launch
623
580
  /// Priority: 1) Ready updates (newest by commitTime), 2) Embedded updates (fallback)
581
+ /// Excludes updates that failed to launch and were never confirmed good
582
+ /// (failedLaunchCount > 0 && successfulLaunchCount == 0) so a bad update can't be re-selected.
624
583
  func selectUpdateToLaunch(from updates: [PulseUpdate]) -> PulseUpdate? {
584
+ // Exclude updates that failed to launch and were never confirmed good.
585
+ let healthy = updates.filter { !($0.failedLaunchCount > 0 && $0.successfulLaunchCount == 0) }
586
+
625
587
  // First, try to find the newest ready (downloaded) update
626
- let readyUpdates = updates.filter { $0.status == .ready }
588
+ let readyUpdates = healthy.filter { $0.status == .ready }
627
589
  if let newest = readyUpdates.sorted(by: { $0.commitTime > $1.commitTime }).first {
628
590
  return newest
629
591
  }
630
592
 
631
- // Fall back to embedded update
632
- return updates.first { $0.status == .embedded }
593
+ // Fall back to embedded update (embedded is always launchable).
594
+ return healthy.first { $0.status == .embedded } ?? updates.first { $0.status == .embedded }
633
595
  }
634
596
 
635
597
  /// Select updates to delete - keeps launched update and one backup
@@ -671,12 +633,14 @@ struct PulseDefaultSelectionPolicy: PulseSelectionPolicy {
671
633
  // MARK: - Crypto Helper
672
634
 
673
635
  struct PulseCrypto {
674
- static func sha256Base64(_ data: Data) -> String {
636
+ /// Lowercase hex SHA-256. Manifests and the asset DB store hashes as hex, so all
637
+ /// integrity comparisons must use hex (not base64).
638
+ static func sha256Hex(_ data: Data) -> String {
675
639
  var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
676
640
  data.withUnsafeBytes {
677
641
  _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
678
642
  }
679
- return Data(hash).base64EncodedString()
643
+ return hash.map { String(format: "%02x", $0) }.joined()
680
644
  }
681
645
  }
682
646
 
@@ -4,6 +4,64 @@
4
4
 
5
5
  import Foundation
6
6
  import Network
7
+ import CryptoKit
8
+
9
+ // MARK: - Asset Path Resolution
10
+
11
+ /// Find a bundled asset file by filename and type.
12
+ /// Handles MIME types ("image/png"), plain extensions ("riv", "png"), and "unknown" type.
13
+ /// Falls back to scanning the directory for files matching the filename prefix.
14
+ internal func findBundledAssetPath(
15
+ bundleRoot: String,
16
+ filename: String,
17
+ assetType: String,
18
+ nsBundleDir: String?
19
+ ) -> String? {
20
+ let fileManager = FileManager.default
21
+
22
+ // Derive extension from type
23
+ let ext: String
24
+ if assetType.contains("/") {
25
+ // MIME type: extract after "/" (e.g., "image/png" -> "png")
26
+ ext = assetType.components(separatedBy: "/").last ?? "png"
27
+ } else if assetType != "unknown" {
28
+ // Plain extension (e.g., "riv", "png")
29
+ ext = assetType
30
+ } else {
31
+ ext = "unknown"
32
+ }
33
+
34
+ // Build search directories
35
+ var searchDirs: [String] = []
36
+ if let dir = nsBundleDir, !dir.isEmpty {
37
+ searchDirs.append("\(bundleRoot)/assets/\(dir)")
38
+ searchDirs.append("\(bundleRoot)/\(dir)")
39
+ }
40
+ searchDirs.append(bundleRoot)
41
+
42
+ // Try with the derived extension
43
+ if ext != "unknown" {
44
+ for dir in searchDirs {
45
+ let path = "\(dir)/\(filename).\(ext)"
46
+ if fileManager.fileExists(atPath: path) {
47
+ return path
48
+ }
49
+ }
50
+ }
51
+
52
+ // Fallback: scan directories for files matching the filename prefix
53
+ // This handles "unknown" type and any other extension mismatches (e.g., .riv files
54
+ // in builds where the embedded manifest had type="unknown" for non-standard assets)
55
+ for dir in searchDirs {
56
+ if let files = try? fileManager.contentsOfDirectory(atPath: dir) {
57
+ for file in files where file.hasPrefix("\(filename).") && file != "\(filename)." {
58
+ return "\(dir)/\(file)"
59
+ }
60
+ }
61
+ }
62
+
63
+ return nil
64
+ }
7
65
 
8
66
  // MARK: - Controller Delegate
9
67
 
@@ -53,6 +111,24 @@ public final class PulseController {
53
111
  /// Remote load status for error recovery
54
112
  public private(set) var remoteLoadStatus: PulseRemoteLoadStatus = .idle
55
113
 
114
+ /// Stable, anonymous per-install device id (persisted in the pulse dir). Used to bucket staged
115
+ /// rollouts server-side via the Pulse-Device-Id header and to attribute telemetry events.
116
+ private var _deviceId: String?
117
+ public var deviceId: String {
118
+ if let id = _deviceId { return id }
119
+ let file = directory.appendingPathComponent("device_id")
120
+ if let data = try? Data(contentsOf: file),
121
+ let s = String(data: data, encoding: .utf8) {
122
+ let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
123
+ if !trimmed.isEmpty { _deviceId = trimmed; return trimmed }
124
+ }
125
+ let id = UUID().uuidString
126
+ do { try id.data(using: .utf8)?.write(to: file) }
127
+ catch { pulseLog("deviceId: persist failed (\(error.localizedDescription)); a new id will be used next launch") }
128
+ _deviceId = id
129
+ return id
130
+ }
131
+
56
132
  // MARK: - Private Properties
57
133
 
58
134
  private var database: PulseDatabase?
@@ -107,7 +183,8 @@ public final class PulseController {
107
183
  launchWaitMs: (info?["PulseUpdatesLaunchWaitMs"] as? Int) ?? 0,
108
184
  channel: info?["PulseUpdatesChannel"] as? String,
109
185
  signingKeyId: info?["PulseUpdatesSigningKeyId"] as? String,
110
- signingPublicKey: info?["PulseUpdatesSigningPublicKey"] as? String
186
+ signingPublicKey: info?["PulseUpdatesSigningPublicKey"] as? String,
187
+ requireSignature: (info?["PulseUpdatesRequireSignature"] as? Bool) ?? PulseUpdatesConfig.defaultRequireSignature
111
188
  )
112
189
  }
113
190
 
@@ -172,6 +249,20 @@ public final class PulseController {
172
249
 
173
250
  // Synchronously select the best update for launch
174
251
  selectBestUpdateSync()
252
+
253
+ // Wire crash-recovery on the getBundleURL launch path. This path never calls start(), so
254
+ // without this the RCT fatal handler / RCTContentDidAppear observer would never install and
255
+ // failed/successful launch recording (and rollback) would never run.
256
+ if errorRecovery == nil {
257
+ errorRecovery = PulseErrorRecovery()
258
+ errorRecovery?.delegate = self
259
+ errorRecovery?.startMonitoring()
260
+ }
261
+
262
+ // Seed launch-in-progress tracking for the selected update before the first root view renders.
263
+ if let updateId = launchedUpdate?.updateId {
264
+ try? database?.recordLaunchStart(updateId: updateId)
265
+ }
175
266
  }
176
267
 
177
268
  /// Synchronously select the best update (used for initial launch)
@@ -211,6 +302,16 @@ public final class PulseController {
211
302
  let exists = FileManager.default.fileExists(atPath: bundlePath.path)
212
303
  pulseLog("selectBestUpdateSync: bundlePath exists=\(exists)")
213
304
 
305
+ // Re-verify the on-disk bundle against its content-addressed hash before handing it
306
+ // to React Native. File existence alone is not enough — a truncated/tampered/partial
307
+ // bundle must not be launched.
308
+ if exists, !bundleFileMatchesHash(at: bundlePath, expectedHash: bundleHash) {
309
+ pulseLogError("selectBestUpdateSync: bundle hash mismatch for \(selectedUpdate.updateId), marking failed and falling back to embedded")
310
+ try? database.setStatus(.failed, forUpdateId: selectedUpdate.updateId)
311
+ launchEmbedded()
312
+ return
313
+ }
314
+
214
315
  if exists {
215
316
  launchAssetUrl = bundlePath
216
317
  isEmbeddedLaunch = false
@@ -246,6 +347,22 @@ public final class PulseController {
246
347
  }
247
348
  }
248
349
 
350
+ /// Re-hash an on-disk bundle file and compare against its expected content-addressed hash
351
+ /// (lowercased hex SHA-256). Returns true only when the file exists and its digest matches.
352
+ private func bundleFileMatchesHash(at fileUrl: URL, expectedHash: String) -> Bool {
353
+ guard let handle = try? FileHandle(forReadingFrom: fileUrl) else { return false }
354
+ defer { try? handle.close() }
355
+
356
+ var hasher = SHA256()
357
+ while true {
358
+ let chunk = try? handle.read(upToCount: 1024 * 1024)
359
+ guard let data = chunk, !data.isEmpty else { break }
360
+ hasher.update(data: data)
361
+ }
362
+ let actual = hasher.finalize().map { String(format: "%02x", $0) }.joined()
363
+ return actual == expectedHash.lowercased()
364
+ }
365
+
249
366
  /// Build localAssets map for an OTA update (downloaded + embedded fallbacks)
250
367
  /// Store BOTH hash AND path-based keys for maximum compatibility:
251
368
  /// - hash: for SHA256 hash lookup (how expo-asset looks up)
@@ -305,40 +422,18 @@ public final class PulseController {
305
422
 
306
423
  var assets: [String: String] = [:]
307
424
  let bundleRoot = Bundle.main.bundlePath
308
- let fileManager = FileManager.default
309
425
 
310
426
  for asset in manifest.assets where !asset.isLaunchAsset {
311
427
  guard let filename = asset.nsBundleFilename, let assetType = asset.type else {
312
428
  continue
313
429
  }
314
430
 
315
- // Get file extension from type (e.g., "image/png" -> "png")
316
- let ext = assetType.components(separatedBy: "/").last ?? "png"
317
- var bundlePath: String? = nil
318
-
319
- // 1. Try in assets folder with subdirectory path (RN stores assets here)
320
- if let dir = asset.nsBundleDir, !dir.isEmpty {
321
- let path = "\(bundleRoot)/assets/\(dir)/\(filename).\(ext)"
322
- if fileManager.fileExists(atPath: path) {
323
- bundlePath = path
324
- }
325
- }
326
-
327
- // 2. Try without assets/ prefix
328
- if bundlePath == nil, let dir = asset.nsBundleDir, !dir.isEmpty {
329
- let path = "\(bundleRoot)/\(dir)/\(filename).\(ext)"
330
- if fileManager.fileExists(atPath: path) {
331
- bundlePath = path
332
- }
333
- }
334
-
335
- // 3. Try at root
336
- if bundlePath == nil {
337
- let path = "\(bundleRoot)/\(filename).\(ext)"
338
- if fileManager.fileExists(atPath: path) {
339
- bundlePath = path
340
- }
341
- }
431
+ let bundlePath = findBundledAssetPath(
432
+ bundleRoot: bundleRoot,
433
+ filename: filename,
434
+ assetType: assetType,
435
+ nsBundleDir: asset.nsBundleDir
436
+ )
342
437
 
343
438
  if let path = bundlePath {
344
439
  let localUri = URL(fileURLWithPath: path).absoluteString
@@ -535,6 +630,7 @@ public final class PulseController {
535
630
  }
536
631
 
537
632
  self.lastCheckManifest = otaManifest
633
+ self.reportEvent("check", updateId: otaManifest.updateId)
538
634
  pulseLog("checkForUpdate: cached manifest for fetch (ota=\(otaCommitTime) > embedded=\(embeddedCommitTime))")
539
635
  }
540
636
  completion(result)
@@ -593,6 +689,12 @@ public final class PulseController {
593
689
  self.pendingFetchCallbacks.removeAll()
594
690
  self.fetchLock.unlock()
595
691
 
692
+ // A freshly downloaded update marks this device "served" on the server, which is what
693
+ // gates its launch outcomes into the crash-rate auto-rollback.
694
+ if case .success(let fetchResult) = result, fetchResult.isNew {
695
+ self.reportEvent("download", updateId: fetchResult.manifest?.updateId)
696
+ }
697
+
596
698
  // Call original completion
597
699
  completion(result)
598
700
 
@@ -619,12 +721,44 @@ public final class PulseController {
619
721
  public func markAppReady() {
620
722
  if let updateId = launchedUpdate?.updateId {
621
723
  try? database?.recordSuccessfulLaunch(updateId: updateId)
724
+ if !isEmbeddedLaunch { reportEvent("launch_success", updateId: updateId) }
622
725
  }
623
726
 
624
727
  // Run reaper after successful launch
625
728
  runReaper()
626
729
  }
627
730
 
731
+ /// Fire-and-forget device telemetry to the server's /pulse/events endpoint (async URLSession, so
732
+ /// it never blocks). Best-effort: failures are logged, never silently swallowed, and never affect
733
+ /// the app. The server marks a device "served" on the "download" event — only served devices count
734
+ /// toward crash-rate auto-rollback — and uses launch_success/launch_failure to drive that rate.
735
+ /// appSlug + base are derived from the manifest URL (.../pulse/manifest/{appSlug}).
736
+ private func reportEvent(_ type: String, updateId: String?) {
737
+ guard let updateId = updateId, !updateId.isEmpty, let config = config else { return }
738
+ let marker = "/pulse/manifest/"
739
+ guard let range = config.updateUrl.range(of: marker) else {
740
+ pulseLog("reportEvent(\(type)): updateUrl '\(config.updateUrl)' has no \(marker) — skipping")
741
+ return
742
+ }
743
+ let base = String(config.updateUrl[..<range.lowerBound])
744
+ let appSlug = String(config.updateUrl[range.upperBound...].prefix(while: { $0 != "/" && $0 != "?" }))
745
+ guard let eventsUrl = URL(string: "\(base)/pulse/events") else { return }
746
+ let body: [String: Any] = ["appSlug": appSlug, "updateId": updateId, "deviceId": deviceId, "type": type]
747
+ guard let httpBody = try? JSONSerialization.data(withJSONObject: body) else { return }
748
+ var req = URLRequest(url: eventsUrl)
749
+ req.httpMethod = "POST"
750
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
751
+ req.timeoutInterval = 5
752
+ req.httpBody = httpBody
753
+ URLSession.shared.dataTask(with: req) { _, response, error in
754
+ if let error = error {
755
+ pulseLog("reportEvent(\(type)) failed: \(error.localizedDescription)")
756
+ } else if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
757
+ pulseLog("reportEvent(\(type)) -> HTTP \(http.statusCode)")
758
+ }
759
+ }.resume()
760
+ }
761
+
628
762
  /// Report a fatal error for error recovery handling
629
763
  public func reportError(_ error: NSError) {
630
764
  errorRecovery?.handle(error: error)
@@ -680,37 +814,15 @@ public final class PulseController {
680
814
  var foundCount = 0
681
815
  var notFoundCount = 0
682
816
  let bundleRoot = Bundle.main.bundlePath
683
- let fileManager = FileManager.default
684
817
 
685
818
  for asset in manifest.assets {
686
819
  if let filename = asset.nsBundleFilename, let assetType = asset.type {
687
- // Get file extension from type (e.g., "image/png" -> "png")
688
- let ext = assetType.components(separatedBy: "/").last ?? "png"
689
- var bundlePath: String? = nil
690
-
691
- // 1. Try in assets folder with subdirectory path (RN stores assets here)
692
- if let dir = asset.nsBundleDir, !dir.isEmpty {
693
- let path = "\(bundleRoot)/assets/\(dir)/\(filename).\(ext)"
694
- if fileManager.fileExists(atPath: path) {
695
- bundlePath = path
696
- }
697
- }
698
-
699
- // 2. Try without assets/ prefix
700
- if bundlePath == nil, let dir = asset.nsBundleDir, !dir.isEmpty {
701
- let path = "\(bundleRoot)/\(dir)/\(filename).\(ext)"
702
- if fileManager.fileExists(atPath: path) {
703
- bundlePath = path
704
- }
705
- }
706
-
707
- // 3. Try at root
708
- if bundlePath == nil {
709
- let path = "\(bundleRoot)/\(filename).\(ext)"
710
- if fileManager.fileExists(atPath: path) {
711
- bundlePath = path
712
- }
713
- }
820
+ let bundlePath = findBundledAssetPath(
821
+ bundleRoot: bundleRoot,
822
+ filename: filename,
823
+ assetType: assetType,
824
+ nsBundleDir: asset.nsBundleDir
825
+ )
714
826
 
715
827
  if let path = bundlePath {
716
828
  embeddedAssetHashes[asset.hash.lowercased()] = URL(fileURLWithPath: path)
@@ -856,7 +968,30 @@ public final class PulseController {
856
968
 
857
969
  private func rollbackToPreviousUpdate() {
858
970
  pulseLog("Rolling back to previous update")
859
- // Implementation depends on error recovery strategy
971
+
972
+ // Mark the currently launched update as failed so the selection policy excludes it
973
+ // (failedLaunchCount > 0 && successfulLaunchCount == 0) on the next selection.
974
+ if let updateId = launchedUpdate?.updateId, !(launchedUpdate?.isEmbedded ?? true) {
975
+ try? database?.recordFailedLaunch(updateId: updateId)
976
+ try? database?.setStatus(.failed, forUpdateId: updateId)
977
+ reportEvent("launch_failure", updateId: updateId)
978
+ pulseLog("rollbackToPreviousUpdate: marked \(updateId) failed")
979
+ }
980
+
981
+ // Clear the current selection and re-select the best remaining update (previous-good, else embedded).
982
+ launchAssetUrl = nil
983
+ launchedUpdate = nil
984
+ launchedManifestJson = nil
985
+ isEmbeddedLaunch = true
986
+ localAssets = [:]
987
+
988
+ selectBestUpdateSync()
989
+ if launchAssetUrl == nil {
990
+ launchEmbedded()
991
+ }
992
+
993
+ // Ask React Native to reload with the rolled-back bundle.
994
+ NotificationCenter.default.post(name: NSNotification.Name("PulseUpdatesReloadRequest"), object: nil)
860
995
  }
861
996
 
862
997
  private func runReaper() {
@@ -1048,8 +1183,31 @@ final class PulseRemoteLoader {
1048
1183
  return
1049
1184
  }
1050
1185
 
1051
- // Verify signature if required
1052
- if config.signingPublicKey != nil {
1186
+ // Signature gate (fail-closed in release).
1187
+ // If signatures are required but no key material is configured, or the
1188
+ // manifest is unsigned, REFUSE the update so the app keeps running the
1189
+ // embedded/known-good bundle instead of applying an unverified one.
1190
+ let hasKeyMaterial = config.signingPublicKey != nil && config.signingKeyId != nil
1191
+ if config.requireSignature {
1192
+ guard hasKeyMaterial else {
1193
+ pulseLogError("REFUSING remote update \(manifest.updateId): requireSignature is enabled but signingPublicKey/signingKeyId are not configured. Falling back to the embedded bundle. Configure PulseUpdatesSigningPublicKey and PulseUpdatesSigningKeyId, or set PulseUpdatesRequireSignature=NO to intentionally allow unsigned updates.")
1194
+ completion(.failure(PulseUpdatesError.signatureRequiredButMissing))
1195
+ return
1196
+ }
1197
+ guard manifest.signature != nil else {
1198
+ pulseLogError("REFUSING remote update \(manifest.updateId): requireSignature is enabled but the manifest carries no signature. Falling back to the embedded bundle.")
1199
+ completion(.failure(PulseUpdatesError.signatureRequiredButMissing))
1200
+ return
1201
+ }
1202
+ guard verifyManifestSignature(manifest: manifest, manifestJson: data, config: config) else {
1203
+ completion(.failure(PulseUpdatesError.signatureVerificationFailed))
1204
+ return
1205
+ }
1206
+ } else if hasKeyMaterial, manifest.signature != nil {
1207
+ // Signatures not strictly required (e.g. DEBUG), but key material is
1208
+ // present, so still verify WHEN a signature is provided. An unsigned
1209
+ // manifest is allowed through here (verifyManifestSignature would reject
1210
+ // a nil signature, which would wrongly block unsigned DEBUG updates).
1053
1211
  guard verifyManifestSignature(manifest: manifest, manifestJson: data, config: config) else {
1054
1212
  completion(.failure(PulseUpdatesError.signatureVerificationFailed))
1055
1213
  return
@@ -1128,6 +1286,9 @@ final class PulseRemoteLoader {
1128
1286
  request.setValue("2", forHTTPHeaderField: "Pulse-Protocol-Version")
1129
1287
  request.setValue("ios", forHTTPHeaderField: "X-Pulse-Platform")
1130
1288
  request.setValue(config.runtimeVersion, forHTTPHeaderField: "X-Pulse-Runtime-Version")
1289
+ // Stable device id so the server can bucket this device into staged rollouts (without it,
1290
+ // a device is held back from any partial rollout until it reaches 100%).
1291
+ request.setValue(PulseController.shared.deviceId, forHTTPHeaderField: "Pulse-Device-Id")
1131
1292
 
1132
1293
  if let channel = config.channel {
1133
1294
  request.setValue(channel, forHTTPHeaderField: "X-Pulse-Channel-Name")
@@ -14,6 +14,10 @@ public struct PulseUpdatesConfig {
14
14
  public let channel: String?
15
15
  public let signingKeyId: String?
16
16
  public let signingPublicKey: String?
17
+ /// When true, remote updates MUST carry a valid Ed25519 signature and the
18
+ /// app MUST be configured with signingKeyId/signingPublicKey, otherwise the
19
+ /// update is refused (fail-closed). Defaults to true in release builds.
20
+ public let requireSignature: Bool
17
21
  public let scopeKey: String
18
22
 
19
23
  public init(
@@ -24,7 +28,8 @@ public struct PulseUpdatesConfig {
24
28
  launchWaitMs: Int = 0,
25
29
  channel: String? = nil,
26
30
  signingKeyId: String? = nil,
27
- signingPublicKey: String? = nil
31
+ signingPublicKey: String? = nil,
32
+ requireSignature: Bool = PulseUpdatesConfig.defaultRequireSignature
28
33
  ) {
29
34
  self.enabled = enabled
30
35
  self.updateUrl = updateUrl
@@ -34,9 +39,21 @@ public struct PulseUpdatesConfig {
34
39
  self.channel = channel
35
40
  self.signingKeyId = signingKeyId
36
41
  self.signingPublicKey = signingPublicKey
42
+ self.requireSignature = requireSignature
37
43
  // Use update URL host as scope key (like Expo)
38
44
  self.scopeKey = URL(string: updateUrl)?.host ?? "default"
39
45
  }
46
+
47
+ /// Default for requireSignature: fail-closed in release, fail-open only in DEBUG.
48
+ /// Callers that don't pass requireSignature (e.g. the JS-driven configure bridge)
49
+ /// inherit this release-secure default automatically.
50
+ public static var defaultRequireSignature: Bool {
51
+ #if DEBUG
52
+ return false
53
+ #else
54
+ return true
55
+ #endif
56
+ }
40
57
  }
41
58
 
42
59
  // MARK: - Manifest Models
@@ -214,6 +231,7 @@ public enum PulseUpdatesError: Error, LocalizedError {
214
231
  case networkError(underlying: Error)
215
232
  case invalidManifest(reason: String)
216
233
  case signatureVerificationFailed
234
+ case signatureRequiredButMissing
217
235
  case hashMismatch(expected: String, actual: String)
218
236
  case downloadFailed(underlying: Error)
219
237
  case noUpdateAvailable
@@ -233,6 +251,8 @@ public enum PulseUpdatesError: Error, LocalizedError {
233
251
  return "Invalid manifest: \(reason)"
234
252
  case .signatureVerificationFailed:
235
253
  return "Signature verification failed"
254
+ case .signatureRequiredButMissing:
255
+ return "Signature required but missing: the SDK is configured to require signed updates but no signing key is configured or the manifest is unsigned"
236
256
  case .hashMismatch(let expected, let actual):
237
257
  return "Hash mismatch: expected \(expected), got \(actual)"
238
258
  case .downloadFailed(let error):
@@ -47,6 +47,16 @@ export interface PulseUpdatesConfig {
47
47
  channel?: string;
48
48
  signingKeyId?: string;
49
49
  signingPublicKey?: string;
50
+ /**
51
+ * When true, manifests must carry a valid Ed25519 signature verifying against
52
+ * `signingPublicKey`; unsigned or unverifiable manifests are rejected (fail-closed).
53
+ *
54
+ * NATIVE-CONFIG ONLY: this is read from the Info.plist / AndroidManifest
55
+ * `PulseUpdatesRequireSignature` meta-data (defaults to `true` in release) and is NOT
56
+ * accepted by `configure()` — setting it here has no effect. Configure signing via the
57
+ * native config so a release can never silently flip to refuse-all from JS.
58
+ */
59
+ requireSignature?: boolean;
50
60
  }
51
61
  export interface PulseUpdatesState {
52
62
  isEnabled: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,SAAS,CAAC,EAAE,cAAc,CAAC;IAG3B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,WAAW,CAAC,EAAE,UAAU,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,OAAO,CAAC;IACf,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,QAAQ,GAAG,WAAW,GAAG,OAAO,CAAC;IAChD,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,OAAO,CAAC;IACpB,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAC;IAC/B,gBAAgB,EAAE,OAAO,CAAC;IAC1B,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;IACvB,aAAa,EAAE,IAAI,GAAG,IAAI,CAAC;IAC3B,eAAe,EAAE,aAAa,GAAG,IAAI,CAAC;IACtC,gBAAgB,EAAE,aAAa,GAAG,IAAI,CAAC;IACvC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;CAC5C"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,SAAS,CAAC,EAAE,cAAc,CAAC;IAG3B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,WAAW,CAAC,EAAE,UAAU,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,OAAO,CAAC;IACf,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,QAAQ,GAAG,WAAW,GAAG,OAAO,CAAC;IAChD,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;OAQG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,OAAO,CAAC;IACpB,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAC;IAC/B,gBAAgB,EAAE,OAAO,CAAC;IAC1B,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;IACvB,aAAa,EAAE,IAAI,GAAG,IAAI,CAAC;IAC3B,eAAe,EAAE,aAAa,GAAG,IAAI,CAAC;IACtC,gBAAgB,EAAE,aAAa,GAAG,IAAI,CAAC;IACvC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;CAC5C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-updates",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "OTA updates for React Native - lightweight alternative to expo-updates",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -26,7 +26,10 @@
26
26
  "build": "bob build",
27
27
  "prepare": "bob build",
28
28
  "test": "node --test tests/*.test.js",
29
- "publish-update": "node scripts/publish.js publish"
29
+ "publish-update": "node scripts/publish.mjs publish"
30
+ },
31
+ "engines": {
32
+ "node": ">=18"
30
33
  },
31
34
  "keywords": [
32
35
  "react-native",
@@ -36,19 +39,18 @@
36
39
  ],
37
40
  "repository": {
38
41
  "type": "git",
39
- "url": "https://git.spicysparks.com/SpicySparks/Pulse-Client.git"
42
+ "url": "https://github.com/pulse-updates/pulse-updates.git"
40
43
  },
41
- "author": "Lyra",
44
+ "author": "Pulse Updates",
42
45
  "license": "MIT",
43
46
  "peerDependencies": {
44
- "react": "*",
45
- "react-native": "*"
47
+ "react": ">=18.2.0",
48
+ "react-native": ">=0.76.0"
46
49
  },
47
50
  "devDependencies": {
48
51
  "@types/react": "^18.2.0",
49
- "@types/react-native": "^0.72.0",
50
52
  "react": "^18.2.0",
51
- "react-native": "^0.73.0",
53
+ "react-native": "^0.83.0",
52
54
  "react-native-builder-bob": "^0.23.0",
53
55
  "typescript": "^5.0.0"
54
56
  },