@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.
- package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +1 -1
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +105 -83
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +91 -4
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +27 -5
- package/android/src/main/java/com/hotupdater/HotUpdaterRestartActivity.kt +39 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +2 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +9 -4
- package/android/src/main/java/com/hotupdater/PostCrashUpdateCheck.kt +195 -0
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +21 -2
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +125 -3
- package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +90 -10
- package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +2 -0
- package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +15 -4
- package/ios/HotUpdater/Internal/PostCrashUpdateCheck.swift +248 -0
- package/ios/HotUpdater/Package.swift +1 -0
- package/lib/commonjs/checkForUpdate.js +10 -1
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/httpClient.js +1 -0
- package/lib/commonjs/httpClient.js.map +1 -1
- package/lib/commonjs/recoveryCheckConfig.js +58 -0
- package/lib/commonjs/recoveryCheckConfig.js.map +1 -0
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/module/checkForUpdate.js +10 -1
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/httpClient.js +1 -0
- package/lib/module/httpClient.js.map +1 -1
- package/lib/module/recoveryCheckConfig.js +54 -0
- package/lib/module/recoveryCheckConfig.js.map +1 -0
- package/lib/module/sdkVersion.js +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/httpClient.d.ts +2 -0
- package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/recoveryCheckConfig.d.ts +28 -0
- package/lib/typescript/commonjs/recoveryCheckConfig.d.ts.map +1 -0
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/httpClient.d.ts +2 -0
- package/lib/typescript/module/httpClient.d.ts.map +1 -1
- package/lib/typescript/module/recoveryCheckConfig.d.ts +28 -0
- package/lib/typescript/module/recoveryCheckConfig.d.ts.map +1 -0
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/package.json +4 -4
- package/src/checkForUpdate.ts +10 -1
- package/src/httpClient.ts +3 -0
- package/src/recoveryCheckConfig.ts +68 -0
- 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
|
|
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.
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1195
|
-
//
|
|
1196
|
-
if launch.isMain ? launch.
|
|
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.
|
|
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
|
-
|
|
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() }
|