@hot-updater/react-native 1.1.1 → 1.1.3

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 (46) hide show
  1. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +1 -1
  2. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +105 -83
  3. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +91 -4
  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/BundleFileStorageService.swift +21 -2
  10. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +125 -3
  11. package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +90 -10
  12. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +2 -0
  13. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +15 -4
  14. package/ios/HotUpdater/Internal/PostCrashUpdateCheck.swift +248 -0
  15. package/ios/HotUpdater/Package.swift +1 -0
  16. package/lib/commonjs/checkForUpdate.js +10 -1
  17. package/lib/commonjs/checkForUpdate.js.map +1 -1
  18. package/lib/commonjs/httpClient.js +1 -0
  19. package/lib/commonjs/httpClient.js.map +1 -1
  20. package/lib/commonjs/recoveryCheckConfig.js +58 -0
  21. package/lib/commonjs/recoveryCheckConfig.js.map +1 -0
  22. package/lib/commonjs/sdkVersion.js +1 -1
  23. package/lib/module/checkForUpdate.js +10 -1
  24. package/lib/module/checkForUpdate.js.map +1 -1
  25. package/lib/module/httpClient.js +1 -0
  26. package/lib/module/httpClient.js.map +1 -1
  27. package/lib/module/recoveryCheckConfig.js +54 -0
  28. package/lib/module/recoveryCheckConfig.js.map +1 -0
  29. package/lib/module/sdkVersion.js +1 -1
  30. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/httpClient.d.ts +2 -0
  32. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/recoveryCheckConfig.d.ts +28 -0
  34. package/lib/typescript/commonjs/recoveryCheckConfig.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  36. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  37. package/lib/typescript/module/httpClient.d.ts +2 -0
  38. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  39. package/lib/typescript/module/recoveryCheckConfig.d.ts +28 -0
  40. package/lib/typescript/module/recoveryCheckConfig.d.ts.map +1 -0
  41. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  42. package/package.json +4 -4
  43. package/src/checkForUpdate.ts +10 -1
  44. package/src/httpClient.ts +3 -0
  45. package/src/recoveryCheckConfig.ts +68 -0
  46. package/src/sdkVersion.ts +1 -1
@@ -0,0 +1,195 @@
1
+ package com.hotupdater
2
+
3
+ import android.util.Log
4
+ import kotlinx.coroutines.runBlocking
5
+ import org.json.JSONObject
6
+ import java.util.concurrent.Executors
7
+ import java.util.concurrent.atomic.AtomicBoolean
8
+
9
+ /**
10
+ * The update check JavaScript leaves in main's key-value store for the check
11
+ * after a crash: native knows no server of its own. A device whose JavaScript
12
+ * never wrote one, such as one whose user hasn't accepted the app's privacy
13
+ * terms yet, has none, so native sends no request.
14
+ */
15
+ internal class RecoveryCheckConfig(
16
+ val baseURL: String,
17
+ val requestHeaders: Map<String, String>,
18
+ val requestTimeoutMillis: Long,
19
+ val updateStrategy: HotUpdaterUpdateStrategy,
20
+ ) {
21
+ /** The check on the current channel, as JavaScript's own check makes it. */
22
+ fun toRequest(): NativeCheckRequest =
23
+ NativeCheckRequest(
24
+ baseURL = baseURL,
25
+ updateStrategy = updateStrategy,
26
+ requestHeaders = requestHeaders,
27
+ requestTimeoutMillis = requestTimeoutMillis,
28
+ )
29
+
30
+ /** This record as JavaScript writes it, which [parse] reads back. */
31
+ fun toJson(): String =
32
+ JSONObject()
33
+ .put("version", VERSION)
34
+ .put("baseURL", baseURL)
35
+ .put("requestHeaders", JSONObject(requestHeaders))
36
+ .put("requestTimeoutMs", requestTimeoutMillis)
37
+ .put("updateStrategy", updateStrategy.value)
38
+ .toString()
39
+
40
+ companion object {
41
+ const val KEY = "hot-updater/recovery-check-config"
42
+ private const val VERSION = 1
43
+ private const val DEFAULT_TIMEOUT_MS = 5_000L
44
+
45
+ /**
46
+ * The record of a native check of main the server answered: its
47
+ * headers as the request sent them ([effectiveHeaders]). Its channel
48
+ * is left out, as JavaScript leaves it out: the check after a crash
49
+ * uses the device's own.
50
+ */
51
+ fun of(request: NativeCheckRequest): RecoveryCheckConfig =
52
+ RecoveryCheckConfig(
53
+ request.baseURL,
54
+ effectiveHeaders(request.requestHeaders),
55
+ request.requestTimeoutMillis,
56
+ request.updateStrategy,
57
+ )
58
+
59
+ /**
60
+ * The record [value] holds, or null for a missing, unparsable,
61
+ * other-version, or invalid one: no absolute HTTP(S) baseURL, an
62
+ * unknown strategy, headers that aren't strings, or a timeout that
63
+ * isn't a positive number. A record JavaScript writes always has
64
+ * headers and a timeout; one without them takes the defaults.
65
+ */
66
+ fun parse(value: String?): RecoveryCheckConfig? {
67
+ val json = value?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
68
+ if (json.opt("version") != VERSION) return null
69
+ val baseURL = json.opt("baseURL") as? String ?: return null
70
+ if (runCatching { requireValidBaseURL(baseURL) }.isFailure) return null
71
+ val strategy = json.opt("updateStrategy") as? String
72
+ val updateStrategy = HotUpdaterUpdateStrategy.values().firstOrNull { it.value == strategy } ?: return null
73
+ val headers = LinkedHashMap<String, String>()
74
+ if (json.has("requestHeaders")) {
75
+ val values = json.opt("requestHeaders") as? JSONObject ?: return null
76
+ values.keys().forEach { name -> headers[name] = values.opt(name) as? String ?: return null }
77
+ }
78
+ val timeout =
79
+ if (json.has("requestTimeoutMs")) {
80
+ val number = json.opt("requestTimeoutMs") as? Number ?: return null
81
+ number.toDouble().takeIf { it.isFinite() && it >= 1 }?.toLong() ?: return null
82
+ } else {
83
+ DEFAULT_TIMEOUT_MS
84
+ }
85
+ return RecoveryCheckConfig(baseURL, headers, timeout, updateStrategy)
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Main's update check at the launch after a crash. Since first content
92
+ * verifies a launch, a bundle whose own update flow crashes later can never
93
+ * fetch its fix from JavaScript; native checks once, in the background, and
94
+ * stages what it finds for the next launch, with no reload.
95
+ *
96
+ * A crash leaves its marker, which the next launch's preparation consumes:
97
+ * [markPending] records that in the key-value store, so a launch that ends
98
+ * before the check finishes, or shows no UI, leaves it for the next launch
99
+ * that does. [startIfPending] then runs the check once per process, after the
100
+ * launch is prepared and outside its locks, once a started activity shows UI.
101
+ *
102
+ * @param whenUiShown runs its action once this process shows UI, at once
103
+ * when it already does; on the main thread
104
+ * @param check checks with main's native checker, which queues the plugin
105
+ * events JavaScript replays
106
+ * @param background runs work off the thread React Native starts on
107
+ */
108
+ internal class PostCrashUpdateCheck(
109
+ private val getItem: (String) -> String?,
110
+ private val setItem: (String, String?) -> Unit,
111
+ private val whenUiShown: ((() -> Unit) -> Unit),
112
+ private val check: suspend (NativeCheckRequest) -> HotUpdaterUpdate?,
113
+ private val isRestartProcess: () -> Boolean,
114
+ private val background: (Runnable) -> Unit = defaultBackground,
115
+ ) {
116
+ private val started = AtomicBoolean(false)
117
+
118
+ /** Main's launch preparation consumed a crash marker that doesn't ask to roll back. */
119
+ fun markPending() {
120
+ setItem(PENDING_KEY, PENDING_VALUE)
121
+ }
122
+
123
+ /**
124
+ * Main's launch preparation consumed a marker that asks to roll back: the
125
+ * rollback settles that crash locally, and a check an earlier crash left
126
+ * pending with it.
127
+ */
128
+ fun clearPending() {
129
+ setItem(PENDING_KEY, null)
130
+ }
131
+
132
+ /**
133
+ * Starts the check when a crash left it pending and this process hasn't
134
+ * run it yet. Returns at once: reading the store is left to [background].
135
+ */
136
+ fun startIfPending() {
137
+ if (started.get()) return
138
+ background(
139
+ Runnable {
140
+ // Hot Updater's restart process only relaunches the app.
141
+ if (isRestartProcess()) return@Runnable
142
+ if (getItem(PENDING_KEY) != PENDING_VALUE) return@Runnable
143
+ // Without a server there is nothing to ask, and the check
144
+ // waits for a launch whose JavaScript left one.
145
+ if (RecoveryCheckConfig.parse(getItem(RecoveryCheckConfig.KEY)) == null) {
146
+ log("skipped no config")
147
+ return@Runnable
148
+ }
149
+ // A launch without the flag must not use up the process's check.
150
+ if (!started.compareAndSet(false, true)) return@Runnable
151
+ whenUiShown { background(Runnable { run() }) }
152
+ },
153
+ )
154
+ }
155
+
156
+ private fun run() {
157
+ // Read again: JavaScript may have saved a newer one while the app came up.
158
+ val config = RecoveryCheckConfig.parse(getItem(RecoveryCheckConfig.KEY))
159
+ if (config == null) {
160
+ log("skipped no config")
161
+ return
162
+ }
163
+ log("started")
164
+ try {
165
+ val update = runBlocking { check(config.toRequest()) }
166
+ when {
167
+ update == null -> log("no update")
168
+ runBlocking { update.updateBundle() } -> log("staged ${update.id}")
169
+ else -> log("skipped superseded")
170
+ }
171
+ } catch (error: Exception) {
172
+ log("failed ${error.message ?: error.javaClass.simpleName}")
173
+ } finally {
174
+ // The attempt finished, whatever it found; a process that died
175
+ // during it leaves the flag for the next launch to try again.
176
+ setItem(PENDING_KEY, null)
177
+ }
178
+ }
179
+
180
+ private fun log(status: String) {
181
+ Log.i(TAG, "[HotUpdaterRecovery] Post-crash update check: $status")
182
+ }
183
+
184
+ companion object {
185
+ const val PENDING_KEY = "hot-updater/recovery-check-pending"
186
+ private const val PENDING_VALUE = "1"
187
+ private const val TAG = "HotUpdaterRecovery"
188
+
189
+ private val executor by lazy {
190
+ Executors.newSingleThreadExecutor { task -> Thread(task, "HotUpdaterPostCrashCheck").apply { isDaemon = true } }
191
+ }
192
+
193
+ private val defaultBackground: (Runnable) -> Unit = { task -> executor.execute(task) }
194
+ }
195
+ }
@@ -421,6 +421,7 @@ class BundleFileStorageService: BundleStorageService {
421
421
  private let builtInBundleIdProvider: () -> String
422
422
  private let builtInAssetResolver: BuiltInAssetResolver
423
423
  private let updateStrategyProvider: () -> UpdateStrategy
424
+ private let metadataWriter: (BundleMetadata, URL) -> Bool
424
425
  private let releaseStateLock = NSRecursiveLock()
425
426
 
426
427
  /// App-wide: every instance's storage reads the same install identity
@@ -787,7 +788,10 @@ class BundleFileStorageService: BundleStorageService {
787
788
  builtInBundleIdProvider: @escaping () -> String = { "" },
788
789
  builtInAssetResolver: BuiltInAssetResolver? = nil,
789
790
  updateStrategyProvider: (() -> UpdateStrategy)? = nil,
790
- downloadRetryDelay: TimeInterval = 1) {
791
+ downloadRetryDelay: TimeInterval = 1,
792
+ metadataWriter: @escaping (BundleMetadata, URL) -> Bool = { metadata, file in
793
+ metadata.save(to: file)
794
+ }) {
791
795
 
792
796
  self.fileSystem = fileSystem
793
797
  self.downloadRetryDelay = downloadRetryDelay
@@ -802,6 +806,7 @@ class BundleFileStorageService: BundleStorageService {
802
806
  defaultBundle: { instance.bundle }
803
807
  )
804
808
  self.updateStrategyProvider = updateStrategyProvider ?? { instance.updateStrategy() }
809
+ self.metadataWriter = metadataWriter
805
810
 
806
811
  // Create queue for file operations
807
812
  self.fileOperationQueue = DispatchQueue(label: "com.hotupdater.fileoperations",
@@ -859,7 +864,7 @@ class BundleFileStorageService: BundleStorageService {
859
864
  }
860
865
  var updatedMetadata = metadata
861
866
  updatedMetadata.isolationKey = isolationKey
862
- return updatedMetadata.save(to: file)
867
+ return metadataWriter(updatedMetadata, file)
863
868
  }
864
869
 
865
870
  private func loadLaunchReport() -> LaunchReport? {
@@ -1368,6 +1373,8 @@ class BundleFileStorageService: BundleStorageService {
1368
1373
  /// ended before first content without a crash marker.
1369
1374
  @discardableResult
1370
1375
  private func rollbackPendingBundle(_ stagingId: String, unfinishedLaunch: Bool = false) -> Bool {
1376
+ releaseStateLock.lock()
1377
+ defer { releaseStateLock.unlock() }
1371
1378
  guard var metadata = loadMetadataOrNull(), metadata.stagingBundleId == stagingId else {
1372
1379
  return false
1373
1380
  }
@@ -1443,6 +1450,8 @@ class BundleFileStorageService: BundleStorageService {
1443
1450
  }
1444
1451
 
1445
1452
  private func applyPendingRecoveryIfNeeded(_ pendingRecovery: PendingCrashRecovery?) {
1453
+ releaseStateLock.lock()
1454
+ defer { releaseStateLock.unlock() }
1446
1455
  guard let metadata = loadMetadataOrNull(),
1447
1456
  let stagingBundleId = metadata.stagingBundleId,
1448
1457
  pendingRecovery?.shouldRollback == true,
@@ -2001,6 +2010,8 @@ class BundleFileStorageService: BundleStorageService {
2001
2010
  }
2002
2011
 
2003
2012
  private func selectLaunch(bundle: Bundle) -> LaunchSelection {
2013
+ releaseStateLock.lock()
2014
+ defer { releaseStateLock.unlock() }
2004
2015
  guard let metadata = loadMetadataOrNull() else {
2005
2016
  return LaunchSelection(
2006
2017
  bundleURL: getFallbackBundleURL(bundle: bundle),
@@ -2081,6 +2092,8 @@ class BundleFileStorageService: BundleStorageService {
2081
2092
  // Cleared before the rollbacks below, so the launch reports their RECOVERED.
2082
2093
  saveLaunchReport(nil)
2083
2094
  applyPendingRecoveryIfNeeded(pendingRecovery())
2095
+ releaseStateLock.lock()
2096
+ defer { releaseStateLock.unlock() }
2084
2097
  // Only a new storage instance consumes an unfinished launch. Repeated bundle
2085
2098
  // lookups or bridge reloads in this process must not reject its own launch.
2086
2099
  if let metadata = loadMetadataOrNull(),
@@ -3019,6 +3032,10 @@ class BundleFileStorageService: BundleStorageService {
3019
3032
  func markLaunchCompleted(bundleId: String?) {
3020
3033
  // Before the guard: content from the built-in bundle (nil) also counts.
3021
3034
  readyInterruptedLaunchRetry(bundleId)
3035
+ // A Release selection can commit at the same time, from a native update
3036
+ // check or JavaScript; neither may save over the other's read.
3037
+ releaseStateLock.lock()
3038
+ defer { releaseStateLock.unlock() }
3022
3039
  guard let bundleId,
3023
3040
  var metadata = loadMetadataOrNull(),
3024
3041
  metadata.verificationPending,
@@ -3200,6 +3217,8 @@ class BundleFileStorageService: BundleStorageService {
3200
3217
  }
3201
3218
 
3202
3219
  func resetChannel() -> Result<Bool, Error> {
3220
+ releaseStateLock.lock()
3221
+ defer { releaseStateLock.unlock() }
3203
3222
  guard case .success = setBundleURL(localPath: nil) else {
3204
3223
  return .failure(BundleStorageError.unknown(nil))
3205
3224
  }
@@ -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() }