@hot-updater/react-native 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +1 -1
  2. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +91 -4
  3. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +2 -1
  4. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +27 -5
  5. package/android/src/main/java/com/hotupdater/HotUpdaterRestartActivity.kt +39 -0
  6. package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +2 -0
  7. package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +9 -4
  8. package/android/src/main/java/com/hotupdater/PostCrashUpdateCheck.kt +195 -0
  9. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +125 -3
  10. package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +90 -10
  11. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +2 -0
  12. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +15 -4
  13. package/ios/HotUpdater/Internal/PostCrashUpdateCheck.swift +248 -0
  14. package/ios/HotUpdater/Package.swift +1 -0
  15. package/lib/commonjs/checkForUpdate.js +10 -1
  16. package/lib/commonjs/checkForUpdate.js.map +1 -1
  17. package/lib/commonjs/httpClient.js +1 -0
  18. package/lib/commonjs/httpClient.js.map +1 -1
  19. package/lib/commonjs/recoveryCheckConfig.js +58 -0
  20. package/lib/commonjs/recoveryCheckConfig.js.map +1 -0
  21. package/lib/commonjs/sdkVersion.js +1 -1
  22. package/lib/module/checkForUpdate.js +10 -1
  23. package/lib/module/checkForUpdate.js.map +1 -1
  24. package/lib/module/httpClient.js +1 -0
  25. package/lib/module/httpClient.js.map +1 -1
  26. package/lib/module/recoveryCheckConfig.js +54 -0
  27. package/lib/module/recoveryCheckConfig.js.map +1 -0
  28. package/lib/module/sdkVersion.js +1 -1
  29. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/httpClient.d.ts +2 -0
  31. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/recoveryCheckConfig.d.ts +28 -0
  33. package/lib/typescript/commonjs/recoveryCheckConfig.d.ts.map +1 -0
  34. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  35. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  36. package/lib/typescript/module/httpClient.d.ts +2 -0
  37. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  38. package/lib/typescript/module/recoveryCheckConfig.d.ts +28 -0
  39. package/lib/typescript/module/recoveryCheckConfig.d.ts.map +1 -0
  40. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  41. package/package.json +4 -4
  42. package/src/checkForUpdate.ts +10 -1
  43. package/src/httpClient.ts +3 -0
  44. package/src/recoveryCheckConfig.ts +68 -0
  45. package/src/sdkVersion.ts +1 -1
@@ -149,6 +149,20 @@ private func hotUpdaterGetMinBundleId() -> String {
149
149
  private let recoveryManager: HotUpdaterRecoveryManager
150
150
  private let releaseCatalogCache: ReleaseCatalogCacheService
151
151
  private let keyValueStorage: KeyValueStorageService
152
+ /// Main only: the native update check of the first launch with UI after
153
+ /// a crash. None in a development build.
154
+ private let postCrashUpdateCheck: HotUpdaterPostCrashUpdateCheck?
155
+
156
+ /// A development build, where a crash is a RedBox during development,
157
+ /// never a reason to check for a fix: main records no crash after its
158
+ /// watch and never checks after one.
159
+ static let isDevelopmentBuild: Bool = {
160
+ #if DEBUG
161
+ return true
162
+ #else
163
+ return false
164
+ #endif
165
+ }()
152
166
  // Read by native update checks on other threads while React Native sets it.
153
167
  private let launchSelectionLock = NSLock()
154
168
  private var launchSelectionValue: LaunchSelection?
@@ -282,6 +296,13 @@ private func hotUpdaterGetMinBundleId() -> String {
282
296
  self.recoveryManager = recoveryManager
283
297
  self.releaseCatalogCache = releaseCatalogCache
284
298
  self.keyValueStorage = keyValueStorage
299
+ self.postCrashUpdateCheck = environment.isMain && !HotUpdaterImpl.isDevelopmentBuild
300
+ ? HotUpdaterPostCrashUpdateCheck(
301
+ storage: keyValueStorage,
302
+ whenShown: HotUpdaterImpl.whenAppIsActive,
303
+ check: HotUpdaterImpl.checkAfterCrash
304
+ )
305
+ : nil
285
306
  super.init()
286
307
  recoveryManager.onRollbackCharged = { [weak self] in
287
308
  self?.invalidateLaunchPreparation()
@@ -382,6 +403,11 @@ private func hotUpdaterGetMinBundleId() -> String {
382
403
  HotUpdaterMainBundle.shared.note(bundle)
383
404
  }
384
405
  let bundleURL = prepareLaunchIfNeeded(bundle: bundle).bundleURL
406
+ // After the launch lock is released: the check reads the launch
407
+ // through it, and never holds up React Native's start.
408
+ if environment.isMain, self === HotUpdaterNativeUpdateBridge.sharedImpl {
409
+ postCrashUpdateCheck?.startIfNeeded()
410
+ }
385
411
  if !environment.isMain, let bundleURL {
386
412
  // The runtime started with this URL finds this impl by it.
387
413
  HotUpdaterNativeUpdateBridge.router.record(launchURL: bundleURL, name: environment.name)
@@ -995,7 +1021,7 @@ private func hotUpdaterGetMinBundleId() -> String {
995
1021
  // Settles what the previous process left first, so the check reads the
996
1022
  // selection, launch state, and crash history this launch will use.
997
1023
  // Each instance consumes only its own crash marker.
998
- bundleStorage.settlePreviousLaunch(pendingRecovery: { self.recoveryManager.consumePendingCrashRecovery() })
1024
+ bundleStorage.settlePreviousLaunch(pendingRecovery: { self.consumePendingCrashRecovery() })
999
1025
  return bundleStorage.peekLaunchBundleId()
1000
1026
  }
1001
1027
  }
@@ -1054,7 +1080,7 @@ private func hotUpdaterGetMinBundleId() -> String {
1054
1080
  // retry replaces it.
1055
1081
  let (selection, generation, epoch) = launchEpoch.prepareCurrent(
1056
1082
  prepare: {
1057
- let pendingRecovery = recoveryManager.consumePendingCrashRecovery()
1083
+ let pendingRecovery = consumePendingCrashRecovery()
1058
1084
  return bundleStorage.prepareLaunch(bundle: bundle, pendingRecovery: pendingRecovery)
1059
1085
  },
1060
1086
  startMonitoring: { selection in
@@ -1084,6 +1110,61 @@ private func hotUpdaterGetMinBundleId() -> String {
1084
1110
  return selection
1085
1111
  }
1086
1112
 
1113
+ /// Keeps a native check's request the server answered for main's check
1114
+ /// after a crash, as JavaScript's checks do; an app that checks only
1115
+ /// natively has no other. Nothing for a named instance or in a
1116
+ /// development build, which have no such check.
1117
+ func recordRecoveryRequest(_ request: NativeCheckRequest) {
1118
+ guard postCrashUpdateCheck != nil, let json = HotUpdaterRecoveryCheckConfig.json(for: request) else { return }
1119
+ keyValueStorage.setItem(HotUpdaterRecoveryCheckConfig.storageKey, value: json)
1120
+ }
1121
+
1122
+ /// This instance's crash marker; for main outside a development build,
1123
+ /// a marker that doesn't roll back also leaves the post-crash update
1124
+ /// check pending.
1125
+ private func consumePendingCrashRecovery() -> PendingCrashRecovery? {
1126
+ guard let postCrashUpdateCheck else {
1127
+ return recoveryManager.consumePendingCrashRecovery()
1128
+ }
1129
+ return postCrashUpdateCheck.consumeMarker { recoveryManager.consumePendingCrashRecovery() }
1130
+ }
1131
+
1132
+ /// Runs `action` once the app is active: a launch without UI, such as a
1133
+ /// silent push, leaves the post-crash check to a later foreground.
1134
+ private static func whenAppIsActive(_ action: @escaping () -> Void) {
1135
+ DispatchQueue.main.async {
1136
+ if UIApplication.shared.applicationState == .active {
1137
+ action()
1138
+ return
1139
+ }
1140
+ NSLog("[HotUpdaterRecovery] Post-crash update check: skipped until the app becomes active")
1141
+ // Boxed: the observer block may not capture a mutable variable.
1142
+ let registration = HotUpdaterObserverRegistration()
1143
+ registration.token = NotificationCenter.default.addObserver(
1144
+ forName: UIApplication.didBecomeActiveNotification,
1145
+ object: nil,
1146
+ queue: .main
1147
+ ) { _ in
1148
+ guard let token = registration.token else { return }
1149
+ NotificationCenter.default.removeObserver(token)
1150
+ registration.token = nil
1151
+ action()
1152
+ }
1153
+ }
1154
+ }
1155
+
1156
+ /// Main's native check of the current channel, which stages an update it
1157
+ /// finds for the next launch; no reload.
1158
+ private static func checkAfterCrash(
1159
+ _ request: NativeCheckRequest
1160
+ ) async throws -> HotUpdaterPostCrashUpdateCheck.Outcome {
1161
+ let checker = HotUpdaterNativeUpdateBridge.checker(impl: HotUpdaterNativeUpdateBridge.sharedImpl)
1162
+ guard let update = try await checker.checkForUpdate(request) else {
1163
+ return .noUpdate
1164
+ }
1165
+ return try await update.updateBundle() ? .staged(bundleId: update.bundleId) : .superseded
1166
+ }
1167
+
1087
1168
  // A background launch, such as a silent push, may never render. Only a
1088
1169
  // launch in the foreground may leave an unfinished launch for the next
1089
1170
  // process to roll back.
@@ -1132,6 +1213,12 @@ private func hotUpdaterGetMinBundleId() -> String {
1132
1213
  }
1133
1214
  }
1134
1215
 
1216
+ /// A one-shot notification observer's token, which its own block removes.
1217
+ /// Main thread only.
1218
+ private final class HotUpdaterObserverRegistration {
1219
+ var token: NSObjectProtocol?
1220
+ }
1221
+
1135
1222
  /// One instance's crash recovery: consumes its marker at launch, watches its
1136
1223
  /// launch until 10 seconds after its first content, and for main, reloads
1137
1224
  /// in-process when its JavaScript fails before first content. The state lives
@@ -1356,9 +1443,15 @@ private final class HotUpdaterSignalCrashSlots: HotUpdaterCrashSlotSink {
1356
1443
  final class HotUpdaterRecoveryHub: NSObject {
1357
1444
  static let shared = HotUpdaterRecoveryHub()
1358
1445
 
1359
- let coordinator = HotUpdaterRecoveryCoordinator(sink: HotUpdaterSignalCrashSlots())
1446
+ let coordinator = HotUpdaterRecoveryCoordinator(
1447
+ sink: HotUpdaterSignalCrashSlots(),
1448
+ recordsCrashesAfterWatch: !HotUpdaterImpl.isDevelopmentBuild
1449
+ )
1360
1450
  /// The launch each runtime runs, by its module.
1361
1451
  private let runtimeLaunches = HotUpdaterRuntimeLaunches()
1452
+ /// Content that appeared in the background, until the foreground.
1453
+ private let backgroundContent = HotUpdaterBackgroundContent()
1454
+ private var backgroundContentObservers: [NSObjectProtocol] = []
1362
1455
 
1363
1456
  override init() {
1364
1457
  super.init()
@@ -1688,6 +1781,17 @@ final class HotUpdaterRecoveryHub: NSObject {
1688
1781
 
1689
1782
  @objc private func handleContentDidAppear(_ notification: Notification) {
1690
1783
  let view = notification.object as AnyObject?
1784
+ // React Native posts content on the main thread, where UIApplication
1785
+ // is read. Content the app shows in the background verifies nothing
1786
+ // until the app comes to the foreground (`HotUpdaterBackgroundContent`).
1787
+ if Thread.isMainThread, UIApplication.shared.applicationState == .background {
1788
+ holdUntilForeground(view)
1789
+ return
1790
+ }
1791
+ contentAppeared(view)
1792
+ }
1793
+
1794
+ private func contentAppeared(_ view: AnyObject?) {
1691
1795
  let owner = owner(of: view)
1692
1796
  // A view no runtime claims is main's, as it always was, unless a
1693
1797
  // named runtime that has no module yet may have shown it.
@@ -1723,6 +1827,24 @@ final class HotUpdaterRecoveryHub: NSObject {
1723
1827
  }
1724
1828
  }
1725
1829
 
1830
+ /// Main thread only.
1831
+ private func holdUntilForeground(_ view: AnyObject?) {
1832
+ guard backgroundContent.hold(view) else { return }
1833
+ backgroundContentObservers = [
1834
+ UIApplication.willEnterForegroundNotification,
1835
+ UIApplication.didBecomeActiveNotification,
1836
+ ].map { name in
1837
+ NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
1838
+ guard let self else { return }
1839
+ self.backgroundContentObservers.forEach { NotificationCenter.default.removeObserver($0) }
1840
+ self.backgroundContentObservers = []
1841
+ for view in self.backgroundContent.takeAll() {
1842
+ self.contentAppeared(view)
1843
+ }
1844
+ }
1845
+ }
1846
+ }
1847
+
1726
1848
  /// Credits the content held for runtimes that had no module yet. Main
1727
1849
  /// thread only.
1728
1850
  private func releaseHeldContent() {
@@ -83,7 +83,10 @@ protocol HotUpdaterCrashSlotSink: AnyObject {
83
83
  /// selected in the foreground, and its fatal before first content reloads it
84
84
  /// in-process. A named launch counts as started only once a view of its
85
85
  /// runtime is shown, since a runtime warmed up without UI must not be rolled
86
- /// back at the next start.
86
+ /// back at the next start. After its watch ends, main still writes its marker
87
+ /// on a crash of its own or of the process, without rollback: its next launch
88
+ /// then checks for a fix natively, since this bundle's JavaScript may crash
89
+ /// before it can.
87
90
  final class HotUpdaterRecoveryCoordinator {
88
91
  /// Native crash markers the signal handler can write, main's included.
89
92
  /// More instances than this still roll back an unfinished launch at their
@@ -142,6 +145,23 @@ final class HotUpdaterRecoveryCoordinator {
142
145
  fileprivate(set) var isCharged = false
143
146
  /// The directory of `markerURL` was created.
144
147
  fileprivate var hasMarkerDirectory = false
148
+
149
+ /// Main only, outside development builds: a crash after its watch
150
+ /// ended still writes its marker (`recordsCrashesAfterWatch`).
151
+ fileprivate var recordsAfterWatch = false
152
+
153
+ /// Whether a crash now writes this launch's marker: while it is
154
+ /// watched, and for main for the rest of the process once it
155
+ /// launched, so the next launch knows to check for a fix
156
+ /// (`HotUpdaterPostCrashUpdateCheck`).
157
+ fileprivate var recordsCrashes: Bool {
158
+ isMonitoring || (recordsAfterWatch && generation > 0)
159
+ }
160
+ /// The bundle a marker of this launch names: main's selected bundle
161
+ /// once its watch ended, which cleared `bundleId`.
162
+ fileprivate var markerBundleId: String? {
163
+ recordsAfterWatch && !isMonitoring ? selectedBundleId : bundleId
164
+ }
145
165
  }
146
166
 
147
167
  enum RecoveryRequest: Equatable {
@@ -251,9 +271,18 @@ final class HotUpdaterRecoveryCoordinator {
251
271
  /// verified and the next start would roll it back.
252
272
  static let heldContentBound: TimeInterval = 10
253
273
 
254
- init(sink: HotUpdaterCrashSlotSink, now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) {
274
+ /// Main's crashes after its watch ended write its marker. A development
275
+ /// build turns this off: a RedBox is no reason to check for a fix.
276
+ private let recordsCrashesAfterWatch: Bool
277
+
278
+ init(
279
+ sink: HotUpdaterCrashSlotSink,
280
+ now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime },
281
+ recordsCrashesAfterWatch: Bool = true
282
+ ) {
255
283
  self.sink = sink
256
284
  self.now = now
285
+ self.recordsCrashesAfterWatch = recordsCrashesAfterWatch
257
286
  }
258
287
 
259
288
  // MARK: - Instances
@@ -267,6 +296,7 @@ final class HotUpdaterRecoveryCoordinator {
267
296
  return launch
268
297
  }
269
298
  let launch = Launch(name: name, markerURL: markerURL, storage: storage)
299
+ launch.recordsAfterWatch = launch.isMain && recordsCrashesAfterWatch
270
300
  launches[name] = launch
271
301
  return launch
272
302
  }
@@ -737,7 +767,8 @@ final class HotUpdaterRecoveryCoordinator {
737
767
  !launch.isMain && launch.isMonitoring && launch.isAwaitingContent && !launch.hasResolvedRuntime
738
768
  }
739
769
 
740
- /// Ends the grace period after first content.
770
+ /// Ends the grace period after first content. Main's crashes are still
771
+ /// recorded afterwards, without rollback, naming the bundle it runs.
741
772
  func finishMonitoring(name: String, generation: Int) {
742
773
  withLock {
743
774
  guard let launch = launches[name], launch.generation == generation else { return }
@@ -873,7 +904,9 @@ final class HotUpdaterRecoveryCoordinator {
873
904
  writeMarker(launch)
874
905
  }
875
906
  let claim = attribute(to: owner)
876
- if launch.isMonitoring, !markerFirst {
907
+ // Main's marker is written after its watch ended too, without
908
+ // rollback.
909
+ if launch.recordsCrashes, !markerFirst {
877
910
  writeMarker(launch)
878
911
  }
879
912
  return claim
@@ -1119,7 +1152,7 @@ final class HotUpdaterRecoveryCoordinator {
1119
1152
  charging {
1120
1153
  if let attributedName, let launch = launches[attributedName] {
1121
1154
  // Not a later launch of the claimed instance.
1122
- if launch.isMonitoring, launch.generation == attributedGeneration {
1155
+ if launch.recordsCrashes, launch.generation == attributedGeneration {
1123
1156
  writeMarker(launch)
1124
1157
  }
1125
1158
  return
@@ -1191,9 +1224,9 @@ final class HotUpdaterRecoveryCoordinator {
1191
1224
 
1192
1225
  private func chargeUnattributedCrash() {
1193
1226
  for launch in launches.values {
1194
- // Main writes while it watches its launch, as it always has; a
1195
- // marker of a verified launch carries `shouldRollback: false`.
1196
- if launch.isMain ? launch.isMonitoring : launch.crashCountsAgainstLaunch {
1227
+ // Main writes for the whole process once it launched; a marker
1228
+ // of a verified launch carries `shouldRollback: false`.
1229
+ if launch.isMain ? launch.recordsCrashes : launch.crashCountsAgainstLaunch {
1197
1230
  writeMarker(launch)
1198
1231
  }
1199
1232
  }
@@ -1204,7 +1237,7 @@ final class HotUpdaterRecoveryCoordinator {
1204
1237
  /// `onRollbackCharged` once the charge finishes (`charging`).
1205
1238
  private func writeMarker(_ launch: Launch) {
1206
1239
  HotUpdaterCrashMarker.write(
1207
- bundleId: launch.bundleId,
1240
+ bundleId: launch.markerBundleId,
1208
1241
  shouldRollback: launch.shouldRollback,
1209
1242
  to: launch.markerURL
1210
1243
  )
@@ -1256,7 +1289,17 @@ final class HotUpdaterRecoveryCoordinator {
1256
1289
  guard let slot = launch.slot else { return }
1257
1290
  let flag = launch.crashCountsAgainstLaunch
1258
1291
  slotFlags[slot] = flag
1259
- sink.updateSlot(slot, markerPath: launch.markerURL.path, bundleId: launch.bundleId, shouldRollback: flag)
1292
+ // Main's slot writes on any signal, and keeps naming its bundle
1293
+ // after its watch ended. A development build's names no marker once
1294
+ // the watch ended, so a signal then records nothing either: an empty
1295
+ // path is a slot the signal handler skips.
1296
+ let recordsNow = !launch.isMain || launch.isMonitoring || launch.recordsAfterWatch
1297
+ sink.updateSlot(
1298
+ slot,
1299
+ markerPath: recordsNow ? launch.markerURL.path : "",
1300
+ bundleId: launch.markerBundleId,
1301
+ shouldRollback: flag
1302
+ )
1260
1303
  if !launch.hasMarkerDirectory {
1261
1304
  try? FileManager.default.createDirectory(
1262
1305
  at: launch.markerURL.deletingLastPathComponent(),
@@ -1551,3 +1594,40 @@ final class HotUpdaterRuntimeLaunches {
1551
1594
  }
1552
1595
  }
1553
1596
 
1597
+ /// First content that appeared while the app was in the background, such as
1598
+ /// the root view a silent push's launch creates. Nobody sees it, so it
1599
+ /// verifies no launch until the app comes to the foreground: the old
1600
+ /// architecture posts content for a root view even when the headless App
1601
+ /// renders nothing, which would otherwise verify a staged bundle no one saw.
1602
+ /// A view torn down before then verifies nothing.
1603
+ final class HotUpdaterBackgroundContent {
1604
+ private struct Entry {
1605
+ weak var view: AnyObject?
1606
+ let hasView: Bool
1607
+ }
1608
+
1609
+ private let lock = NSLock()
1610
+ private var entries: [Entry] = []
1611
+
1612
+ /// Holds `view`'s content for the foreground. Returns true for the first
1613
+ /// one held, when the caller starts waiting for the foreground.
1614
+ func hold(_ view: AnyObject?) -> Bool {
1615
+ lock.lock()
1616
+ defer { lock.unlock() }
1617
+ entries.append(Entry(view: view, hasView: view != nil))
1618
+ return entries.count == 1
1619
+ }
1620
+
1621
+ /// The content held, once: the views still alive, and nil for content
1622
+ /// that came without a view.
1623
+ func takeAll() -> [AnyObject?] {
1624
+ lock.lock()
1625
+ let taken = entries
1626
+ entries = []
1627
+ lock.unlock()
1628
+ return taken.compactMap { entry -> AnyObject?? in
1629
+ if !entry.hasView { return .some(nil) }
1630
+ return entry.view.map { .some($0) }
1631
+ }
1632
+ }
1633
+ }
@@ -24,6 +24,8 @@ final class HotUpdaterImplUpdateHost: NativeUpdateHost {
24
24
 
25
25
  func fingerprintHash() -> String? { impl.getFingerprintHash() }
26
26
 
27
+ func recordRecoveryRequest(_ request: NativeCheckRequest) { impl.recordRecoveryRequest(request) }
28
+
27
29
  func crashHistory() -> [String] { impl.getCrashHistory() }
28
30
 
29
31
  func activeUpdateState() -> [String: Any] { impl.getActiveUpdateState() }
@@ -382,6 +382,13 @@ protocol NativeUpdateHost: NativeUpdateEventStorage {
382
382
  func releaseCatalogCache(_ partition: String) -> String?
383
383
  func setReleaseCatalogCache(_ partition: String, value: String) -> Bool
384
384
  func removeReleaseCatalogCache(_ partition: String)
385
+ /// The server answered `request`: main keeps it for its check after a
386
+ /// crash, as JavaScript's checks do.
387
+ func recordRecoveryRequest(_ request: NativeCheckRequest)
388
+ }
389
+
390
+ extension NativeUpdateHost {
391
+ func recordRecoveryRequest(_ request: NativeCheckRequest) {}
385
392
  }
386
393
 
387
394
  /// A promise rejection from a native method, as JavaScript receives it.
@@ -1225,13 +1232,17 @@ final class NativeUpdateChecker {
1225
1232
  )
1226
1233
  let strategyPath = strategy == .fingerprint ? "fingerprint" : "app-version"
1227
1234
  let catalogURL = "\(baseURL)/release-catalogs/\(strategyPath)/ios/\(channelKey)/\(Self.encodeURIComponent(strategyValue))"
1228
- // The server says this scope has no catalog yet: no update, not a failure.
1229
- guard let catalog = try await fetchReleaseCatalog(
1235
+ let answer = try await fetchReleaseCatalog(
1230
1236
  baseURL: baseURL,
1231
1237
  url: catalogURL,
1232
1238
  expectedScope: expectedScope,
1233
1239
  options: options
1234
- ) else {
1240
+ )
1241
+ // The server answered with a valid catalog (checked as it was read),
1242
+ // or with none yet.
1243
+ host.recordRecoveryRequest(options)
1244
+ // The server says this scope has no catalog yet: no update, not a failure.
1245
+ guard let catalog = answer else {
1235
1246
  return nil
1236
1247
  }
1237
1248
  let catalogId = catalog.catalogId
@@ -1597,7 +1608,7 @@ final class NativeUpdateChecker {
1597
1608
  }
1598
1609
 
1599
1610
  /// Header names lowercased, as `new Headers(requestHeaders)` stores them.
1600
- private static func normalizedHeaders(_ headers: [String: String]) -> [String: String] {
1611
+ static func normalizedHeaders(_ headers: [String: String]) -> [String: String] {
1601
1612
  var normalized = [String: String]()
1602
1613
  for (name, value) in headers {
1603
1614
  normalized[name.lowercased()] = value
@@ -0,0 +1,248 @@
1
+ import Foundation
2
+
3
+ /// What main's JavaScript leaves in its key-value store for the native update
4
+ /// check after a crash: the server and request settings of its last update
5
+ /// check. Without a valid record there is no request at all, since only an
6
+ /// app whose JavaScript checked for updates has agreed to talk to a server.
7
+ struct HotUpdaterRecoveryCheckConfig {
8
+ static let storageKey = "hot-updater/recovery-check-config"
9
+ static let version = 1
10
+ /// `NativeCheckRequest`'s default, for a record written without one.
11
+ static let defaultRequestTimeoutMs: Double = 5000
12
+
13
+ let baseURL: String
14
+ let requestHeaders: [String: String]
15
+ /// Seconds, as `NativeCheckRequest` takes it; JavaScript writes milliseconds.
16
+ let requestTimeout: TimeInterval
17
+ let updateStrategy: HotUpdaterUpdateStrategy
18
+
19
+ /// Nil for a missing, unparsable, or wrong-version record, or one without
20
+ /// an absolute HTTP(S) `baseURL`, a known `updateStrategy`, string
21
+ /// headers, or a timeout of at least a millisecond. A record without
22
+ /// headers or a timeout takes none and the default.
23
+ init?(json value: String?) {
24
+ guard let value,
25
+ let data = value.data(using: .utf8),
26
+ let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
27
+ Self.number(object["version"]) == Double(Self.version),
28
+ let baseURL = object["baseURL"] as? String,
29
+ (try? NativeUpdateChecker.validatedBaseURL(baseURL)) != nil else {
30
+ return nil
31
+ }
32
+ switch object["updateStrategy"] as? String {
33
+ case "appVersion":
34
+ updateStrategy = .appVersion
35
+ case "fingerprint":
36
+ updateStrategy = .fingerprint
37
+ default:
38
+ return nil
39
+ }
40
+ switch object["requestHeaders"] {
41
+ case nil:
42
+ requestHeaders = [:]
43
+ case let headers as [String: Any]:
44
+ var strings: [String: String] = [:]
45
+ for (name, value) in headers {
46
+ guard let value = value as? String else { return nil }
47
+ strings[name] = value
48
+ }
49
+ requestHeaders = strings
50
+ default:
51
+ return nil
52
+ }
53
+ let timeoutMs: Double
54
+ switch object["requestTimeoutMs"] {
55
+ case nil:
56
+ timeoutMs = Self.defaultRequestTimeoutMs
57
+ case let raw:
58
+ guard let number = Self.number(raw), number.isFinite, number >= 1 else { return nil }
59
+ timeoutMs = number
60
+ }
61
+ self.baseURL = baseURL
62
+ requestTimeout = timeoutMs / 1000
63
+ }
64
+
65
+ /// The record of `request`, as JavaScript writes it, for a native check of
66
+ /// main the server answered: its headers as the request sent them, so two
67
+ /// spellings of one name keep the value that went out. Its channel is left
68
+ /// out, as JavaScript leaves it out: the check after a crash uses the
69
+ /// device's own.
70
+ static func json(for request: NativeCheckRequest) -> String? {
71
+ let object: [String: Any] = [
72
+ "version": version,
73
+ "baseURL": request.baseURL,
74
+ "requestHeaders": NativeUpdateChecker.normalizedHeaders(request.requestHeaders),
75
+ "requestTimeoutMs": request.requestTimeout * 1000,
76
+ "updateStrategy": request.updateStrategy.name,
77
+ ]
78
+ guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else {
79
+ return nil
80
+ }
81
+ return String(data: data, encoding: .utf8)
82
+ }
83
+
84
+ /// The check of the current channel with these settings.
85
+ var request: NativeCheckRequest {
86
+ NativeCheckRequest(
87
+ baseURL: baseURL,
88
+ updateStrategy: updateStrategy,
89
+ requestHeaders: requestHeaders,
90
+ channel: nil,
91
+ requestTimeout: requestTimeout
92
+ )
93
+ }
94
+
95
+ /// A JSON number; a JSON boolean, which Foundation also reads as an
96
+ /// NSNumber, is not one.
97
+ private static func number(_ value: Any?) -> Double? {
98
+ guard let number = value as? NSNumber, String(cString: number.objCType) != "c" else {
99
+ return nil
100
+ }
101
+ return number.doubleValue
102
+ }
103
+ }
104
+
105
+ /// Main's native update check at the first launch with UI after a crash. A
106
+ /// bundle whose JavaScript update flow crashes after its first content is
107
+ /// never rolled back and could never fetch its own fix; this checks for one
108
+ /// natively, and stages what it finds for the next launch.
109
+ ///
110
+ /// Consuming any crash marker leaves the check pending in the key-value
111
+ /// store. Once launch preparation finished, the check runs at most once per
112
+ /// process, when the app is shown and JavaScript left a valid config; the
113
+ /// flag is cleared once it finishes, whatever the outcome, so a process that
114
+ /// dies during the check, or a launch without UI, leaves it to the next one.
115
+ final class HotUpdaterPostCrashUpdateCheck: @unchecked Sendable {
116
+ static let pendingKey = "hot-updater/recovery-check-pending"
117
+ static let pendingValue = "1"
118
+
119
+ /// How a check that ran ended, as its log line reports it.
120
+ enum Outcome: Equatable {
121
+ case staged(bundleId: String)
122
+ case noUpdate
123
+ /// The update was found, but another check or reset replaced its
124
+ /// selection before it was staged.
125
+ case superseded
126
+ }
127
+
128
+ private let storage: KeyValueStorageService
129
+ private let queue: DispatchQueue
130
+ private let whenShown: (@escaping () -> Void) -> Void
131
+ private let check: (NativeCheckRequest) async throws -> Outcome
132
+ private let log: (String) -> Void
133
+ private let lock = NSLock()
134
+ private var state = State.idle
135
+
136
+ private enum State {
137
+ case idle
138
+ /// Pending, until the app is shown.
139
+ case waiting
140
+ /// A check is on its way: nothing starts another in this process.
141
+ case started
142
+ }
143
+
144
+ /// - Parameters:
145
+ /// - storage: main's key-value store, the one JavaScript writes to.
146
+ /// - whenShown: runs its argument once the app is shown, now or later.
147
+ /// - check: checks with the request, and stages an update it finds.
148
+ init(
149
+ storage: KeyValueStorageService,
150
+ queue: DispatchQueue = DispatchQueue.global(qos: .utility),
151
+ whenShown: @escaping (@escaping () -> Void) -> Void,
152
+ check: @escaping (NativeCheckRequest) async throws -> Outcome,
153
+ log: @escaping (String) -> Void = { NSLog("[HotUpdaterRecovery] Post-crash update check: \($0)") }
154
+ ) {
155
+ self.storage = storage
156
+ self.queue = queue
157
+ self.whenShown = whenShown
158
+ self.check = check
159
+ self.log = log
160
+ }
161
+
162
+ var isPending: Bool {
163
+ storage.getItem(Self.pendingKey) == Self.pendingValue
164
+ }
165
+
166
+ /// Consumes main's crash marker with `consume`. A marker that doesn't
167
+ /// ask for rollback means the previous process crashed on a bundle this
168
+ /// launch runs again, so the next launch with UI checks for a fix. One
169
+ /// that asks for rollback is settled locally: the bundle that crashed
170
+ /// before its first content is gone, and the one restored passed its own.
171
+ /// It also clears a check an earlier crash left pending, which the
172
+ /// rollback settled too.
173
+ func consumeMarker(_ consume: () -> PendingCrashRecovery?) -> PendingCrashRecovery? {
174
+ let recovery = consume()
175
+ if let recovery {
176
+ storage.setItem(Self.pendingKey, value: recovery.shouldRollback ? nil : Self.pendingValue)
177
+ }
178
+ return recovery
179
+ }
180
+
181
+ /// Call once launch preparation finished. Returns at once: the check runs
182
+ /// on `queue`, after the app is shown, if it is pending, configured, and
183
+ /// none ran in this process.
184
+ func startIfNeeded() {
185
+ queue.async { [self] in
186
+ guard request() != nil, transition(from: .idle, to: .waiting) else { return }
187
+ whenShown { [self] in
188
+ queue.async { [self] in
189
+ // Read again: JavaScript may have refreshed the config
190
+ // while the app wasn't shown.
191
+ guard let request = request() else {
192
+ _ = transition(from: .waiting, to: .idle)
193
+ return
194
+ }
195
+ guard transition(from: .waiting, to: .started) else { return }
196
+ log("started")
197
+ Task { await run(request) }
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ /// The request a pending check sends, or nil when none is pending, one
204
+ /// already ran in this process, or JavaScript left no valid config.
205
+ func request() -> NativeCheckRequest? {
206
+ guard !started, isPending else { return nil }
207
+ guard let config = HotUpdaterRecoveryCheckConfig(
208
+ json: storage.getItem(HotUpdaterRecoveryCheckConfig.storageKey)
209
+ ) else {
210
+ log("skipped no config")
211
+ return nil
212
+ }
213
+ return config.request
214
+ }
215
+
216
+ /// Runs the check, then clears the flag, whatever the outcome.
217
+ func run(_ request: NativeCheckRequest) async {
218
+ let status: String
219
+ do {
220
+ switch try await check(request) {
221
+ case .staged(let bundleId):
222
+ status = "staged \(bundleId)"
223
+ case .noUpdate:
224
+ status = "no update"
225
+ case .superseded:
226
+ status = "skipped superseded"
227
+ }
228
+ } catch {
229
+ status = "failed \(error.localizedDescription)"
230
+ }
231
+ storage.setItem(Self.pendingKey, value: nil)
232
+ log(status)
233
+ }
234
+
235
+ private var started: Bool {
236
+ lock.lock()
237
+ defer { lock.unlock() }
238
+ return state == .started
239
+ }
240
+
241
+ private func transition(from current: State, to next: State) -> Bool {
242
+ lock.lock()
243
+ defer { lock.unlock() }
244
+ guard state == current else { return false }
245
+ state = next
246
+ return true
247
+ }
248
+ }