@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.
- package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +1 -1
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +91 -4
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +2 -1
- 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/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
|
@@ -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() }
|
|
@@ -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
|
-
|
|
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
|
-
)
|
|
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
|
-
|
|
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
|
+
}
|