@hot-updater/react-native 1.0.8 → 1.1.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.
Files changed (145) hide show
  1. package/HotUpdater.podspec +14 -2
  2. package/Package.swift +24 -3
  3. package/android/build.gradle +2 -6
  4. package/android/hot-updater-fingerprint.gradle +135 -0
  5. package/android/src/main/cpp/HotUpdaterRecovery.cpp +291 -21
  6. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +176 -15
  7. package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +20 -8
  8. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +145 -31
  9. package/android/src/main/java/com/hotupdater/CohortService.kt +10 -2
  10. package/android/src/main/java/com/hotupdater/FileManagerService.kt +3 -1
  11. package/android/src/main/java/com/hotupdater/HotUpdaterConfig.kt +249 -21
  12. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +8 -4
  13. package/android/src/main/java/com/hotupdater/HotUpdaterHeldContent.kt +92 -0
  14. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +319 -113
  15. package/android/src/main/java/com/hotupdater/HotUpdaterInstance.kt +87 -0
  16. package/android/src/main/java/com/hotupdater/HotUpdaterInstanceEnvironment.kt +323 -0
  17. package/android/src/main/java/com/hotupdater/HotUpdaterMainThread.kt +46 -0
  18. package/android/src/main/java/com/hotupdater/HotUpdaterProcess.kt +30 -0
  19. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +493 -0
  20. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
  21. package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
  22. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
  23. package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
  24. package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
  25. package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
  26. package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
  27. package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
  28. package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
  29. package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
  30. package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
  31. package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
  32. package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
  33. package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
  34. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
  35. package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
  36. package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
  37. package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
  38. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
  39. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
  40. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
  41. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
  42. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
  43. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
  44. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
  45. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
  46. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
  47. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
  48. package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
  49. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
  50. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
  51. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
  52. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
  53. package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
  54. package/android/src/newarch/HotUpdater.kt +53 -15
  55. package/android/src/newarch/HotUpdaterModule.kt +42 -17
  56. package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
  57. package/android/src/oldarch/HotUpdater.kt +51 -12
  58. package/android/src/oldarch/HotUpdaterModule.kt +43 -17
  59. package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
  60. package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
  61. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
  62. package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
  63. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
  64. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
  65. package/ios/HotUpdater/Internal/CohortService.swift +8 -0
  66. package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
  67. package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
  68. package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
  69. package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
  70. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
  71. package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
  72. package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
  73. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
  74. package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
  75. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
  76. package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
  77. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
  78. package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
  79. package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
  80. package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
  81. package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
  82. package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
  83. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
  84. package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
  85. package/ios/HotUpdater/Package.swift +11 -0
  86. package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
  87. package/ios/HotUpdater/Public/HotUpdater.h +21 -2
  88. package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
  89. package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
  90. package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
  91. package/lib/commonjs/checkForUpdate.js +3 -1
  92. package/lib/commonjs/checkForUpdate.js.map +1 -1
  93. package/lib/commonjs/index.js +9 -12
  94. package/lib/commonjs/index.js.map +1 -1
  95. package/lib/commonjs/native.js +24 -79
  96. package/lib/commonjs/native.js.map +1 -1
  97. package/lib/commonjs/nativeUpdateEvents.js +66 -0
  98. package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
  99. package/lib/commonjs/sdkVersion.js +1 -1
  100. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  101. package/lib/module/checkForUpdate.js +3 -1
  102. package/lib/module/checkForUpdate.js.map +1 -1
  103. package/lib/module/index.js +10 -13
  104. package/lib/module/index.js.map +1 -1
  105. package/lib/module/native.js +19 -73
  106. package/lib/module/native.js.map +1 -1
  107. package/lib/module/nativeUpdateEvents.js +61 -0
  108. package/lib/module/nativeUpdateEvents.js.map +1 -0
  109. package/lib/module/sdkVersion.js +1 -1
  110. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  111. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/index.d.ts +4 -14
  113. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/native.d.ts +9 -46
  115. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
  117. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  119. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
  120. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  121. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  122. package/lib/typescript/module/index.d.ts +4 -14
  123. package/lib/typescript/module/index.d.ts.map +1 -1
  124. package/lib/typescript/module/native.d.ts +9 -46
  125. package/lib/typescript/module/native.d.ts.map +1 -1
  126. package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
  127. package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
  128. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  129. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
  130. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  131. package/package.json +4 -4
  132. package/src/checkForUpdate.ts +7 -1
  133. package/src/index.ts +9 -17
  134. package/src/native.ts +19 -91
  135. package/src/nativeUpdateEvents.ts +77 -0
  136. package/src/sdkVersion.ts +1 -1
  137. package/src/specs/NativeHotUpdater.ts +20 -3
  138. package/android/libs/README.md +0 -50
  139. package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
  140. package/android/src/bridge/BridgeReactInstance.kt +0 -38
  141. package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
  142. package/android/src/main/java/com/hotupdater/ContentAppearedLog.kt +0 -31
  143. package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
  144. package/android/src/newarch/ReactIntegrationManager.kt +0 -212
  145. package/android/src/oldarch/ReactIntegrationManager.kt +0 -120
@@ -283,10 +283,24 @@ public protocol BundleStorageService {
283
283
  func getCachedBundleURL() -> URL?
284
284
  func getFallbackBundleURL(bundle: Bundle) -> URL? // Synchronous as it's lightweight
285
285
  func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection
286
+ /// Settles what the previous process left once per process, before the
287
+ /// first read of the selection: a crash rollback and a launch that never
288
+ /// finished. `prepareLaunch` runs it first; a native update check that runs
289
+ /// before React Native starts runs it earlier. `pendingRecovery` is called
290
+ /// only when this run settles.
291
+ func settlePreviousLaunch(pendingRecovery: () -> PendingCrashRecovery?)
292
+ /// The bundle the next `prepareLaunch` would run, read without preparing
293
+ /// the launch. Nil is the built-in bundle. Call `settlePreviousLaunch`
294
+ /// first, or a bundle the next launch rolls back is returned.
295
+ func peekLaunchBundleId() -> String?
286
296
 
287
297
  // Bundle update
288
- func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL?, assets: [String: ChangedAssetDescriptor], fileResolver: ArtifactFileResolver?, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void)
289
- func stageReleaseSelection(_ selection: PersistedSelection) -> Bool
298
+ /// Stages a bundle. `selection` is the Release selection the staging
299
+ /// commits; it travels with this staging, and the staging fails as stale
300
+ /// when it is no longer current.
301
+ func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL?, assets: [String: ChangedAssetDescriptor], fileResolver: ArtifactFileResolver?, selection: PersistedSelection?, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void)
302
+ /// Whether a staging of `selection` would still commit.
303
+ func isSelectionCurrent(_ selection: PersistedSelection) -> Bool
290
304
  func acceptReleaseCatalog(catalogId: String, scopeKey: String, generation: Int64, catalogHash: String, channel: String, selectionContextHash: String) -> Bool
291
305
  func getActiveUpdateState() -> [String: Any]
292
306
  func isReleaseSelectionCurrent(catalogId: String, scopeKey: String, generation: Int64, catalogHash: String, channel: String, selectionContextHash: String) -> Bool
@@ -294,6 +308,12 @@ public protocol BundleStorageService {
294
308
 
295
309
  // Rollback support
296
310
  func markLaunchStarted(bundleId: String?)
311
+ /// Forgets that the launch of `bundleId` started, leaving it pending: the
312
+ /// next start launches it again instead of counting an unfinished launch.
313
+ /// For a launch whose process another instance's crash takes down.
314
+ /// Returns whether the launch had started.
315
+ @discardableResult
316
+ func clearLaunchStarted(bundleId: String?) -> Bool
297
317
  func markLaunchCompleted(bundleId: String?)
298
318
  func notifyAppReady() -> [String: Any]
299
319
  func getCrashHistory() -> CrashedHistory
@@ -327,7 +347,11 @@ public protocol BundleStorageService {
327
347
  }
328
348
 
329
349
  public extension BundleStorageService {
330
- func stageReleaseSelection(_: PersistedSelection) -> Bool { false }
350
+ func settlePreviousLaunch(pendingRecovery _: () -> PendingCrashRecovery?) {}
351
+ @discardableResult
352
+ func clearLaunchStarted(bundleId _: String?) -> Bool { false }
353
+ func peekLaunchBundleId() -> String? { nil }
354
+ func isSelectionCurrent(_: PersistedSelection) -> Bool { false }
331
355
  func acceptReleaseCatalog(catalogId _: String, scopeKey _: String, generation _: Int64, catalogHash _: String, channel _: String, selectionContextHash _: String) -> Bool { false }
332
356
  func getActiveUpdateState() -> [String: Any] { [:] }
333
357
  func isReleaseSelectionCurrent(catalogId _: String, scopeKey _: String, generation _: Int64, catalogHash _: String, channel _: String, selectionContextHash _: String) -> Bool { false }
@@ -374,11 +398,16 @@ class BundleFileStorageService: BundleStorageService {
374
398
  private let downloadRetryDelay: TimeInterval
375
399
  private let preferences: PreferencesService
376
400
  private let isolationKey: String
401
+ /// Whose store this is: main's `Documents/bundle-store`, or a named
402
+ /// instance's own, with its public key and update strategy.
403
+ private let instance: HotUpdaterInstanceEnvironment
377
404
 
378
405
  // Queue for potentially long-running sequences within updateBundle or for explicit background tasks.
379
406
  private let fileOperationQueue: DispatchQueue
380
407
 
381
408
  private var hasPreparedLaunch = false
409
+ private let settleLock = NSLock()
410
+ private var hasSettledPreviousLaunch = false
382
411
  private var currentLaunchReport: LaunchReport?
383
412
  // The staged bundle this process launched before its first content. Only that
384
413
  // launch is pending; a bundle staged later waits for the next launch.
@@ -393,16 +422,20 @@ class BundleFileStorageService: BundleStorageService {
393
422
  private let builtInAssetResolver: BuiltInAssetResolver
394
423
  private let updateStrategyProvider: () -> UpdateStrategy
395
424
  private let releaseStateLock = NSRecursiveLock()
396
- private var pendingInstallSelections: [String: PersistedSelection] = [:]
397
425
 
398
- private let installIdentityLock = NSLock()
399
- private var currentInstallIdentity: InstallIdentity?
426
+ /// App-wide: every instance's storage reads the same install identity
427
+ /// file, so one lock and cache per file keep two instances that start at
428
+ /// once from creating different ids.
429
+ private static let installIdentityLock = NSLock()
430
+ private static var installIdentities: [String: InstallIdentity] = [:]
400
431
 
401
432
  private let protectedBundleStoreEntries: Set<String> = [
402
433
  BundleMetadata.metadataFilename,
403
434
  CrashedHistory.crashedHistoryFilename,
404
435
  LaunchReport.launchReportFilename,
405
436
  "builtin-index-v1.json",
437
+ // Written when a launch crashes, read at the next start.
438
+ HotUpdaterCrashMarker.filename,
406
439
  ]
407
440
 
408
441
  private func createDiffProgressFiles(
@@ -725,7 +758,8 @@ class BundleFileStorageService: BundleStorageService {
725
758
  throw BundleStorageError.signatureVerificationFailed(.fileHashMismatch)
726
759
  }
727
760
 
728
- guard SignatureVerifier.isSigningEnabled() else {
761
+ let publicKey = instance.publicKey()
762
+ guard SignatureVerifier.isSigningEnabled(publicKey: publicKey) else {
729
763
  return
730
764
  }
731
765
 
@@ -735,7 +769,8 @@ class BundleFileStorageService: BundleStorageService {
735
769
 
736
770
  switch SignatureVerifier.verifyHashSignature(
737
771
  fileHash: asset.fileHash,
738
- signatureBase64: signature
772
+ signatureBase64: signature,
773
+ publicKey: publicKey
739
774
  ) {
740
775
  case .success:
741
776
  return
@@ -748,13 +783,10 @@ class BundleFileStorageService: BundleStorageService {
748
783
  downloadService: DownloadService,
749
784
  preferences: PreferencesService,
750
785
  isolationKey: String,
786
+ instance: HotUpdaterInstanceEnvironment = .main,
751
787
  builtInBundleIdProvider: @escaping () -> String = { "" },
752
788
  builtInAssetResolver: BuiltInAssetResolver? = nil,
753
- updateStrategyProvider: @escaping () -> UpdateStrategy = {
754
- let fingerprintHash = HotUpdaterConfig.shared.fingerprintHash
755
- ?? Bundle.main.object(forInfoDictionaryKey: "HOT_UPDATER_FINGERPRINT_HASH") as? String
756
- return (fingerprintHash?.isEmpty == false) ? .fingerprint : .appVersion
757
- },
789
+ updateStrategyProvider: (() -> UpdateStrategy)? = nil,
758
790
  downloadRetryDelay: TimeInterval = 1) {
759
791
 
760
792
  self.fileSystem = fileSystem
@@ -762,13 +794,14 @@ class BundleFileStorageService: BundleStorageService {
762
794
  self.downloadService = downloadService
763
795
  self.preferences = preferences
764
796
  self.isolationKey = isolationKey
797
+ self.instance = instance
765
798
  self.builtInBundleIdProvider = builtInBundleIdProvider
766
799
  self.builtInAssetResolver = builtInAssetResolver ?? IOSBuiltInAssetResolver(
767
- cacheURL: URL(fileURLWithPath: fileSystem.documentsPath())
768
- .appendingPathComponent("bundle-store", isDirectory: true)
769
- .appendingPathComponent("builtin-index-v1.json")
800
+ cacheURL: URL(fileURLWithPath: instance.bundleStorePath(documentsPath: fileSystem.documentsPath()), isDirectory: true)
801
+ .appendingPathComponent("builtin-index-v1.json"),
802
+ defaultBundle: { instance.bundle }
770
803
  )
771
- self.updateStrategyProvider = updateStrategyProvider
804
+ self.updateStrategyProvider = updateStrategyProvider ?? { instance.updateStrategy() }
772
805
 
773
806
  // Create queue for file operations
774
807
  self.fileOperationQueue = DispatchQueue(label: "com.hotupdater.fileoperations",
@@ -860,21 +893,21 @@ class BundleFileStorageService: BundleStorageService {
860
893
  /// Loads the install identity, creating it once when its file is missing.
861
894
  @discardableResult
862
895
  private func loadOrCreateInstallIdentity() -> InstallIdentity {
863
- installIdentityLock.lock()
864
- defer { installIdentityLock.unlock() }
896
+ Self.installIdentityLock.lock()
897
+ defer { Self.installIdentityLock.unlock() }
865
898
 
866
- if let currentInstallIdentity {
867
- return currentInstallIdentity
868
- }
869
899
  let file = installIdentityFileURL()
900
+ if let identity = Self.installIdentities[file.path] {
901
+ return identity
902
+ }
870
903
  if let identity = InstallIdentity.load(from: file) {
871
- currentInstallIdentity = identity
904
+ Self.installIdentities[file.path] = identity
872
905
  return identity
873
906
  }
874
907
 
875
908
  let identity = InstallIdentity()
876
909
  // Keep the new id for this process even when it cannot be saved.
877
- currentInstallIdentity = identity
910
+ Self.installIdentities[file.path] = identity
878
911
  _ = identity.save(to: file)
879
912
  return identity
880
913
  }
@@ -1285,11 +1318,12 @@ class BundleFileStorageService: BundleStorageService {
1285
1318
  return metadata.verificationPending && metadata.stagingBundleId != nil
1286
1319
  }
1287
1320
 
1288
- private func prepareMetadataForNewStagingBundle(_ metadata: BundleMetadata, bundleId: String) -> BundleMetadata? {
1321
+ private func prepareMetadataForNewStagingBundle(
1322
+ _ metadata: BundleMetadata,
1323
+ bundleId: String,
1324
+ incomingSelection: PersistedSelection?
1325
+ ) -> BundleMetadata? {
1289
1326
  let currentVerifiedBundleId = getCurrentVerifiedBundleId(metadata).flatMap { $0 == bundleId ? nil : $0 }
1290
- releaseStateLock.lock()
1291
- let incomingSelection = pendingInstallSelections.removeValue(forKey: bundleId)
1292
- releaseStateLock.unlock()
1293
1327
  if let incomingSelection, !isSelectionCurrent(incomingSelection) {
1294
1328
  return nil
1295
1329
  }
@@ -1325,7 +1359,7 @@ class BundleFileStorageService: BundleStorageService {
1325
1359
  toBundleId: stagingSelection.bundleId
1326
1360
  ),
1327
1361
  highestSeenCatalogs: metadata.highestSeenCatalogs,
1328
- currentSelectionContexts: metadata.currentSelectionContexts,
1362
+ currentSelectionContexts: contextsAfterCommitting(metadata.currentSelectionContexts, incomingSelection),
1329
1363
  updatedAt: Date().timeIntervalSince1970 * 1000
1330
1364
  )
1331
1365
  }
@@ -1424,6 +1458,21 @@ class BundleFileStorageService: BundleStorageService {
1424
1458
  "\(catalogId)|\(scopeKey)"
1425
1459
  }
1426
1460
 
1461
+ /// The selection contexts that stay current once `selection` commits: only
1462
+ /// its own scope's, or none for a staging without a Release selection. A
1463
+ /// check made before this commit, of another scope such as another channel,
1464
+ /// no longer stages over it.
1465
+ private func contextsAfterCommitting(
1466
+ _ contexts: [String: String],
1467
+ _ selection: PersistedSelection?
1468
+ ) -> [String: String] {
1469
+ guard let catalogId = selection?.catalogId, let scopeKey = selection?.scopeKey else {
1470
+ return [:]
1471
+ }
1472
+ let stateKey = releaseScopeStateKey(catalogId: catalogId, scopeKey: scopeKey)
1473
+ return contexts.filter { $0.key == stateKey }
1474
+ }
1475
+
1427
1476
  private func selectionContextValue(channel: String, selectionContextHash: String) -> String {
1428
1477
  "\(channel)\n\(selectionContextHash)"
1429
1478
  }
@@ -1511,7 +1560,7 @@ class BundleFileStorageService: BundleStorageService {
1511
1560
  )
1512
1561
  }
1513
1562
 
1514
- private func isSelectionCurrent(_ selection: PersistedSelection) -> Bool {
1563
+ func isSelectionCurrent(_ selection: PersistedSelection) -> Bool {
1515
1564
  guard let catalogId = selection.catalogId,
1516
1565
  let scopeKey = selection.scopeKey,
1517
1566
  let generation = selection.generation,
@@ -1529,13 +1578,6 @@ class BundleFileStorageService: BundleStorageService {
1529
1578
  )
1530
1579
  }
1531
1580
 
1532
- func stageReleaseSelection(_ selection: PersistedSelection) -> Bool {
1533
- releaseStateLock.lock()
1534
- defer { releaseStateLock.unlock() }
1535
- guard isSelectionCurrent(selection) else { return false }
1536
- pendingInstallSelections[selection.bundleId] = selection
1537
- return true
1538
- }
1539
1581
 
1540
1582
  func commitReleaseSelection(_ selection: PersistedSelection) -> Bool {
1541
1583
  releaseStateLock.lock()
@@ -1547,6 +1589,15 @@ class BundleFileStorageService: BundleStorageService {
1547
1589
  || metadata.stagingBundleId == selection.bundleId {
1548
1590
  metadata.stagingBundleId = selection.bundleId
1549
1591
  metadata.stagingSelection = selection
1592
+ // The launch of the staged bundle reports this Release as applied.
1593
+ if let transition = metadata.pendingSelectionTransition, transition.toBundleId == selection.bundleId {
1594
+ metadata.pendingSelectionTransition = PendingSelectionTransition(
1595
+ fromReleaseId: transition.fromReleaseId,
1596
+ fromBundleId: transition.fromBundleId,
1597
+ toReleaseId: selection.releaseId,
1598
+ toBundleId: transition.toBundleId
1599
+ )
1600
+ }
1550
1601
  } else if metadata.stableSelection?.bundleId == selection.bundleId
1551
1602
  || metadata.stableBundleId == selection.bundleId {
1552
1603
  metadata.stableBundleId = selection.bundleId
@@ -1566,6 +1617,7 @@ class BundleFileStorageService: BundleStorageService {
1566
1617
  metadata.pendingSelectionTransition = nil
1567
1618
  }
1568
1619
  metadata.updatedAt = Date().timeIntervalSince1970 * 1000
1620
+ metadata.currentSelectionContexts = contextsAfterCommitting(metadata.currentSelectionContexts, selection)
1569
1621
  guard saveMetadata(metadata) else { return false }
1570
1622
  do {
1571
1623
  try preferences.setItem(
@@ -1608,7 +1660,7 @@ class BundleFileStorageService: BundleStorageService {
1608
1660
  * @return Result with the directory path or error
1609
1661
  */
1610
1662
  func bundleStoreDir() -> Result<String, Error> {
1611
- let path = (fileSystem.documentsPath() as NSString).appendingPathComponent("bundle-store")
1663
+ let path = instance.bundleStorePath(documentsPath: fileSystem.documentsPath())
1612
1664
  return ensureDirectoryExists(path: path)
1613
1665
  }
1614
1666
 
@@ -1618,7 +1670,7 @@ class BundleFileStorageService: BundleStorageService {
1618
1670
  * @return Result with the directory path or error
1619
1671
  */
1620
1672
  func tempDir() -> Result<String, Error> {
1621
- let path = (fileSystem.documentsPath() as NSString).appendingPathComponent("bundle-temp")
1673
+ let path = instance.tempPath(documentsPath: fileSystem.documentsPath())
1622
1674
  return ensureDirectoryExists(path: path)
1623
1675
  }
1624
1676
 
@@ -1998,20 +2050,69 @@ class BundleFileStorageService: BundleStorageService {
1998
2050
  )
1999
2051
  }
2000
2052
 
2001
- func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection {
2002
- builtInAssetResolver.use(bundle: bundle)
2053
+ func peekLaunchBundleId() -> String? {
2054
+ // Reads the store without bundleStoreDir(), which creates it.
2055
+ let storeDir = instance.bundleStorePath(documentsPath: fileSystem.documentsPath())
2056
+ let metadataFile = URL(fileURLWithPath: storeDir).appendingPathComponent(BundleMetadata.metadataFilename)
2057
+ guard let metadata = BundleMetadata.load(from: metadataFile, expectedIsolationKey: isolationKey) else {
2058
+ return nil
2059
+ }
2060
+ func hasBundleFile(_ bundleId: String) -> Bool {
2061
+ let directory = (storeDir as NSString).appendingPathComponent(bundleId)
2062
+ guard case .success(let bundlePath) = findBundleFile(in: directory, expectedBundleId: bundleId) else {
2063
+ return false
2064
+ }
2065
+ return bundlePath != nil
2066
+ }
2067
+ if let stagingBundleId = metadata.stagingBundleId, hasBundleFile(stagingBundleId) {
2068
+ return stagingBundleId
2069
+ }
2070
+ if let stableBundleId = metadata.stableBundleId, hasBundleFile(stableBundleId) {
2071
+ return stableBundleId
2072
+ }
2073
+ return nil
2074
+ }
2075
+
2076
+ func settlePreviousLaunch(pendingRecovery: () -> PendingCrashRecovery?) {
2077
+ settleLock.lock()
2078
+ defer { settleLock.unlock() }
2079
+ guard !hasSettledPreviousLaunch else { return }
2080
+ hasSettledPreviousLaunch = true
2081
+ // Cleared before the rollbacks below, so the launch reports their RECOVERED.
2003
2082
  saveLaunchReport(nil)
2004
- loadOrCreateInstallIdentity()
2005
- applyPendingRecoveryIfNeeded(pendingRecovery)
2083
+ applyPendingRecoveryIfNeeded(pendingRecovery())
2006
2084
  // Only a new storage instance consumes an unfinished launch. Repeated bundle
2007
2085
  // lookups or bridge reloads in this process must not reject its own launch.
2008
- if !hasPreparedLaunch,
2009
- let metadata = loadMetadataOrNull(),
2086
+ if let metadata = loadMetadataOrNull(),
2010
2087
  metadata.verificationPending,
2011
2088
  metadata.launchInProgress,
2012
2089
  let stagingBundleId = metadata.stagingBundleId {
2013
2090
  rollbackPendingBundle(stagingBundleId, unfinishedLaunch: true)
2014
2091
  }
2092
+ // A pending bundle whose file is gone rolls back, as selectLaunch does.
2093
+ if let metadata = loadMetadataOrNull(),
2094
+ metadata.verificationPending,
2095
+ let stagingBundleId = metadata.stagingBundleId,
2096
+ case .success(let storeDir) = bundleStoreDir() {
2097
+ let stagingBundleDir = (storeDir as NSString).appendingPathComponent(stagingBundleId)
2098
+ if case .success(let bundlePath) = findBundleFile(in: stagingBundleDir, expectedBundleId: stagingBundleId),
2099
+ bundlePath != nil {
2100
+ return
2101
+ }
2102
+ rollbackPendingBundle(stagingBundleId)
2103
+ }
2104
+ }
2105
+
2106
+ func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection {
2107
+ builtInAssetResolver.use(bundle: bundle)
2108
+ loadOrCreateInstallIdentity()
2109
+ if hasPreparedLaunch {
2110
+ // A later launch in this process, after a reset or a reload.
2111
+ saveLaunchReport(nil)
2112
+ applyPendingRecoveryIfNeeded(pendingRecovery)
2113
+ } else {
2114
+ settlePreviousLaunch { pendingRecovery }
2115
+ }
2015
2116
  hasPreparedLaunch = true
2016
2117
  // A launch is recorded only once it can show UI (markLaunchStarted).
2017
2118
  let selection = selectLaunch(bundle: bundle)
@@ -2029,7 +2130,7 @@ class BundleFileStorageService: BundleStorageService {
2029
2130
  * @param progressHandler Callback for download and extraction progress (0.0 to 1.0)
2030
2131
  * @param completion Callback with result of the operation
2031
2132
  */
2032
- func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL? = nil, assets: [String: ChangedAssetDescriptor], fileResolver: ArtifactFileResolver? = nil, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void) {
2133
+ func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL? = nil, assets: [String: ChangedAssetDescriptor], fileResolver: ArtifactFileResolver? = nil, selection: PersistedSelection? = nil, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void) {
2033
2134
  // Check if bundle is in crashed history
2034
2135
  if getCrashHistory().contains(bundleId) {
2035
2136
  NSLog("[BundleStorage] Bundle '\(bundleId)' is in crashed history, rejecting update")
@@ -2053,6 +2154,7 @@ class BundleFileStorageService: BundleStorageService {
2053
2154
  archiveUrl: archiveUrl,
2054
2155
  changedAssets: assets,
2055
2156
  fileResolver: fileResolver,
2157
+ selection: selection,
2056
2158
  storeDir: storeDir,
2057
2159
  progressHandler: progressHandler,
2058
2160
  completion: completion
@@ -2079,6 +2181,16 @@ class BundleFileStorageService: BundleStorageService {
2079
2181
  guard let file = files[path], let descriptor = downloads[path] else {
2080
2182
  throw BundleStorageError.invalidBundle
2081
2183
  }
2184
+ // A download needs an absolute HTTP(S) URL with a host.
2185
+ guard ["http", "https"].contains(file.url.scheme?.lowercased() ?? ""),
2186
+ file.url.host?.isEmpty == false else {
2187
+ throw UpdateFailureError(
2188
+ underlying: NSError(domain: "HotUpdater", code: 0, userInfo: [
2189
+ NSLocalizedDescriptionKey: "Invalid '\(path)' provided: \(file.url.absoluteString)"
2190
+ ]),
2191
+ failure: UpdateFailure.invalidResponse.with(resource: .file)
2192
+ )
2193
+ }
2082
2194
  downloads[path] = ChangedAssetDescriptor(
2083
2195
  fileUrl: file.url,
2084
2196
  fileHash: descriptor.fileHash,
@@ -2481,6 +2593,7 @@ class BundleFileStorageService: BundleStorageService {
2481
2593
 
2482
2594
  private func promoteStagedBundle(
2483
2595
  bundleId: String,
2596
+ selection: PersistedSelection?,
2484
2597
  stagingDirectory: String,
2485
2598
  finalDirectory: String,
2486
2599
  bundlePathInStaging: String
@@ -2488,7 +2601,11 @@ class BundleFileStorageService: BundleStorageService {
2488
2601
  releaseStateLock.lock()
2489
2602
  defer { releaseStateLock.unlock() }
2490
2603
  let currentMetadata = loadMetadataOrNull() ?? createInitialMetadata()
2491
- guard let updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId) else {
2604
+ guard let updatedMetadata = prepareMetadataForNewStagingBundle(
2605
+ currentMetadata,
2606
+ bundleId: bundleId,
2607
+ incomingSelection: selection
2608
+ ) else {
2492
2609
  throw BundleStorageError.unknown(StaleReleaseSelectionError())
2493
2610
  }
2494
2611
  let finalBundlePath = try resolveBundlePathAfterMove(
@@ -2535,6 +2652,7 @@ class BundleFileStorageService: BundleStorageService {
2535
2652
  archiveUrl: URL?,
2536
2653
  changedAssets: [String: ChangedAssetDescriptor],
2537
2654
  fileResolver: ArtifactFileResolver?,
2655
+ selection: PersistedSelection?,
2538
2656
  storeDir: String,
2539
2657
  progressHandler: @escaping (UpdateProgressPayload) -> Void,
2540
2658
  completion: @escaping (Result<UpdateDelivery, Error>) -> Void
@@ -2604,7 +2722,8 @@ class BundleFileStorageService: BundleStorageService {
2604
2722
  failurePhase = .verification
2605
2723
  let manifestVerificationResult = SignatureVerifier.verifyBundle(
2606
2724
  fileURL: URL(fileURLWithPath: tempManifestPath),
2607
- fileHash: manifestFileHash
2725
+ fileHash: manifestFileHash,
2726
+ publicKey: instance.publicKey()
2608
2727
  )
2609
2728
  guard case .success = manifestVerificationResult else {
2610
2729
  if case .failure(let error) = manifestVerificationResult {
@@ -2831,6 +2950,7 @@ class BundleFileStorageService: BundleStorageService {
2831
2950
 
2832
2951
  let updatedMetadata = try promoteStagedBundle(
2833
2952
  bundleId: bundleId,
2953
+ selection: selection,
2834
2954
  stagingDirectory: tmpDir,
2835
2955
  finalDirectory: realDir,
2836
2956
  bundlePathInStaging: bundlePathInTmp
@@ -2881,6 +3001,21 @@ class BundleFileStorageService: BundleStorageService {
2881
3001
  _ = saveMetadata(metadata)
2882
3002
  }
2883
3003
 
3004
+ @discardableResult
3005
+ func clearLaunchStarted(bundleId: String?) -> Bool {
3006
+ releaseStateLock.lock()
3007
+ defer { releaseStateLock.unlock() }
3008
+ guard let bundleId,
3009
+ var metadata = loadMetadataOrNull(),
3010
+ metadata.verificationPending,
3011
+ metadata.launchInProgress,
3012
+ metadata.stagingBundleId == bundleId else {
3013
+ return false
3014
+ }
3015
+ metadata.launchInProgress = false
3016
+ return saveMetadata(metadata)
3017
+ }
3018
+
2884
3019
  func markLaunchCompleted(bundleId: String?) {
2885
3020
  // Before the guard: content from the built-in bundle (nil) also counts.
2886
3021
  readyInterruptedLaunchRetry(bundleId)
@@ -3077,6 +3212,9 @@ class BundleFileStorageService: BundleStorageService {
3077
3212
  clearedMetadata.verificationPending = false
3078
3213
  clearedMetadata.pendingTransition = nil
3079
3214
  clearedMetadata.pendingSelectionTransition = nil
3215
+ // Selections made before the reset no longer stage; the high-water
3216
+ // marks stay, so an older catalog is still refused.
3217
+ clearedMetadata.currentSelectionContexts = [:]
3080
3218
  clearedMetadata.updatedAt = Date().timeIntervalSince1970 * 1000
3081
3219
 
3082
3220
  guard saveMetadata(clearedMetadata) else {
@@ -2,6 +2,10 @@ import Foundation
2
2
  import UIKit
3
3
 
4
4
  final class CohortService {
5
+ /// App-wide: every instance's service reads and creates the same stored
6
+ /// cohort, so instances that check at once never pick different ones.
7
+ private static let lock = NSLock()
8
+
5
9
  private let userDefaults: UserDefaults
6
10
 
7
11
  private let cohortKey = "HotUpdater_CustomCohort"
@@ -41,10 +45,14 @@ final class CohortService {
41
45
  if cohort.isEmpty {
42
46
  return
43
47
  }
48
+ Self.lock.lock()
49
+ defer { Self.lock.unlock() }
44
50
  userDefaults.set(cohort, forKey: cohortKey)
45
51
  }
46
52
 
47
53
  func getCohort() -> String {
54
+ Self.lock.lock()
55
+ defer { Self.lock.unlock() }
48
56
  if let cohort = userDefaults.string(forKey: cohortKey), !cohort.isEmpty {
49
57
  return cohort
50
58
  }