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 +21 -0
- package/README.md +57 -8
- package/android/src/main/java/app/pulse/updates/PulseController.kt +234 -37
- package/android/src/main/java/app/pulse/updates/database/PulseDatabase.kt +14 -0
- package/android/src/main/java/app/pulse/updates/errorrecovery/PulseErrorRecovery.kt +43 -0
- package/android/src/main/java/app/pulse/updates/launcher/PulseAppLauncher.kt +11 -4
- package/ios/PulseUpdates/AppLauncher/PulseAppLauncher.swift +27 -63
- package/ios/PulseUpdates/PulseController.swift +221 -60
- package/ios/PulseUpdates/PulseTypes.swift +21 -1
- package/lib/typescript/types.d.ts +10 -0
- package/lib/typescript/types.d.ts.map +1 -1
- package/package.json +10 -8
- package/scripts/generate-embedded-manifest.mjs +5 -1
- package/scripts/publish.mjs +176 -5
- package/src/types.ts +10 -0
|
@@ -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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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.
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1052
|
-
|
|
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;
|
|
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.
|
|
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.
|
|
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://
|
|
42
|
+
"url": "https://github.com/pulse-updates/pulse-updates.git"
|
|
40
43
|
},
|
|
41
|
-
"author": "
|
|
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.
|
|
53
|
+
"react-native": "^0.83.0",
|
|
52
54
|
"react-native-builder-bob": "^0.23.0",
|
|
53
55
|
"typescript": "^5.0.0"
|
|
54
56
|
},
|