@hot-updater/react-native 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/HotUpdater.podspec +14 -2
  2. package/Package.swift +24 -3
  3. package/android/build.gradle +2 -6
  4. package/android/hot-updater-fingerprint.gradle +135 -0
  5. package/android/src/main/cpp/HotUpdaterRecovery.cpp +291 -21
  6. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +176 -15
  7. package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +20 -8
  8. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +145 -31
  9. package/android/src/main/java/com/hotupdater/CohortService.kt +10 -2
  10. package/android/src/main/java/com/hotupdater/FileManagerService.kt +3 -1
  11. package/android/src/main/java/com/hotupdater/HotUpdaterConfig.kt +249 -21
  12. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +8 -4
  13. package/android/src/main/java/com/hotupdater/HotUpdaterHeldContent.kt +92 -0
  14. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +319 -113
  15. package/android/src/main/java/com/hotupdater/HotUpdaterInstance.kt +87 -0
  16. package/android/src/main/java/com/hotupdater/HotUpdaterInstanceEnvironment.kt +323 -0
  17. package/android/src/main/java/com/hotupdater/HotUpdaterMainThread.kt +46 -0
  18. package/android/src/main/java/com/hotupdater/HotUpdaterProcess.kt +30 -0
  19. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +492 -0
  20. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
  21. package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
  22. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
  23. package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
  24. package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
  25. package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
  26. package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
  27. package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
  28. package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
  29. package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
  30. package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
  31. package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
  32. package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
  33. package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
  34. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
  35. package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
  36. package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
  37. package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
  38. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
  39. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
  40. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
  41. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
  42. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
  43. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
  44. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
  45. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
  46. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
  47. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
  48. package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
  49. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
  50. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
  51. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
  52. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
  53. package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
  54. package/android/src/newarch/HotUpdater.kt +53 -15
  55. package/android/src/newarch/HotUpdaterModule.kt +42 -17
  56. package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
  57. package/android/src/oldarch/HotUpdater.kt +51 -12
  58. package/android/src/oldarch/HotUpdaterModule.kt +43 -17
  59. package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
  60. package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
  61. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
  62. package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
  63. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
  64. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
  65. package/ios/HotUpdater/Internal/CohortService.swift +8 -0
  66. package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
  67. package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
  68. package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
  69. package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
  70. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
  71. package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
  72. package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
  73. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
  74. package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
  75. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
  76. package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
  77. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
  78. package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
  79. package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
  80. package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
  81. package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
  82. package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
  83. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
  84. package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
  85. package/ios/HotUpdater/Package.swift +11 -0
  86. package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
  87. package/ios/HotUpdater/Public/HotUpdater.h +21 -2
  88. package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
  89. package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
  90. package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
  91. package/lib/commonjs/checkForUpdate.js +3 -1
  92. package/lib/commonjs/checkForUpdate.js.map +1 -1
  93. package/lib/commonjs/index.js +9 -12
  94. package/lib/commonjs/index.js.map +1 -1
  95. package/lib/commonjs/native.js +24 -79
  96. package/lib/commonjs/native.js.map +1 -1
  97. package/lib/commonjs/nativeUpdateEvents.js +66 -0
  98. package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
  99. package/lib/commonjs/sdkVersion.js +1 -1
  100. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  101. package/lib/module/checkForUpdate.js +3 -1
  102. package/lib/module/checkForUpdate.js.map +1 -1
  103. package/lib/module/index.js +10 -13
  104. package/lib/module/index.js.map +1 -1
  105. package/lib/module/native.js +19 -73
  106. package/lib/module/native.js.map +1 -1
  107. package/lib/module/nativeUpdateEvents.js +61 -0
  108. package/lib/module/nativeUpdateEvents.js.map +1 -0
  109. package/lib/module/sdkVersion.js +1 -1
  110. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  111. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/index.d.ts +4 -14
  113. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/native.d.ts +9 -46
  115. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
  117. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  119. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
  120. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  121. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  122. package/lib/typescript/module/index.d.ts +4 -14
  123. package/lib/typescript/module/index.d.ts.map +1 -1
  124. package/lib/typescript/module/native.d.ts +9 -46
  125. package/lib/typescript/module/native.d.ts.map +1 -1
  126. package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
  127. package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
  128. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  129. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
  130. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  131. package/package.json +4 -4
  132. package/src/checkForUpdate.ts +7 -1
  133. package/src/index.ts +9 -17
  134. package/src/native.ts +19 -91
  135. package/src/nativeUpdateEvents.ts +77 -0
  136. package/src/sdkVersion.ts +1 -1
  137. package/src/specs/NativeHotUpdater.ts +20 -3
  138. package/android/libs/README.md +0 -50
  139. package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
  140. package/android/src/bridge/BridgeReactInstance.kt +0 -38
  141. package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
  142. package/android/src/main/java/com/hotupdater/ContentAppearedLog.kt +0 -31
  143. package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
  144. package/android/src/newarch/ReactIntegrationManager.kt +0 -212
  145. package/android/src/oldarch/ReactIntegrationManager.kt +0 -120
@@ -0,0 +1,2051 @@
1
+ import Foundation
2
+
3
+ // A port of the JavaScript update check (`checkForUpdate.ts`, `httpClient.ts`,
4
+ // and `releaseCatalogCache.ts`) for apps that check and stage updates before
5
+ // React Native starts. Plugin events it would emit are queued in the native
6
+ // key-value store, and the next `HotUpdater.init` replays them.
7
+
8
+ // MARK: - Public API
9
+
10
+ /// How the server matches the app to a Release catalog, as the
11
+ /// `updateStrategy` of `HotUpdater.init`.
12
+ @objc public enum HotUpdaterUpdateStrategy: Int {
13
+ case appVersion
14
+ case fingerprint
15
+
16
+ /// The name JavaScript uses in plugin events.
17
+ var name: String {
18
+ switch self {
19
+ case .appVersion: return "appVersion"
20
+ case .fingerprint: return "fingerprint"
21
+ }
22
+ }
23
+ }
24
+
25
+ /// Whether an update moves the app forward or back.
26
+ @objc public enum HotUpdaterUpdateStatus: Int {
27
+ case update
28
+ case rollback
29
+ }
30
+
31
+ /// The values JavaScript passes to `HotUpdater.init`: the server and the
32
+ /// request defaults of a `HotUpdaterInstance`.
33
+ @objc public final class HotUpdaterOptions: NSObject, @unchecked Sendable {
34
+ /// The server exposing the Hot Updater v1 client HTTP protocol.
35
+ @objc public let baseURL: String
36
+ /// Sent with every request; a check's own headers override them.
37
+ @objc public let requestHeaders: [String: String]
38
+ /// Seconds each request may take, unless a check sets its own.
39
+ @objc public let requestTimeout: TimeInterval
40
+ /// The bundle that holds the embedded `main.jsbundle` and the
41
+ /// `HOT_UPDATER_*` Info.plist keys: a brownfield XCFramework passes its
42
+ /// own bundle.
43
+ @objc public let bundle: Bundle
44
+ /// Keeps this instance's staged bundles, channel, and queued events apart
45
+ /// from every other name's, like a Firebase app name: 1 to 32 of `a-z`,
46
+ /// `0-9`, and `-`. Instances with the same name share them. `"main"`, the
47
+ /// default, is the state the TurboModule and `HotUpdater.bundleURL()` use.
48
+ @objc public let name: String
49
+ /// The channel this build ships with, before any runtime switch, over
50
+ /// the `HOT_UPDATER_CHANNEL` Info.plist key. Keep it the same from the
51
+ /// first launch on: it is part of the storage isolation key, so a change
52
+ /// deletes the downloaded bundles. To check another channel at runtime,
53
+ /// pass `channel` to `checkForUpdate`.
54
+ @objc public let channel: String?
55
+ /// Selects the fingerprint update strategy, over the fingerprint the
56
+ /// build embedded.
57
+ @objc public let fingerprintHash: String?
58
+ /// The PEM key bundle signatures are verified with, over the
59
+ /// `HOT_UPDATER_PUBLIC_KEY` Info.plist key.
60
+ @objc public let publicKey: String?
61
+
62
+ /// `channel`, `fingerprintHash`, and `publicKey` left nil (or empty) read
63
+ /// the instance's Info.plist: `bundle`'s, then, for `main`, the app's.
64
+ /// Instances of one name share their state, so a second instance of a
65
+ /// name with another bundle or another of these values throws.
66
+ @objc public init(
67
+ baseURL: String,
68
+ requestHeaders: [String: String] = [:],
69
+ requestTimeout: TimeInterval = 5,
70
+ bundle: Bundle = .main,
71
+ name: String = "main",
72
+ channel: String? = nil,
73
+ fingerprintHash: String? = nil,
74
+ publicKey: String? = nil
75
+ ) {
76
+ self.baseURL = baseURL
77
+ self.requestHeaders = requestHeaders
78
+ self.requestTimeout = requestTimeout
79
+ self.bundle = bundle
80
+ self.name = name
81
+ self.channel = channel
82
+ self.fingerprintHash = fingerprintHash
83
+ self.publicKey = publicKey
84
+ }
85
+
86
+ /// The initializer without the embedded values, for Objective-C, where
87
+ /// the defaults above don't apply.
88
+ @available(swift, obsoleted: 1.0, message: "Use init(baseURL:requestHeaders:requestTimeout:bundle:name:channel:fingerprintHash:publicKey:)")
89
+ @objc(initWithBaseURL:requestHeaders:requestTimeout:bundle:name:)
90
+ public convenience init(
91
+ objcBaseURL baseURL: String,
92
+ requestHeaders: [String: String],
93
+ requestTimeout: TimeInterval,
94
+ bundle: Bundle,
95
+ name: String
96
+ ) {
97
+ self.init(
98
+ baseURL: baseURL,
99
+ requestHeaders: requestHeaders,
100
+ requestTimeout: requestTimeout,
101
+ bundle: bundle,
102
+ name: name,
103
+ channel: nil,
104
+ fingerprintHash: nil,
105
+ publicKey: nil
106
+ )
107
+ }
108
+
109
+ /// The initializer without `name`, for Objective-C.
110
+ @available(swift, obsoleted: 1.0, message: "Use init(baseURL:requestHeaders:requestTimeout:bundle:name:channel:fingerprintHash:publicKey:)")
111
+ @objc(initWithBaseURL:requestHeaders:requestTimeout:bundle:)
112
+ public convenience init(
113
+ objcBaseURL baseURL: String,
114
+ requestHeaders: [String: String],
115
+ requestTimeout: TimeInterval,
116
+ bundle: Bundle
117
+ ) {
118
+ self.init(
119
+ baseURL: baseURL,
120
+ requestHeaders: requestHeaders,
121
+ requestTimeout: requestTimeout,
122
+ bundle: bundle,
123
+ name: "main",
124
+ channel: nil,
125
+ fingerprintHash: nil,
126
+ publicKey: nil
127
+ )
128
+ }
129
+ }
130
+
131
+ /// The values JavaScript passes to `checkForUpdate`.
132
+ @objc public final class HotUpdaterCheckOptions: NSObject, @unchecked Sendable {
133
+ @objc public let updateStrategy: HotUpdaterUpdateStrategy
134
+ /// Checks this channel instead of the current one. The switch is only
135
+ /// persisted once the returned update is staged.
136
+ @objc public let channel: String?
137
+ /// Merged over the instance's headers; these win.
138
+ @objc public let requestHeaders: [String: String]
139
+ /// Seconds each request may take; nil uses the instance's.
140
+ public let requestTimeout: TimeInterval?
141
+
142
+ public init(
143
+ updateStrategy: HotUpdaterUpdateStrategy,
144
+ channel: String? = nil,
145
+ requestHeaders: [String: String] = [:],
146
+ requestTimeout: TimeInterval? = nil
147
+ ) {
148
+ self.updateStrategy = updateStrategy
149
+ self.channel = channel
150
+ self.requestHeaders = requestHeaders
151
+ self.requestTimeout = requestTimeout
152
+ }
153
+
154
+ @objc public convenience init(updateStrategy: HotUpdaterUpdateStrategy, channel: String?) {
155
+ self.init(updateStrategy: updateStrategy, channel: channel, requestHeaders: [:], requestTimeout: nil)
156
+ }
157
+
158
+ /// The full initializer for Objective-C, where `requestTimeout` is seconds
159
+ /// as an `NSNumber`, or nil for the instance's.
160
+ @available(swift, obsoleted: 1.0, message: "Use init(updateStrategy:channel:requestHeaders:requestTimeout:)")
161
+ @objc(initWithUpdateStrategy:channel:requestHeaders:requestTimeout:)
162
+ public convenience init(
163
+ updateStrategy: HotUpdaterUpdateStrategy,
164
+ channel: String?,
165
+ requestHeaders: [String: String],
166
+ objcRequestTimeout: NSNumber?
167
+ ) {
168
+ self.init(
169
+ updateStrategy: updateStrategy,
170
+ channel: channel,
171
+ requestHeaders: requestHeaders,
172
+ requestTimeout: objcRequestTimeout?.doubleValue
173
+ )
174
+ }
175
+
176
+ /// `requestTimeout` for Objective-C: seconds, or nil for the instance's.
177
+ @available(swift, obsoleted: 1.0, message: "Use requestTimeout")
178
+ @objc(requestTimeout) public var objcRequestTimeout: NSNumber? {
179
+ requestTimeout.map { NSNumber(value: $0) }
180
+ }
181
+ }
182
+
183
+ /// One check's settings: the instance's, with the check's own over them, as
184
+ /// JavaScript's `checkForUpdate` merges them.
185
+ struct NativeCheckRequest {
186
+ let baseURL: String
187
+ let updateStrategy: HotUpdaterUpdateStrategy
188
+ let requestHeaders: [String: String]
189
+ let channel: String?
190
+ let requestTimeout: TimeInterval
191
+
192
+ init(
193
+ baseURL: String,
194
+ updateStrategy: HotUpdaterUpdateStrategy,
195
+ requestHeaders: [String: String] = [:],
196
+ channel: String? = nil,
197
+ requestTimeout: TimeInterval = 5
198
+ ) {
199
+ self.baseURL = baseURL
200
+ self.updateStrategy = updateStrategy
201
+ self.requestHeaders = requestHeaders
202
+ self.channel = channel
203
+ self.requestTimeout = requestTimeout
204
+ }
205
+
206
+ init(_ instance: HotUpdaterOptions, _ check: HotUpdaterCheckOptions) {
207
+ var headers = instance.requestHeaders
208
+ for (name, value) in check.requestHeaders {
209
+ // Header names are case-insensitive: the check's spelling replaces the instance's.
210
+ headers = headers.filter { $0.key.lowercased() != name.lowercased() }
211
+ headers[name] = value
212
+ }
213
+ self.init(
214
+ baseURL: instance.baseURL,
215
+ updateStrategy: check.updateStrategy,
216
+ requestHeaders: headers,
217
+ channel: check.channel,
218
+ requestTimeout: check.requestTimeout ?? instance.requestTimeout
219
+ )
220
+ }
221
+ }
222
+
223
+ /// An update the check found, with the fields of JavaScript's
224
+ /// `CheckForUpdateResult`.
225
+ @objc public final class HotUpdaterUpdate: NSObject, @unchecked Sendable {
226
+ /// The Release ID, or the bundle ID when the update names no Release.
227
+ @objc(identifier) public let id: String
228
+ @objc public let releaseId: String?
229
+ @objc public let status: HotUpdaterUpdateStatus
230
+ @objc public let shouldForceUpdate: Bool
231
+ @objc public let message: String?
232
+ /// How many of the 1000 rollout cohorts receive the Release.
233
+ @objc public let rolloutCohortCount: Int
234
+ /// Cohorts that receive the Release whatever `rolloutCohortCount` says.
235
+ @objc public let targetCohorts: [String]
236
+ /// `INSTALL`, `ADOPT_RELEASE`, or `USE_BUILTIN`.
237
+ @objc public let transitionKind: String
238
+
239
+ /// The bundle the update stages.
240
+ let bundleId: String
241
+ /// The update selects the bundle the app already launches, so staging it
242
+ /// only records the selection.
243
+ let runsCurrentBundle: Bool
244
+ /// The selection the update stages: kind, bundle, Release, and channel,
245
+ /// from one catalog and selection context. A staging already running
246
+ /// joins only the same target.
247
+ let targetKey: String
248
+ private let checker: NativeUpdateChecker
249
+ private let stagedLock = NSLock()
250
+ private var staged = false
251
+ /// Stages the update.
252
+ let install: (@escaping (Double) -> Void) async throws -> Bool
253
+ /// Whether the selection this update stages is still the current one.
254
+ let isSelectionCurrent: () -> Bool
255
+
256
+ init(
257
+ id: String,
258
+ releaseId: String?,
259
+ status: HotUpdaterUpdateStatus,
260
+ shouldForceUpdate: Bool,
261
+ message: String?,
262
+ rolloutCohortCount: Int = 1000,
263
+ targetCohorts: [String] = [],
264
+ bundleId: String,
265
+ transitionKind: String,
266
+ runsCurrentBundle: Bool,
267
+ targetKey: String,
268
+ checker: NativeUpdateChecker,
269
+ install: @escaping (@escaping (Double) -> Void) async throws -> Bool,
270
+ isSelectionCurrent: @escaping () -> Bool = { true }
271
+ ) {
272
+ self.id = id
273
+ self.releaseId = releaseId
274
+ self.status = status
275
+ self.shouldForceUpdate = shouldForceUpdate
276
+ self.message = message
277
+ self.rolloutCohortCount = rolloutCohortCount
278
+ self.targetCohorts = targetCohorts
279
+ self.bundleId = bundleId
280
+ self.transitionKind = transitionKind
281
+ self.runsCurrentBundle = runsCurrentBundle
282
+ self.targetKey = targetKey
283
+ self.checker = checker
284
+ self.install = install
285
+ self.isSelectionCurrent = isSelectionCurrent
286
+ }
287
+
288
+ /// Downloads, verifies, and stages the update for the next launch, or
289
+ /// commits a selection that needs no download. Joins a download of the
290
+ /// same bundle already in progress, returns true at once once this update
291
+ /// is staged, and waits for other native update work without a deadline.
292
+ /// `onProgress` receives 0...1 on the main queue.
293
+ public func updateBundle(onProgress: ((Double) -> Void)? = nil) async throws -> Bool {
294
+ try await checker.updateBundle(self, onProgress: onProgress)
295
+ }
296
+
297
+ /// Whether this update was staged in this process.
298
+ var isStaged: Bool {
299
+ stagedLock.lock()
300
+ defer { stagedLock.unlock() }
301
+ return staged
302
+ }
303
+
304
+ func markStaged() {
305
+ stagedLock.lock()
306
+ staged = true
307
+ stagedLock.unlock()
308
+ }
309
+
310
+ /// `updateBundle(onProgress:)` for Objective-C; completes on the main queue.
311
+ @objc(updateBundleWithProgress:completion:)
312
+ public func updateBundle(
313
+ onProgress: ((Double) -> Void)?,
314
+ completion: @escaping (Bool, Error?) -> Void
315
+ ) {
316
+ Task {
317
+ do {
318
+ let staged = try await checker.updateBundle(self, onProgress: onProgress)
319
+ DispatchQueue.main.async { completion(staged, nil) }
320
+ } catch {
321
+ DispatchQueue.main.async { completion(false, error) }
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ // MARK: - Host
328
+
329
+ /// A response from the update server. Header names are lowercase.
330
+ struct NativeUpdateResponse {
331
+ let status: Int
332
+ let headers: [String: String]
333
+ let body: String
334
+ /// The status and headers arrived but the body did not.
335
+ var bodyReadFailed = false
336
+ }
337
+
338
+ /// A 200 whose body could not be read: the request fails, but `response`
339
+ /// arrived and is still reported to `onHttpResponse`.
340
+ struct NativeUpdateResponseBodyError: Error {
341
+ let response: NativeUpdateResponse
342
+ let underlying: Error
343
+ }
344
+
345
+ protocol NativeUpdateTransport {
346
+ func get(_ url: URL, headers: [String: String], timeout: TimeInterval) async throws -> NativeUpdateResponse
347
+ }
348
+
349
+ /// The native key-value store behind the TurboModule's `getStorageItem`.
350
+ protocol NativeUpdateEventStorage: AnyObject {
351
+ func storageItem(_ key: String) -> String?
352
+ func setStorageItem(_ key: String, value: String?)
353
+ }
354
+
355
+ /// The native state and methods the TurboModule exposes to JavaScript, with
356
+ /// the same dictionaries.
357
+ protocol NativeUpdateHost: NativeUpdateEventStorage {
358
+ var appVersion: String? { get }
359
+ var minBundleId: String { get }
360
+ /// The running bundle, or the one the next launch runs before React Native
361
+ /// has started; nil or the nil UUID for the built-in bundle.
362
+ func bundleId() -> String?
363
+ func channel() -> String
364
+ func defaultChannel() -> String
365
+ func cohort() -> String
366
+ func fingerprintHash() -> String?
367
+ func crashHistory() -> [String]
368
+ func activeUpdateState() -> [String: Any]
369
+ func acceptReleaseCatalog(_ params: [String: Any]) -> Bool
370
+ func isReleaseSelectionCurrent(_ params: [String: Any]) -> Bool
371
+ func commitReleaseSelection(_ params: [String: Any]) async throws -> Bool
372
+ /// Native `updateBundle`; resolves with `{ delivery, patchFallback }`.
373
+ /// Rejects with `NativeUpdateRejection`.
374
+ /// `onFilesResponse` receives each artifact files response's status and
375
+ /// body (nil when it could not be read), for `onHttpResponse`.
376
+ func updateBundle(
377
+ _ params: [String: Any],
378
+ onFilesResponse: ((Int, String?) -> Void)?,
379
+ progress: @escaping (Double) -> Void
380
+ ) async throws -> Any?
381
+ func resetChannel() async -> Bool
382
+ func releaseCatalogCache(_ partition: String) -> String?
383
+ func setReleaseCatalogCache(_ partition: String, value: String) -> Bool
384
+ func removeReleaseCatalogCache(_ partition: String)
385
+ }
386
+
387
+ /// A promise rejection from a native method, as JavaScript receives it.
388
+ struct NativeUpdateRejection: LocalizedError {
389
+ let code: String?
390
+ let message: String?
391
+ let userInfo: [String: Any]
392
+
393
+ var errorDescription: String? { message ?? code }
394
+ }
395
+
396
+ enum NativeUpdateCheckError: LocalizedError {
397
+ case hotUpdater(String)
398
+ case invalidResponse(String)
399
+ case http(status: Int)
400
+ case staleReleaseCatalog
401
+
402
+ var errorDescription: String? {
403
+ switch self {
404
+ case .hotUpdater(let message), .invalidResponse(let message):
405
+ return message
406
+ case .http(let status):
407
+ return "Request failed with HTTP \(status)"
408
+ case .staleReleaseCatalog:
409
+ return "Release catalog selection became stale before it was committed"
410
+ }
411
+ }
412
+ }
413
+
414
+ // MARK: - Events
415
+
416
+ /// Plugin hook calls for the next `HotUpdater.init` to replay, oldest first,
417
+ /// as a JSON array of `{ name, payload, occurredAt }`. One lock guards every
418
+ /// append and take in the process, so a take never loses an append.
419
+ enum NativeUpdateEvents {
420
+ static let storageKey = "hot-updater/native-update-events"
421
+ static let maxEventsPerName = 20
422
+
423
+ private static let lock = NSLock()
424
+
425
+ /// What an event is reported under: the native build (its app version and
426
+ /// the minimum bundle ID its build generated) and the cohort.
427
+ static func build(appVersion: String?, minBundleId: String, cohort: String) -> String {
428
+ "\(appVersion ?? "")|\(minBundleId)|\(normalizeCohortValue(cohort))"
429
+ }
430
+
431
+ /// Appends an event of the native `build` it happened in, dropping the
432
+ /// oldest of the same name beyond `maxEventsPerName`.
433
+ static func append(
434
+ name: String,
435
+ payload: [String: Any],
436
+ occurredAt: Date,
437
+ build: String,
438
+ to storage: NativeUpdateEventStorage
439
+ ) {
440
+ lock.lock()
441
+ defer { lock.unlock() }
442
+
443
+ var events = read(storage)
444
+ events.append([
445
+ "name": name,
446
+ "payload": payload,
447
+ "occurredAt": NSNumber(value: Int64((occurredAt.timeIntervalSince1970 * 1000).rounded())),
448
+ "build": build,
449
+ ] as [String: Any])
450
+ let sameName = events.indices.filter { (events[$0] as? [String: Any])?["name"] as? String == name }
451
+ if sameName.count > maxEventsPerName {
452
+ for index in sameName.prefix(sameName.count - maxEventsPerName).reversed() {
453
+ events.remove(at: index)
454
+ }
455
+ }
456
+ guard let data = try? JSONSerialization.data(withJSONObject: events),
457
+ let value = String(data: data, encoding: .utf8) else {
458
+ NSLog("[HotUpdater] Failed to queue \(name)")
459
+ return
460
+ }
461
+ storage.setStorageItem(storageKey, value: value)
462
+ }
463
+
464
+ /// The queued events as a JSON array, or nil when there are none; the
465
+ /// queue is cleared either way.
466
+ /// Drops every queued event named `name`.
467
+ static func remove(name: String, from storage: NativeUpdateEventStorage) {
468
+ lock.lock()
469
+ defer { lock.unlock() }
470
+ let kept = read(storage).filter { ($0 as? [String: Any])?["name"] as? String != name }
471
+ guard !kept.isEmpty,
472
+ let data = try? JSONSerialization.data(withJSONObject: kept) else {
473
+ storage.setStorageItem(storageKey, value: nil)
474
+ return
475
+ }
476
+ storage.setStorageItem(storageKey, value: String(data: data, encoding: .utf8))
477
+ }
478
+
479
+ /// Takes the events of the current native `build` and cohort, clearing the
480
+ /// queue. An event of another, such as the app version before an upgrade
481
+ /// or a cohort set since, is dropped so none is reported against a release
482
+ /// or cohort it did not happen in.
483
+ static func take(from storage: NativeUpdateEventStorage, build: String) -> String? {
484
+ lock.lock()
485
+ defer { lock.unlock() }
486
+
487
+ guard let stored = storage.storageItem(storageKey) else {
488
+ return nil
489
+ }
490
+ storage.setStorageItem(storageKey, value: nil)
491
+ let events = (parse(stored) ?? []).filter { ($0 as? [String: Any])?["build"] as? String == build }
492
+ guard !events.isEmpty,
493
+ let data = try? JSONSerialization.data(withJSONObject: events) else {
494
+ return nil
495
+ }
496
+ return String(data: data, encoding: .utf8)
497
+ }
498
+
499
+ private static func read(_ storage: NativeUpdateEventStorage) -> [Any] {
500
+ storage.storageItem(storageKey).flatMap(parse) ?? []
501
+ }
502
+
503
+ private static func parse(_ value: String) -> [Any]? {
504
+ try? JSONSerialization.jsonObject(with: Data(value.utf8)) as? [Any]
505
+ }
506
+ }
507
+
508
+ // MARK: - Concurrency
509
+
510
+ /// A staging in progress, from the moment it is published on the gate until
511
+ /// it finishes; callers that join it wait for its result.
512
+ final class NativeUpdateStaging: @unchecked Sendable {
513
+ let update: HotUpdaterUpdate
514
+ let progress = NativeUpdateProgress()
515
+
516
+ private let lock = NSLock()
517
+ private var result: Result<Bool, Error>?
518
+ private var observers: [(Result<Bool, Error>) -> Void] = []
519
+
520
+ init(update: HotUpdaterUpdate) {
521
+ self.update = update
522
+ }
523
+
524
+ /// Calls `observer` with the result, at once when it is already known.
525
+ func onFinish(_ observer: @escaping (Result<Bool, Error>) -> Void) {
526
+ lock.lock()
527
+ if let result {
528
+ lock.unlock()
529
+ observer(result)
530
+ return
531
+ }
532
+ observers.append(observer)
533
+ lock.unlock()
534
+ }
535
+
536
+ func finish(_ result: Result<Bool, Error>) {
537
+ lock.lock()
538
+ self.result = result
539
+ let observers = self.observers
540
+ self.observers = []
541
+ lock.unlock()
542
+ observers.forEach { $0(result) }
543
+ }
544
+ }
545
+
546
+ /// A FIFO async mutex over native update work. Unlike an actor, it stays held
547
+ /// across suspension points, so a check and its download run one at a time.
548
+ /// The holder can hand the gate to a staging it publishes; while that staging
549
+ /// runs, callers that accept it join it instead of queueing, and callers
550
+ /// already queued that accept it are woken to join it. The staging and the
551
+ /// gate change under one lock, so no caller can see the gate free while a
552
+ /// staging is still being handed off, or miss a staging that is running.
553
+ final class NativeUpdateGate: @unchecked Sendable {
554
+ enum Entry {
555
+ /// The caller holds the gate and must release it.
556
+ case acquired
557
+ /// The caller joins this staging; it does not hold the gate.
558
+ case joined(NativeUpdateStaging)
559
+ /// The calling task was cancelled first.
560
+ case stopped
561
+ }
562
+
563
+ private final class Waiter {
564
+ let joins: (NativeUpdateStaging) -> Bool
565
+ var continuation: CheckedContinuation<Entry, Never>?
566
+ var isAbandoned = false
567
+
568
+ init(joins: @escaping (NativeUpdateStaging) -> Bool) {
569
+ self.joins = joins
570
+ }
571
+ }
572
+
573
+ private let lock = NSLock()
574
+ private var isHeld = false
575
+ private var staging: NativeUpdateStaging?
576
+ private var waiters: [Waiter] = []
577
+
578
+ /// Joins the running staging when `joins` accepts it, or else waits for
579
+ /// the gate until the calling task is cancelled.
580
+ func enter(
581
+ joins: @escaping (NativeUpdateStaging) -> Bool = { _ in false }
582
+ ) async -> Entry {
583
+ let waiter = Waiter(joins: joins)
584
+ return await withTaskCancellationHandler {
585
+ await withCheckedContinuation { (continuation: CheckedContinuation<Entry, Never>) in
586
+ lock.lock()
587
+ if let staging, joins(staging) {
588
+ lock.unlock()
589
+ continuation.resume(returning: .joined(staging))
590
+ return
591
+ }
592
+ if waiter.isAbandoned {
593
+ lock.unlock()
594
+ continuation.resume(returning: .stopped)
595
+ return
596
+ }
597
+ if !isHeld {
598
+ isHeld = true
599
+ lock.unlock()
600
+ continuation.resume(returning: .acquired)
601
+ return
602
+ }
603
+ waiter.continuation = continuation
604
+ waiters.append(waiter)
605
+ lock.unlock()
606
+ }
607
+ } onCancel: {
608
+ abandon(waiter)
609
+ }
610
+ }
611
+
612
+ /// Hands the gate the caller holds to `staging`, which releases it with
613
+ /// `release(finishing:)`, and wakes the waiters that join it.
614
+ func publish(_ staging: NativeUpdateStaging) {
615
+ lock.lock()
616
+ self.staging = staging
617
+ let joining = waiters.filter { $0.joins(staging) }
618
+ waiters.removeAll { $0.joins(staging) }
619
+ lock.unlock()
620
+ for waiter in joining {
621
+ let continuation = waiter.continuation
622
+ waiter.continuation = nil
623
+ continuation?.resume(returning: .joined(staging))
624
+ }
625
+ }
626
+
627
+ func release(finishing staging: NativeUpdateStaging? = nil) {
628
+ lock.lock()
629
+ if let staging, self.staging === staging {
630
+ self.staging = nil
631
+ }
632
+ if waiters.isEmpty {
633
+ isHeld = false
634
+ lock.unlock()
635
+ } else {
636
+ let next = waiters.removeFirst()
637
+ let continuation = next.continuation
638
+ next.continuation = nil
639
+ lock.unlock()
640
+ continuation?.resume(returning: .acquired)
641
+ }
642
+ }
643
+
644
+ private func abandon(_ waiter: Waiter) {
645
+ lock.lock()
646
+ waiter.isAbandoned = true
647
+ guard let index = waiters.firstIndex(where: { $0 === waiter }) else {
648
+ lock.unlock()
649
+ return
650
+ }
651
+ waiters.remove(at: index)
652
+ let continuation = waiter.continuation
653
+ waiter.continuation = nil
654
+ lock.unlock()
655
+ continuation?.resume(returning: .stopped)
656
+ }
657
+ }
658
+
659
+ /// How a wait for a staging ended.
660
+ private enum StagingWait {
661
+ case finished(Result<Bool, Error>)
662
+ /// The waiting task was cancelled.
663
+ case stopped
664
+ }
665
+
666
+ /// Resumes its waiter with the first outcome it is given.
667
+ private final class StagingWaitRace: @unchecked Sendable {
668
+ private let lock = NSLock()
669
+ private var outcome: StagingWait?
670
+ private var waiter: CheckedContinuation<StagingWait, Never>?
671
+
672
+ func wait() async -> StagingWait {
673
+ await withCheckedContinuation { (continuation: CheckedContinuation<StagingWait, Never>) in
674
+ lock.lock()
675
+ if let outcome {
676
+ lock.unlock()
677
+ continuation.resume(returning: outcome)
678
+ } else {
679
+ waiter = continuation
680
+ lock.unlock()
681
+ }
682
+ }
683
+ }
684
+
685
+ func finish(_ outcome: StagingWait) {
686
+ lock.lock()
687
+ guard self.outcome == nil else {
688
+ lock.unlock()
689
+ return
690
+ }
691
+ self.outcome = outcome
692
+ let waiter = self.waiter
693
+ self.waiter = nil
694
+ lock.unlock()
695
+ waiter?.resume(returning: outcome)
696
+ }
697
+ }
698
+
699
+ /// Relays a staging's progress to its listeners on the main queue, at most
700
+ /// every 1% or 100 ms. A listener gets no value once removed; a caller that
701
+ /// waited for a finished staging still gets the values reported before, such
702
+ /// as the 1 of its download.
703
+ final class NativeUpdateProgress: @unchecked Sendable {
704
+ private let lock = NSLock()
705
+ private var listeners: [Int: (Double) -> Void] = [:]
706
+ private var nextListener = 0
707
+ private var lastValue = -1.0
708
+ private var lastTime: TimeInterval = 0
709
+
710
+ /// Returns the token that `remove` takes.
711
+ @discardableResult
712
+ func add(_ listener: ((Double) -> Void)?) -> Int? {
713
+ guard let listener else { return nil }
714
+ lock.lock()
715
+ defer { lock.unlock() }
716
+ nextListener += 1
717
+ listeners[nextListener] = listener
718
+ return nextListener
719
+ }
720
+
721
+ /// `afterPending` keeps the values already reported for the listener,
722
+ /// such as the 1 of a finished download; otherwise none arrives after this.
723
+ func remove(_ token: Int?, afterPending: Bool = false) {
724
+ guard let token else { return }
725
+ if afterPending {
726
+ DispatchQueue.main.async { [self] in remove(token) }
727
+ return
728
+ }
729
+ lock.lock()
730
+ listeners[token] = nil
731
+ lock.unlock()
732
+ }
733
+
734
+ func report(_ progress: Double) {
735
+ let value = progress.isFinite ? min(max(progress, 0), 1) : 0
736
+ let now = ProcessInfo.processInfo.systemUptime
737
+ lock.lock()
738
+ let isComplete = value >= 1 && lastValue < 1
739
+ let isStep = value > lastValue && (value - lastValue >= 0.01 || now - lastTime >= 0.1)
740
+ guard isComplete || isStep else {
741
+ lock.unlock()
742
+ return
743
+ }
744
+ lastValue = value
745
+ lastTime = now
746
+ let tokens = listeners.keys.sorted()
747
+ lock.unlock()
748
+ guard !tokens.isEmpty else { return }
749
+ DispatchQueue.main.async { [self] in
750
+ // A listener removed since, as a cancelled caller returns, gets no value.
751
+ for token in tokens {
752
+ lock.lock()
753
+ let listener = listeners[token]
754
+ lock.unlock()
755
+ listener?(value)
756
+ }
757
+ }
758
+ }
759
+ }
760
+
761
+ // MARK: - Transport
762
+
763
+ final class URLSessionUpdateTransport: NativeUpdateTransport {
764
+ private let protocolClasses: [AnyClass]?
765
+
766
+ /// `protocolClasses` replaces the session's URL protocols; tests stub
767
+ /// the network with it.
768
+ init(protocolClasses: [AnyClass]? = nil) {
769
+ self.protocolClasses = protocolClasses
770
+ }
771
+
772
+ func get(_ url: URL, headers: [String: String], timeout: TimeInterval) async throws -> NativeUpdateResponse {
773
+ var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
774
+ request.httpMethod = "GET"
775
+ for (name, value) in headers {
776
+ request.setValue(value, forHTTPHeaderField: name)
777
+ }
778
+ // The catalog cache sends If-None-Match itself; URLCache must not
779
+ // answer a 304 with a stored 200. The resource timeout bounds the
780
+ // whole request, as the JavaScript abort does.
781
+ let configuration = URLSessionConfiguration.ephemeral
782
+ configuration.urlCache = nil
783
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
784
+ configuration.timeoutIntervalForRequest = timeout
785
+ configuration.timeoutIntervalForResource = timeout
786
+ if let protocolClasses {
787
+ configuration.protocolClasses = protocolClasses
788
+ }
789
+ // A delegate sees the status and headers before the body, so a body
790
+ // that breaks still reports the response that arrived.
791
+ let collector = DataTaskCollector()
792
+ let session = URLSession(configuration: configuration, delegate: collector, delegateQueue: nil)
793
+ defer { session.finishTasksAndInvalidate() }
794
+
795
+ // Cancelling the caller cancels the request, so a cancelled check
796
+ // stops waiting at once instead of at the timeout.
797
+ let running = RunningDataTask()
798
+ return try await withTaskCancellationHandler {
799
+ try await withCheckedThrowingContinuation { continuation in
800
+ collector.onComplete = { data, response, error in
801
+ if let error {
802
+ if (error as? URLError)?.code == .cancelled {
803
+ continuation.resume(throwing: CancellationError())
804
+ } else if let response {
805
+ // The status arrived but the body broke: a broken error body
806
+ // must not hide the HTTP error, and a 200 fails with its
807
+ // response, as on Android.
808
+ let partial = NativeUpdateResponse(
809
+ status: response.statusCode,
810
+ headers: Self.headers(of: response),
811
+ body: "",
812
+ bodyReadFailed: true
813
+ )
814
+ if response.statusCode == 200 {
815
+ continuation.resume(throwing: NativeUpdateResponseBodyError(response: partial, underlying: error))
816
+ } else {
817
+ continuation.resume(returning: partial)
818
+ }
819
+ } else {
820
+ continuation.resume(throwing: error)
821
+ }
822
+ return
823
+ }
824
+ guard let response else {
825
+ continuation.resume(throwing: URLError(.badServerResponse))
826
+ return
827
+ }
828
+ continuation.resume(returning: NativeUpdateResponse(
829
+ status: response.statusCode,
830
+ headers: Self.headers(of: response),
831
+ body: String(decoding: data, as: UTF8.self)
832
+ ))
833
+ }
834
+ running.start(session.dataTask(with: request))
835
+ }
836
+ } onCancel: {
837
+ running.cancel()
838
+ }
839
+ }
840
+ }
841
+
842
+ extension URLSessionUpdateTransport {
843
+ fileprivate static func headers(of response: HTTPURLResponse) -> [String: String] {
844
+ var headers = [String: String]()
845
+ for (name, value) in response.allHeaderFields {
846
+ guard let name = name as? String else { continue }
847
+ headers[name.lowercased()] = "\(value)"
848
+ }
849
+ return headers
850
+ }
851
+ }
852
+
853
+ /// Collects one data task's response, body, and completion for its caller.
854
+ private final class DataTaskCollector: NSObject, URLSessionDataDelegate, @unchecked Sendable {
855
+ private let lock = NSLock()
856
+ private var response: HTTPURLResponse?
857
+ private var data = Data()
858
+ private var completion: ((Data, HTTPURLResponse?, Error?) -> Void)?
859
+
860
+ var onComplete: ((Data, HTTPURLResponse?, Error?) -> Void)? {
861
+ get {
862
+ lock.lock()
863
+ defer { lock.unlock() }
864
+ return completion
865
+ }
866
+ set {
867
+ lock.lock()
868
+ completion = newValue
869
+ lock.unlock()
870
+ }
871
+ }
872
+
873
+ func urlSession(
874
+ _: URLSession,
875
+ dataTask _: URLSessionDataTask,
876
+ didReceive response: URLResponse,
877
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
878
+ ) {
879
+ lock.lock()
880
+ self.response = response as? HTTPURLResponse
881
+ lock.unlock()
882
+ completionHandler(.allow)
883
+ }
884
+
885
+ func urlSession(_: URLSession, dataTask _: URLSessionDataTask, didReceive data: Data) {
886
+ lock.lock()
887
+ self.data.append(data)
888
+ lock.unlock()
889
+ }
890
+
891
+ func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: Error?) {
892
+ lock.lock()
893
+ let result = (data, response, completion)
894
+ completion = nil
895
+ lock.unlock()
896
+ result.2?(result.0, result.1, error)
897
+ }
898
+ }
899
+
900
+ /// The data task of one request; cancelling before it starts cancels it as
901
+ /// it starts.
902
+ private final class RunningDataTask: @unchecked Sendable {
903
+ private let lock = NSLock()
904
+ private var task: URLSessionDataTask?
905
+ private var cancelled = false
906
+
907
+ func start(_ task: URLSessionDataTask) {
908
+ lock.lock()
909
+ self.task = task
910
+ let cancelled = self.cancelled
911
+ lock.unlock()
912
+ task.resume()
913
+ if cancelled {
914
+ task.cancel()
915
+ }
916
+ }
917
+
918
+ func cancel() {
919
+ lock.lock()
920
+ cancelled = true
921
+ let task = self.task
922
+ lock.unlock()
923
+ task?.cancel()
924
+ }
925
+ }
926
+
927
+ // MARK: - Update check
928
+
929
+ final class NativeUpdateChecker {
930
+ private static let nilUUID = "00000000-0000-0000-0000-000000000000"
931
+ private static let maxETagBytes = 1024
932
+ private static let maxCatalogCacheEntryBytes =
933
+ ReleaseCatalogLimits.maxReleaseCatalogWireBytes + maxETagBytes + 3
934
+
935
+ private let host: NativeUpdateHost
936
+ private let transport: NativeUpdateTransport
937
+ /// The wall clock, for the times events record.
938
+ private let now: () -> Date
939
+ private let gate = NativeUpdateGate()
940
+ private let committedTargetLock = NSLock()
941
+ /// The channel the check or staging holding the gate requests, recorded
942
+ /// with its HTTP responses: an explicit channel is not the runtime one
943
+ /// until the switch commits.
944
+ private var requestChannelValue: String?
945
+ /// The cohort the check or staging holding the gate selected for, which
946
+ /// its events report.
947
+ private var requestCohortValue: String?
948
+ /// The target the last staging committed, for another retained result of
949
+ /// the same target.
950
+ private var lastCommittedTargetKey: String?
951
+
952
+ init(
953
+ host: NativeUpdateHost,
954
+ transport: NativeUpdateTransport = URLSessionUpdateTransport(),
955
+ now: @escaping () -> Date = Date.init
956
+ ) {
957
+ self.host = host
958
+ self.transport = transport
959
+ self.now = now
960
+ }
961
+
962
+ /// The update the app should apply, or nil. A failed check queues
963
+ /// `onUpdateError` and throws. Waits for other native update work
964
+ /// without a deadline.
965
+ func checkForUpdate(_ request: NativeCheckRequest) async throws -> HotUpdaterUpdate? {
966
+ guard case .acquired = await gate.enter() else {
967
+ throw CancellationError()
968
+ }
969
+ defer { gate.release() }
970
+ return try await check(request)
971
+ }
972
+
973
+ /// Stages `update`, joins its staging already running, or returns true at
974
+ /// once when it was already staged.
975
+ func updateBundle(_ update: HotUpdaterUpdate, onProgress: ((Double) -> Void)?) async throws -> Bool {
976
+ // A reset or a newer catalog can undo a staging; it counts only while current.
977
+ if update.isStaged, update.isSelectionCurrent() {
978
+ return true
979
+ }
980
+ let staging: NativeUpdateStaging
981
+ let listener: Int?
982
+ switch await gate.enter(joins: { $0.update.targetKey == update.targetKey }) {
983
+ case .joined(let running):
984
+ staging = running
985
+ listener = running.progress.add(onProgress)
986
+ case .stopped:
987
+ throw CancellationError()
988
+ case .acquired:
989
+ // A staging of the same target may have committed while this call
990
+ // waited, or before it was made; it counts while the selection is current.
991
+ if update.isStaged || committedTarget == update.targetKey {
992
+ let current = update.isSelectionCurrent()
993
+ gate.release()
994
+ guard current else {
995
+ throw NativeUpdateCheckError.staleReleaseCatalog
996
+ }
997
+ update.markStaged()
998
+ return true
999
+ }
1000
+ (staging, listener) = startStaging(update, onProgress: onProgress)
1001
+ }
1002
+ // A caller that stopped waiting gets no value from then on, even when
1003
+ // the staging finished meanwhile; one that saw it finish still gets
1004
+ // the values reported before, such as the 1 of its download.
1005
+ var sawFinish = false
1006
+ defer { staging.progress.remove(listener, afterPending: sawFinish) }
1007
+ switch await wait(for: staging) {
1008
+ case .finished(let result):
1009
+ sawFinish = true
1010
+ let committed = try result.get()
1011
+ // A joined staging of the same target staged this update too.
1012
+ if committed { update.markStaged() }
1013
+ return committed
1014
+ case .stopped:
1015
+ throw CancellationError()
1016
+ }
1017
+ }
1018
+
1019
+ /// Clears the runtime channel once other native update work finishes.
1020
+ /// A reset deletes staged bundles, so their queued downloads are dropped
1021
+ /// rather than replayed.
1022
+ func resetChannel() async -> Bool {
1023
+ await whenIdle {
1024
+ let reset = await self.host.resetChannel()
1025
+ if reset { NativeUpdateEvents.remove(name: "onBundleDownloaded", from: self.host) }
1026
+ return reset
1027
+ } ?? false
1028
+ }
1029
+
1030
+ /// Runs `body` once no check or staging runs, as a channel reset must: a
1031
+ /// staging that finished after the reset would save its channel again.
1032
+ /// Returns nil when the wait is cancelled.
1033
+ @discardableResult
1034
+ func whenIdle<T>(_ body: () async -> T) async -> T? {
1035
+ guard case .acquired = await gate.enter() else {
1036
+ return nil
1037
+ }
1038
+ defer { gate.release() }
1039
+ // What runs here, such as a reset, can undo what was staged.
1040
+ committedTarget = nil
1041
+ return await body()
1042
+ }
1043
+
1044
+ private var requestChannel: String? {
1045
+ get {
1046
+ committedTargetLock.lock()
1047
+ defer { committedTargetLock.unlock() }
1048
+ return requestChannelValue
1049
+ }
1050
+ set {
1051
+ committedTargetLock.lock()
1052
+ requestChannelValue = newValue
1053
+ committedTargetLock.unlock()
1054
+ }
1055
+ }
1056
+
1057
+ private var requestCohort: String? {
1058
+ get {
1059
+ committedTargetLock.lock()
1060
+ defer { committedTargetLock.unlock() }
1061
+ return requestCohortValue
1062
+ }
1063
+ set {
1064
+ committedTargetLock.lock()
1065
+ requestCohortValue = newValue
1066
+ committedTargetLock.unlock()
1067
+ }
1068
+ }
1069
+
1070
+ private var committedTarget: String? {
1071
+ get {
1072
+ committedTargetLock.lock()
1073
+ defer { committedTargetLock.unlock() }
1074
+ return lastCommittedTargetKey
1075
+ }
1076
+ set {
1077
+ committedTargetLock.lock()
1078
+ lastCommittedTargetKey = newValue
1079
+ committedTargetLock.unlock()
1080
+ }
1081
+ }
1082
+
1083
+ /// Hands the gate the caller holds to a staging of `update`, published
1084
+ /// before it starts, which releases the gate when it finishes. The
1085
+ /// caller's progress listener is added before the staging starts, so its
1086
+ /// first reports reach the caller; the token removes it.
1087
+ private func startStaging(
1088
+ _ update: HotUpdaterUpdate,
1089
+ onProgress: ((Double) -> Void)?
1090
+ ) -> (NativeUpdateStaging, Int?) {
1091
+ let staging = NativeUpdateStaging(update: update)
1092
+ let listener = staging.progress.add(onProgress)
1093
+ gate.publish(staging)
1094
+ Task { [self] in
1095
+ let result: Result<Bool, Error>
1096
+ do {
1097
+ result = .success(try await update.install(staging.progress.report))
1098
+ } catch {
1099
+ result = .failure(error)
1100
+ }
1101
+ if case .success(true) = result {
1102
+ update.markStaged()
1103
+ committedTarget = update.targetKey
1104
+ }
1105
+ staging.finish(result)
1106
+ gate.release(finishing: staging)
1107
+ }
1108
+ return (staging, listener)
1109
+ }
1110
+
1111
+ /// Waits for a staging until it finishes or the caller is cancelled,
1112
+ /// which leaves the staging running.
1113
+ private func wait(for staging: NativeUpdateStaging) async -> StagingWait {
1114
+ let race = StagingWaitRace()
1115
+ staging.onFinish { race.finish(.finished($0)) }
1116
+ return await withTaskCancellationHandler {
1117
+ await race.wait()
1118
+ } onCancel: {
1119
+ race.finish(.stopped)
1120
+ }
1121
+ }
1122
+
1123
+ // MARK: checkForUpdate
1124
+
1125
+ private func check(_ options: NativeCheckRequest) async throws -> HotUpdaterUpdate? {
1126
+ let currentAppVersion = host.appVersion
1127
+ let currentBundleId = resolveBundleId(host.bundleId())
1128
+ let minimumReleaseId = host.minBundleId
1129
+ let defaultChannel = host.defaultChannel()
1130
+ let nativeChannel = host.channel()
1131
+ let isSwitched = nativeChannel != defaultChannel
1132
+ let currentChannel = isSwitched ? nativeChannel : defaultChannel
1133
+ let explicitChannel = options.channel.flatMap { $0.isEmpty ? nil : $0 }
1134
+ let targetChannel = explicitChannel ?? currentChannel
1135
+ // A check that cannot start, with an invalid cohort, no app version, or
1136
+ // another channel while the runtime channel is switched, throws without
1137
+ // onUpdateError: checkForUpdate.ts reports these only to onError.
1138
+ let cohort = try Self.normalizeAndValidateCohort(host.cohort())
1139
+
1140
+ guard let currentAppVersion, !currentAppVersion.isEmpty else {
1141
+ throw NativeUpdateCheckError.hotUpdater("Failed to get app version")
1142
+ }
1143
+
1144
+ if isSwitched, let explicitChannel, explicitChannel != currentChannel {
1145
+ throw NativeUpdateCheckError.hotUpdater(
1146
+ "Runtime channel is already switched to \"\(currentChannel)\". Call hotUpdater.resetChannel() before checking \"\(explicitChannel)\"."
1147
+ )
1148
+ }
1149
+
1150
+ let fingerprintHash = host.fingerprintHash()
1151
+ requestChannel = targetChannel
1152
+ requestCohort = cohort
1153
+
1154
+ let update: HotUpdaterUpdate?
1155
+ do {
1156
+ // Invalid options fail the check as a failed request does in
1157
+ // JavaScript, so they are reported too.
1158
+ let baseURL = try Self.validatedBaseURL(options.baseURL)
1159
+ guard options.requestTimeout.isFinite, options.requestTimeout > 0 else {
1160
+ throw NativeUpdateCheckError.hotUpdater("requestTimeout must be a positive number of seconds")
1161
+ }
1162
+ update = try await checkReleaseCatalog(
1163
+ options: options,
1164
+ baseURL: baseURL,
1165
+ currentAppVersion: currentAppVersion,
1166
+ currentBundleId: currentBundleId,
1167
+ minimumReleaseId: minimumReleaseId,
1168
+ currentChannel: currentChannel,
1169
+ targetChannel: targetChannel,
1170
+ explicitChannel: explicitChannel,
1171
+ cohort: cohort,
1172
+ fingerprintHash: fingerprintHash
1173
+ )
1174
+ } catch {
1175
+ reportUpdateError(
1176
+ error,
1177
+ stage: "check",
1178
+ resource: "catalog",
1179
+ scope: ErrorScope(
1180
+ channel: targetChannel,
1181
+ bundleId: currentBundleId,
1182
+ updateStrategy: options.updateStrategy,
1183
+ targetBundleId: nil,
1184
+ targetReleaseId: nil
1185
+ )
1186
+ )
1187
+ throw error
1188
+ }
1189
+ if update == nil {
1190
+ // The app keeps its bundle, as checkForUpdate.ts reports it.
1191
+ let releaseId = runningReleaseId(bundleId: currentBundleId, channel: currentChannel)
1192
+ queueUnchanged(channel: currentChannel, bundleId: currentBundleId, releaseId: releaseId, previousReleaseId: releaseId)
1193
+ }
1194
+ return update
1195
+ }
1196
+
1197
+ private func checkReleaseCatalog(
1198
+ options: NativeCheckRequest,
1199
+ baseURL: String,
1200
+ currentAppVersion: String,
1201
+ currentBundleId: String,
1202
+ minimumReleaseId: String,
1203
+ currentChannel: String,
1204
+ targetChannel: String,
1205
+ explicitChannel: String?,
1206
+ cohort: String,
1207
+ fingerprintHash: String?
1208
+ ) async throws -> HotUpdaterUpdate? {
1209
+ let channelKey = try encodeChannelKey(targetChannel)
1210
+ let strategy: ReleaseStrategy = options.updateStrategy == .appVersion ? .appVersion : .fingerprint
1211
+ let canonicalAppVersion = canonicalizeAppVersion(currentAppVersion)
1212
+ if strategy == .appVersion && canonicalAppVersion == nil {
1213
+ throw NativeUpdateCheckError.hotUpdater("Failed to canonicalize app version")
1214
+ }
1215
+ let fingerprintHash = fingerprintHash.flatMap { $0.isEmpty ? nil : $0 }
1216
+ if strategy == .fingerprint && fingerprintHash == nil {
1217
+ throw NativeUpdateCheckError.hotUpdater("Fingerprint hash is required")
1218
+ }
1219
+ let strategyValue = strategy == .appVersion ? canonicalAppVersion! : fingerprintHash!
1220
+ let expectedScope = ReleaseCatalogScope(
1221
+ platform: .ios,
1222
+ channelKey: channelKey,
1223
+ strategy: strategy,
1224
+ fingerprintHash: strategy == .fingerprint ? fingerprintHash : nil
1225
+ )
1226
+ let strategyPath = strategy == .fingerprint ? "fingerprint" : "app-version"
1227
+ let catalogURL = "\(baseURL)/release-catalogs/\(strategyPath)/ios/\(channelKey)/\(Self.encodeURIComponent(strategyValue))"
1228
+ // The server says this scope has no catalog yet: no update, not a failure.
1229
+ guard let catalog = try await fetchReleaseCatalog(
1230
+ baseURL: baseURL,
1231
+ url: catalogURL,
1232
+ expectedScope: expectedScope,
1233
+ options: options
1234
+ ) else {
1235
+ return nil
1236
+ }
1237
+ let catalogId = catalog.catalogId
1238
+ let scopeKey = catalog.scopeKey
1239
+
1240
+ let crashedBundleIds = host.crashHistory()
1241
+ let activeState = host.activeUpdateState()
1242
+ let active = Self.parseSelection(activeState["activeSelection"])
1243
+ let stable = Self.parseSelection(activeState["stableSelection"])
1244
+ let explicitScopeSwitch = explicitChannel != nil && explicitChannel != currentChannel
1245
+ let activeInTargetScope = active.flatMap { active in
1246
+ active.catalogId.jsEquals(catalogId) && active.scopeKey.jsEquals(scopeKey) ? active : nil
1247
+ }
1248
+ let hasAuthenticatedActive = active.map {
1249
+ $0.catalogId != nil && $0.scopeKey != nil && $0.generation != nil
1250
+ } ?? false
1251
+ if hasAuthenticatedActive && activeInTargetScope == nil && !explicitScopeSwitch {
1252
+ throw NativeUpdateCheckError.hotUpdater("Release transition rejected: UNSOLICITED_SCOPE")
1253
+ }
1254
+ let selectorCurrentBundleId =
1255
+ activeInTargetScope != nil || (!hasAuthenticatedActive && !explicitScopeSwitch)
1256
+ ? currentBundleId
1257
+ : minimumReleaseId
1258
+ let selectionContextHash = createReleaseSelectionContextHash(ReleaseSelectionContext(
1259
+ activeBundleId: selectorCurrentBundleId,
1260
+ activeReleaseId: activeInTargetScope?.releaseId,
1261
+ cohort: cohort,
1262
+ minimumReleaseId: minimumReleaseId,
1263
+ strategy: strategy,
1264
+ strategyValue: strategyValue,
1265
+ crashedBundleIds: crashedBundleIds
1266
+ ))
1267
+ let guardParams: [String: Any] = [
1268
+ "catalogId": catalogId,
1269
+ "catalogHash": catalog.catalogHash,
1270
+ "channel": targetChannel,
1271
+ "generation": NSNumber(value: catalog.generation),
1272
+ "scopeKey": scopeKey,
1273
+ "selectionContextHash": selectionContextHash,
1274
+ ]
1275
+ guard host.acceptReleaseCatalog(guardParams) else {
1276
+ throw NativeUpdateCheckError.invalidResponse("Rejected a stale or inconsistent Release catalog")
1277
+ }
1278
+ let stableInTargetScope = stable.flatMap { stable in
1279
+ stable.catalogId.jsEquals(catalogId) && stable.scopeKey.jsEquals(scopeKey) ? stable : nil
1280
+ }
1281
+ // A staged update is active but not running yet; force decisions count
1282
+ // from the Release the app runs.
1283
+ let runningReleaseId = [activeInTargetScope, stableInTargetScope]
1284
+ .first { $0?.bundleId.jsEquals(selectorCurrentBundleId) == true }??
1285
+ .releaseId
1286
+ guard let desired = selectDesiredRelease(catalog, input: ReleaseSelectionInput(
1287
+ builtInBundleId: minimumReleaseId,
1288
+ currentBundleId: selectorCurrentBundleId,
1289
+ activeReleaseId: activeInTargetScope?.releaseId,
1290
+ runningReleaseId: .some(runningReleaseId),
1291
+ minimumReleaseId: minimumReleaseId,
1292
+ cohort: cohort,
1293
+ crashedBundleIds: crashedBundleIds
1294
+ )) else {
1295
+ return nil
1296
+ }
1297
+
1298
+ if desired.kind == .builtin,
1299
+ currentBundleId.jsEquals(minimumReleaseId),
1300
+ active == nil || active?.kind.jsEquals(SelectionKind.builtin.rawValue) == true {
1301
+ return nil
1302
+ }
1303
+
1304
+ let receipt = PersistedSelection(
1305
+ kind: desired.kind.rawValue,
1306
+ releaseId: desired.releaseId,
1307
+ bundleId: desired.bundleId,
1308
+ catalogId: catalogId,
1309
+ scopeKey: scopeKey,
1310
+ generation: catalog.generation,
1311
+ catalogHash: catalog.catalogHash,
1312
+ channel: targetChannel,
1313
+ selectionContextHash: selectionContextHash
1314
+ )
1315
+ if let active, Self.sameReceipt(active, receipt) {
1316
+ return nil
1317
+ }
1318
+
1319
+ let authorization = authorizeReleaseTransition(
1320
+ active: active,
1321
+ desired: receipt,
1322
+ explicitScopeSwitch: explicitScopeSwitch
1323
+ )
1324
+ if !authorization.authorized {
1325
+ if authorization.reason == .emptyTargetScope {
1326
+ return nil
1327
+ }
1328
+ throw NativeUpdateCheckError.hotUpdater("Release transition rejected: \(authorization.reason.rawValue)")
1329
+ }
1330
+
1331
+ let release = desired.release
1332
+ let transitionKind: String
1333
+ if desired.kind == .builtin {
1334
+ transitionKind = "USE_BUILTIN"
1335
+ } else if active?.bundleId.jsEquals(desired.bundleId) == true {
1336
+ transitionKind = "ADOPT_RELEASE"
1337
+ } else {
1338
+ transitionKind = "INSTALL"
1339
+ }
1340
+ let fromReleaseId: String?
1341
+ if let active, active.bundleId.jsEquals(currentBundleId) {
1342
+ fromReleaseId = active.releaseId
1343
+ } else if let stable, stable.bundleId.jsEquals(currentBundleId) {
1344
+ fromReleaseId = stable.releaseId
1345
+ } else {
1346
+ fromReleaseId = nil
1347
+ }
1348
+ let errorScope = ErrorScope(
1349
+ channel: targetChannel,
1350
+ bundleId: currentBundleId,
1351
+ updateStrategy: options.updateStrategy,
1352
+ targetBundleId: desired.bundleId,
1353
+ targetReleaseId: desired.releaseId
1354
+ )
1355
+
1356
+ let installSelection = { [self] (progress: @escaping (Double) -> Void) async throws -> Bool in
1357
+ requestChannel = targetChannel
1358
+ requestCohort = cohort
1359
+ guard host.isReleaseSelectionCurrent(guardParams) else {
1360
+ throw NativeUpdateCheckError.staleReleaseCatalog
1361
+ }
1362
+
1363
+ if transitionKind != "INSTALL" {
1364
+ let committed = try await host.commitReleaseSelection([
1365
+ "guard": guardParams,
1366
+ "selection": receipt.dictionary,
1367
+ ])
1368
+ if committed, transitionKind == "USE_BUILTIN" {
1369
+ // The built-in bundle replaced every staged one: their downloads are void.
1370
+ NativeUpdateEvents.remove(name: "onBundleDownloaded", from: host)
1371
+ }
1372
+ // Only the running bundle is unchanged: adopting a staged bundle's
1373
+ // newer Release leaves it to report itself when it launches.
1374
+ if committed, transitionKind == "ADOPT_RELEASE", desired.bundleId.jsEquals(currentBundleId) {
1375
+ // The running bundle is now selected as a newer Release.
1376
+ queueUnchanged(
1377
+ channel: receipt.channel,
1378
+ bundleId: receipt.bundleId,
1379
+ releaseId: receipt.releaseId,
1380
+ previousReleaseId: active?.releaseId
1381
+ )
1382
+ }
1383
+ return committed
1384
+ }
1385
+
1386
+ let artifact = try await resolveArtifact(
1387
+ baseURL: baseURL,
1388
+ currentBundleId: currentBundleId,
1389
+ targetBundleId: desired.bundleId,
1390
+ options: options
1391
+ )
1392
+ guard host.isReleaseSelectionCurrent(guardParams) else {
1393
+ throw NativeUpdateCheckError.staleReleaseCatalog
1394
+ }
1395
+ var params: [String: Any] = [
1396
+ "bundleId": desired.bundleId,
1397
+ "channel": targetChannel,
1398
+ "manifestFileHash": artifact.manifestFileHash,
1399
+ "manifestUrl": artifact.manifestUrl,
1400
+ "selection": receipt.dictionary,
1401
+ ]
1402
+ if let archiveUrl = artifact.archiveUrl {
1403
+ params["archiveUrl"] = archiveUrl
1404
+ }
1405
+ if let filesUrl = artifact.filesUrl {
1406
+ // installArtifact's parameters, as checkForUpdate.ts sends them:
1407
+ // the files request repeats the check's headers and timeout.
1408
+ params["filesUrl"] = filesUrl
1409
+ params["requestHeaders"] = options.requestHeaders
1410
+ params["requestTimeout"] = options.requestTimeout * 1000
1411
+ if let patch = artifact.patch {
1412
+ params["patch"] = patch
1413
+ }
1414
+ } else {
1415
+ params["assets"] = artifact.assets
1416
+ }
1417
+ // The files responses are reported like the other artifact requests.
1418
+ let onFilesResponse: ((Int, String?) -> Void)? = artifact.filesUrl.flatMap(URL.init(string:)).map { url in
1419
+ { [self] status, body in
1420
+ queueHttpResponse(url, resource: "artifact", NativeUpdateResponse(
1421
+ status: status,
1422
+ headers: [:],
1423
+ body: body ?? "",
1424
+ bodyReadFailed: body == nil
1425
+ ))
1426
+ }
1427
+ }
1428
+ let staged = try await host.updateBundle(params, onFilesResponse: onFilesResponse, progress: progress)
1429
+ if let delivery = Self.readBundleDelivery(staged),
1430
+ !desired.bundleId.jsEquals(currentBundleId) {
1431
+ var payload: [String: Any] = [
1432
+ "channel": targetChannel,
1433
+ "fromBundleId": currentBundleId,
1434
+ "fromReleaseId": fromReleaseId ?? NSNull(),
1435
+ "toBundleId": desired.bundleId,
1436
+ "toReleaseId": desired.releaseId ?? NSNull(),
1437
+ "updateStrategy": options.updateStrategy.name,
1438
+ ]
1439
+ payload.merge(delivery) { _, delivery in delivery }
1440
+ queueEvent("onBundleDownloaded", payload)
1441
+ }
1442
+ return true
1443
+ }
1444
+ let updateBundleForSelection = { [self] (progress: @escaping (Double) -> Void) async throws -> Bool in
1445
+ do {
1446
+ return try await installSelection(progress)
1447
+ } catch {
1448
+ reportUpdateError(error, stage: "download", resource: "artifact", scope: errorScope)
1449
+ throw error
1450
+ }
1451
+ }
1452
+
1453
+ // Adopting the bundle the app already runs needs no reload; adopting a
1454
+ // staged one does when the selector forces it.
1455
+ let shouldForceUpdate = desired.bundleId.jsEquals(currentBundleId) ? false : desired.shouldForceUpdate
1456
+ queueEvent("onUpdateCheck", [
1457
+ "status": "UPDATE_AVAILABLE",
1458
+ "channel": targetChannel,
1459
+ "fromBundleId": currentBundleId,
1460
+ "fromReleaseId": fromReleaseId ?? NSNull(),
1461
+ "toBundleId": desired.bundleId,
1462
+ "toReleaseId": desired.releaseId ?? NSNull(),
1463
+ "transitionKind": transitionKind,
1464
+ "updateStatus": desired.status.rawValue,
1465
+ "shouldForceUpdate": shouldForceUpdate,
1466
+ "updateStrategy": options.updateStrategy.name,
1467
+ ])
1468
+
1469
+ return HotUpdaterUpdate(
1470
+ id: desired.releaseId ?? desired.bundleId,
1471
+ releaseId: desired.releaseId,
1472
+ status: desired.status == .rollback ? .rollback : .update,
1473
+ shouldForceUpdate: shouldForceUpdate,
1474
+ message: release?.message,
1475
+ rolloutCohortCount: release?.rolloutCohortCount ?? 1000,
1476
+ targetCohorts: release?.targetCohorts ?? [],
1477
+ bundleId: desired.bundleId,
1478
+ transitionKind: transitionKind,
1479
+ runsCurrentBundle: desired.bundleId.jsEquals(currentBundleId),
1480
+ // JSON, so no field can spell another, such as a channel with a colon.
1481
+ targetKey: Self.jsonKey([
1482
+ desired.kind.rawValue, desired.bundleId, desired.releaseId ?? NSNull(), targetChannel,
1483
+ catalogId, catalog.generation, catalog.catalogHash, selectionContextHash,
1484
+ ]),
1485
+ checker: self,
1486
+ install: updateBundleForSelection,
1487
+ isSelectionCurrent: { [host] in host.isReleaseSelectionCurrent(guardParams) }
1488
+ )
1489
+ }
1490
+
1491
+ // MARK: State
1492
+
1493
+ private static func jsonKey(_ fields: [Any]) -> String {
1494
+ let data = (try? JSONSerialization.data(withJSONObject: fields)) ?? Data()
1495
+ return String(decoding: data, as: UTF8.self)
1496
+ }
1497
+
1498
+ private func queueUnchanged(channel: String, bundleId: String, releaseId: String?, previousReleaseId: String?) {
1499
+ queueEvent("onUpdateCheck", [
1500
+ "status": "UNCHANGED",
1501
+ "channel": channel,
1502
+ "bundleId": bundleId,
1503
+ "releaseId": releaseId ?? NSNull(),
1504
+ "previousReleaseId": previousReleaseId ?? NSNull(),
1505
+ ])
1506
+ }
1507
+
1508
+ private func resolveBundleId(_ bundleId: String?) -> String {
1509
+ guard let bundleId, !bundleId.isEmpty, bundleId != Self.nilUUID else {
1510
+ return host.minBundleId
1511
+ }
1512
+ return bundleId
1513
+ }
1514
+
1515
+ private func runningReleaseId(bundleId: String, channel: String) -> String? {
1516
+ let state = host.activeUpdateState()
1517
+ return [state["activeSelection"], state["stableSelection"]]
1518
+ .compactMap(Self.parseSelection)
1519
+ .first { $0.bundleId.jsEquals(bundleId) && $0.channel.jsEquals(channel) }?
1520
+ .releaseId
1521
+ }
1522
+
1523
+ private static func parseSelection(_ value: Any?) -> PersistedSelection? {
1524
+ guard let data = value as? [String: Any],
1525
+ let kind = data["kind"] as? String,
1526
+ let bundleId = data["bundleId"] as? String,
1527
+ let channel = data["channel"] as? String else {
1528
+ return nil
1529
+ }
1530
+ return PersistedSelection(
1531
+ kind: kind,
1532
+ releaseId: data["releaseId"] as? String,
1533
+ bundleId: bundleId,
1534
+ catalogId: data["catalogId"] as? String,
1535
+ scopeKey: data["scopeKey"] as? String,
1536
+ generation: (data["generation"] as? NSNumber)?.int64Value,
1537
+ catalogHash: data["catalogHash"] as? String,
1538
+ channel: channel,
1539
+ selectionContextHash: data["selectionContextHash"] as? String
1540
+ )
1541
+ }
1542
+
1543
+ private static func sameReceipt(_ first: PersistedSelection, _ second: PersistedSelection) -> Bool {
1544
+ first.kind.jsEquals(second.kind)
1545
+ && first.releaseId.jsEquals(second.releaseId)
1546
+ && first.bundleId.jsEquals(second.bundleId)
1547
+ && first.catalogId.jsEquals(second.catalogId)
1548
+ && first.scopeKey.jsEquals(second.scopeKey)
1549
+ && first.generation == second.generation
1550
+ && first.catalogHash.jsEquals(second.catalogHash)
1551
+ && first.channel.jsEquals(second.channel)
1552
+ && first.selectionContextHash.jsEquals(second.selectionContextHash)
1553
+ }
1554
+
1555
+ private static func normalizeAndValidateCohort(_ cohort: String) throws -> String {
1556
+ let normalized = normalizeCohortValue(cohort)
1557
+ let isNumeric = Int(normalized).map { (1...ReleaseCatalogLimits.numericCohortSize).contains($0) } ?? false
1558
+ guard isNumeric || isCustomCohort(normalized) else {
1559
+ throw NativeUpdateCheckError.hotUpdater(
1560
+ "Invalid cohort. Use 1-1000 or a lowercase slug without spaces, up to \(ReleaseCatalogLimits.maxCohortLength) characters."
1561
+ )
1562
+ }
1563
+ return normalized
1564
+ }
1565
+
1566
+ private static func readBundleDelivery(_ value: Any?) -> [String: Any]? {
1567
+ guard let value = value as? [String: Any],
1568
+ let delivery = value["delivery"] as? String,
1569
+ ["patch", "manifest", "archive"].contains(delivery) else {
1570
+ return nil
1571
+ }
1572
+ return ["delivery": delivery, "patchFallback": value["patchFallback"] as? Bool == true]
1573
+ }
1574
+
1575
+ // MARK: HTTP
1576
+
1577
+ /// `baseURL` without trailing slashes; it must be an absolute HTTP(S) URL.
1578
+ static func validatedBaseURL(_ baseURL: String) throws -> String {
1579
+ var value = baseURL
1580
+ while value.hasSuffix("/") {
1581
+ value.removeLast()
1582
+ }
1583
+ guard let url = URL(string: value),
1584
+ let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
1585
+ url.host?.isEmpty == false else {
1586
+ throw NativeUpdateCheckError.hotUpdater("Invalid baseURL: \(baseURL)")
1587
+ }
1588
+ return value
1589
+ }
1590
+
1591
+ private static let uriComponentAllowed = CharacterSet(
1592
+ charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()"
1593
+ )
1594
+
1595
+ static func encodeURIComponent(_ value: String) -> String {
1596
+ value.addingPercentEncoding(withAllowedCharacters: uriComponentAllowed) ?? value
1597
+ }
1598
+
1599
+ /// Header names lowercased, as `new Headers(requestHeaders)` stores them.
1600
+ private static func normalizedHeaders(_ headers: [String: String]) -> [String: String] {
1601
+ var normalized = [String: String]()
1602
+ for (name, value) in headers {
1603
+ normalized[name.lowercased()] = value
1604
+ }
1605
+ return normalized
1606
+ }
1607
+
1608
+ /// Requests `url` with the check's `requestTimeout`.
1609
+ private func get(
1610
+ _ url: String,
1611
+ resource: String,
1612
+ headers: [String: String],
1613
+ options: NativeCheckRequest
1614
+ ) async throws -> NativeUpdateResponse {
1615
+ guard let requestURL = URL(string: url) else {
1616
+ throw URLError(.badURL)
1617
+ }
1618
+ do {
1619
+ let response = try await transport.get(requestURL, headers: headers, timeout: options.requestTimeout)
1620
+ queueHttpResponse(requestURL, resource: resource, response)
1621
+ return response
1622
+ } catch let error as NativeUpdateResponseBodyError {
1623
+ // Reported with a null body, as fetchUpdateResponse.ts does, and the
1624
+ // request fails as it did.
1625
+ queueHttpResponse(requestURL, resource: resource, error.response)
1626
+ throw error.underlying
1627
+ }
1628
+ }
1629
+
1630
+ /// The longest response body queued, in UTF-16 code units as the Android
1631
+ /// and JavaScript paths count it: the queue lives in the key-value store.
1632
+ /// A grapheme count would let one character with thousands of combining
1633
+ /// marks through whole.
1634
+ static let httpResponseBodyLimit = 4_096
1635
+
1636
+ /// The body as queued: at most `httpResponseBodyLimit` UTF-16 code units.
1637
+ static func queuedHttpResponseBody(_ body: String) -> (body: String, truncated: Bool) {
1638
+ let units = body.utf16
1639
+ guard units.count > httpResponseBodyLimit else { return (body, false) }
1640
+ // Decoding repairs a surrogate pair split at the limit.
1641
+ return (String(decoding: Array(units.prefix(httpResponseBodyLimit)), as: UTF16.self), true)
1642
+ }
1643
+
1644
+ /// Queues a catalog or artifact response for `onHttpResponse`, as
1645
+ /// fetchUpdateResponse.ts reports it. `resource` is "catalog" or "artifact".
1646
+ private func queueHttpResponse(_ url: URL, resource: String, _ response: NativeUpdateResponse) {
1647
+ let path = url.path.isEmpty ? "/" : url.path
1648
+ let queued = Self.queuedHttpResponseBody(response.body)
1649
+ var payload: [String: Any] = [
1650
+ "resource": resource,
1651
+ "path": path,
1652
+ "status": response.status,
1653
+ "body": response.bodyReadFailed ? NSNull() : queued.body,
1654
+ "bodyTruncated": queued.truncated,
1655
+ ]
1656
+ if let requestChannel { payload["channel"] = requestChannel }
1657
+ queueEvent("onHttpResponse", payload)
1658
+ }
1659
+
1660
+ /// How native downloads a Bundle, as httpClient.ts resolves it. In
1661
+ /// artifact protocol v1, `assets` holds a URL for every file. In v2,
1662
+ /// `patch` is the Hermes patch with its `assetPath`, and the staging asks
1663
+ /// `filesUrl` for the URLs of the files it downloads.
1664
+ private struct Artifact {
1665
+ let assets: [String: Any]
1666
+ let manifestUrl: String
1667
+ let manifestFileHash: String
1668
+ let archiveUrl: String?
1669
+ let filesUrl: String?
1670
+ var patch: [String: Any]? = nil
1671
+ }
1672
+
1673
+ private func resolveArtifact(
1674
+ baseURL: String,
1675
+ currentBundleId: String,
1676
+ targetBundleId: String,
1677
+ options: NativeCheckRequest
1678
+ ) async throws -> Artifact {
1679
+ var headers = Self.normalizedHeaders(options.requestHeaders)
1680
+ if headers["accept"] == nil {
1681
+ headers["accept"] = "application/json"
1682
+ }
1683
+ let target = Self.encodeURIComponent(targetBundleId)
1684
+ let route = "\(target)/from/\(Self.encodeURIComponent(currentBundleId))"
1685
+ let response = try await get(
1686
+ "\(baseURL)/artifacts/v2/\(route)",
1687
+ resource: "artifact",
1688
+ headers: headers,
1689
+ options: options
1690
+ )
1691
+ // Legacy: artifact protocol v1: a server older than v2 has no such
1692
+ // route. Remove this fallback once no supported server predates v2.
1693
+ if response.status == 404 {
1694
+ return try await resolveArtifactV1(baseURL: baseURL, route: route, headers: headers, options: options)
1695
+ }
1696
+ guard response.status == 200 else {
1697
+ throw NativeUpdateCheckError.http(status: response.status)
1698
+ }
1699
+ // As JSON.parse reads it, which JSONSerialization does not.
1700
+ guard let info = StrictJson.parse(response.body) as? [String: Any] else {
1701
+ throw NativeUpdateCheckError.invalidResponse("Received an invalid artifact response")
1702
+ }
1703
+ try Self.requireArtifactProtocolV2(info)
1704
+ return Artifact(
1705
+ assets: [:],
1706
+ manifestUrl: info["manifestUrl"] as! String,
1707
+ manifestFileHash: info["manifestFileHash"] as! String,
1708
+ archiveUrl: (info["archiveUrl"] as? String).flatMap { $0.isEmpty ? nil : $0 },
1709
+ filesUrl: "\(baseURL)/artifacts/v2/\(target)/files",
1710
+ patch: info["patch"] as? [String: Any]
1711
+ )
1712
+ }
1713
+
1714
+ // Legacy: artifact protocol v1: reads a server that predates v2; remove with the fallback.
1715
+ private func resolveArtifactV1(
1716
+ baseURL: String,
1717
+ route: String,
1718
+ headers: [String: String],
1719
+ options: NativeCheckRequest
1720
+ ) async throws -> Artifact {
1721
+ let response = try await get(
1722
+ "\(baseURL)/artifacts/v1/\(route)",
1723
+ resource: "artifact",
1724
+ headers: headers,
1725
+ options: options
1726
+ )
1727
+ guard response.status == 200 else {
1728
+ throw NativeUpdateCheckError.http(status: response.status)
1729
+ }
1730
+ guard let info = StrictJson.parse(response.body) as? [String: Any] else {
1731
+ throw NativeUpdateCheckError.invalidResponse("Received an invalid artifact response")
1732
+ }
1733
+ try Self.requireArtifactProtocolV1(info)
1734
+ try Self.requireArtifactUrls(info)
1735
+ return Artifact(
1736
+ assets: info["assets"] as! [String: Any],
1737
+ manifestUrl: info["manifestUrl"] as! String,
1738
+ manifestFileHash: info["manifestFileHash"] as! String,
1739
+ archiveUrl: (info["archiveUrl"] as? String).flatMap { $0.isEmpty ? nil : $0 },
1740
+ filesUrl: nil
1741
+ )
1742
+ }
1743
+
1744
+ private static func requireArtifactProtocolV2(_ info: [String: Any]) throws {
1745
+ let unsupported = NativeUpdateCheckError.invalidResponse("Server does not support artifact protocol 2.")
1746
+ guard let version = info["artifactProtocolVersion"] as? NSNumber,
1747
+ CFGetTypeID(version) != CFBooleanGetTypeID(),
1748
+ version.doubleValue == 2,
1749
+ let manifestUrl = info["manifestUrl"] as? String, !manifestUrl.isEmpty,
1750
+ let manifestFileHash = info["manifestFileHash"] as? String, !manifestFileHash.isEmpty else {
1751
+ throw unsupported
1752
+ }
1753
+ if let archiveUrl = info["archiveUrl"], !(archiveUrl is String) {
1754
+ throw unsupported
1755
+ }
1756
+ var patchUrl: String?
1757
+ if let patch = info["patch"] {
1758
+ guard let patch = patch as? [String: Any],
1759
+ let assetPath = patch["assetPath"] as? String, !assetPath.isEmpty,
1760
+ let url = patch["patchUrl"] as? String, !url.isEmpty else {
1761
+ throw unsupported
1762
+ }
1763
+ patchUrl = url
1764
+ }
1765
+ try requireArtifactUrl(manifestUrl)
1766
+ if let archiveUrl = info["archiveUrl"] as? String, !archiveUrl.isEmpty {
1767
+ try requireArtifactUrl(archiveUrl)
1768
+ }
1769
+ if let patchUrl {
1770
+ try requireArtifactUrl(patchUrl)
1771
+ }
1772
+ }
1773
+
1774
+ private static func requireArtifactProtocolV1(_ info: [String: Any]) throws {
1775
+ let unsupported = NativeUpdateCheckError.invalidResponse("Server does not support artifact protocol 1.")
1776
+ guard let version = info["artifactProtocolVersion"] as? NSNumber,
1777
+ CFGetTypeID(version) != CFBooleanGetTypeID(),
1778
+ version.doubleValue == 1,
1779
+ let assets = info["assets"] as? [String: Any],
1780
+ let manifestUrl = info["manifestUrl"] as? String, !manifestUrl.isEmpty,
1781
+ let manifestFileHash = info["manifestFileHash"] as? String, !manifestFileHash.isEmpty else {
1782
+ throw unsupported
1783
+ }
1784
+ if let archiveUrl = info["archiveUrl"], !(archiveUrl is String) {
1785
+ throw unsupported
1786
+ }
1787
+ for asset in assets.values {
1788
+ let asset = asset as? [String: Any]
1789
+ let file = asset?["file"] as? [String: Any]
1790
+ guard let url = file?["url"] as? String, !url.isEmpty,
1791
+ let fileHash = asset?["fileHash"] as? String, !fileHash.isEmpty else {
1792
+ throw NativeUpdateCheckError.invalidResponse(
1793
+ "Artifact protocol 1 requires an original file for every asset."
1794
+ )
1795
+ }
1796
+ }
1797
+ }
1798
+
1799
+ /// A URL the device downloads from: the server returns each one absolute.
1800
+ private static func requireArtifactUrl(_ value: String) throws {
1801
+ guard value.range(of: "^https?://", options: [.regularExpression, .caseInsensitive]) != nil else {
1802
+ throw NativeUpdateCheckError.invalidResponse("Artifact URLs must be absolute HTTP(S) URLs.")
1803
+ }
1804
+ // Foundation parses a hostless "https://"; a download needs a host.
1805
+ guard let url = URL(string: value), url.host?.isEmpty == false else {
1806
+ throw NativeUpdateCheckError.invalidResponse("Invalid artifact URL: \(value)")
1807
+ }
1808
+ }
1809
+
1810
+ private static func requireArtifactUrls(_ info: [String: Any]) throws {
1811
+ try requireArtifactUrl(info["manifestUrl"] as? String ?? "")
1812
+ if let archiveUrl = info["archiveUrl"] as? String, !archiveUrl.isEmpty {
1813
+ try requireArtifactUrl(archiveUrl)
1814
+ }
1815
+ for asset in (info["assets"] as? [String: Any] ?? [:]).values {
1816
+ let asset = asset as? [String: Any]
1817
+ try requireArtifactUrl((asset?["file"] as? [String: Any])?["url"] as? String ?? "")
1818
+ // A patch is an object, or null, false or absent for none, as
1819
+ // JavaScript and Android read it.
1820
+ switch asset?["patch"] {
1821
+ case nil, is NSNull:
1822
+ break
1823
+ case let patch as [String: Any]:
1824
+ try requireArtifactUrl(patch["patchUrl"] as? String ?? "")
1825
+ case let flag as Bool where !flag:
1826
+ break
1827
+ default:
1828
+ throw NativeUpdateCheckError.invalidResponse("Artifact patch descriptors must be objects.")
1829
+ }
1830
+ }
1831
+ }
1832
+
1833
+ // MARK: Release catalog cache
1834
+
1835
+ private struct CachedCatalog {
1836
+ let catalog: ReleaseCatalog
1837
+ let etag: String
1838
+ }
1839
+
1840
+ private static func isValidETag(_ value: String?) -> Bool {
1841
+ guard let value else { return false }
1842
+ return !value.isEmpty
1843
+ && !value.contains("\r")
1844
+ && !value.contains("\n")
1845
+ && getUtf8ByteLength(value) <= maxETagBytes
1846
+ }
1847
+
1848
+ private static func catalogCachePartition(
1849
+ baseURL: String,
1850
+ url: String,
1851
+ headers: [String: String]
1852
+ ) -> String {
1853
+ let headerLines = normalizedHeaders(headers)
1854
+ .sorted { $0.key < $1.key }
1855
+ .map { "\($0.key):\($0.value)" }
1856
+ return (["native:\(baseURL)", url] + headerLines).joined(separator: "\n")
1857
+ }
1858
+
1859
+ private func readValidatedCache(partition: String, expectedScope: ReleaseCatalogScope) -> CachedCatalog? {
1860
+ guard let value = host.releaseCatalogCache(partition) else {
1861
+ return nil
1862
+ }
1863
+ let parts = value.split(separator: "\n", maxSplits: 2, omittingEmptySubsequences: false)
1864
+ guard parts.count == 3, parts[0] == "1", Self.isValidETag(String(parts[1])),
1865
+ let catalog = parseReleaseCatalog(String(parts[2]), expectedScope: expectedScope) else {
1866
+ host.removeReleaseCatalogCache(partition)
1867
+ return nil
1868
+ }
1869
+ return CachedCatalog(catalog: catalog, etag: String(parts[1]))
1870
+ }
1871
+
1872
+ private func fetchCatalogResponse(
1873
+ url: String,
1874
+ etag: String?,
1875
+ options: NativeCheckRequest
1876
+ ) async throws -> NativeUpdateResponse {
1877
+ var headers = Self.normalizedHeaders(options.requestHeaders)
1878
+ headers["accept"] = "application/json"
1879
+ headers["if-none-match"] = etag
1880
+ return try await get(url, resource: "catalog", headers: headers, options: options)
1881
+ }
1882
+
1883
+ /// The scope's catalog, or nil when the server answers that the scope has
1884
+ /// none (a 404 with `x-hot-updater-catalog: none`).
1885
+ private func fetchReleaseCatalog(
1886
+ baseURL: String,
1887
+ url: String,
1888
+ expectedScope: ReleaseCatalogScope,
1889
+ options: NativeCheckRequest
1890
+ ) async throws -> ReleaseCatalog? {
1891
+ let partition = Self.catalogCachePartition(baseURL: baseURL, url: url, headers: options.requestHeaders)
1892
+ let cached = readValidatedCache(partition: partition, expectedScope: expectedScope)
1893
+ let response = try await fetchCatalogResponse(url: url, etag: cached?.etag, options: options)
1894
+
1895
+ if response.status == 304 {
1896
+ if let cached {
1897
+ return cached.catalog
1898
+ }
1899
+ let repairResponse = try await fetchCatalogResponse(url: url, etag: nil, options: options)
1900
+ return try consumeCatalogResponse(repairResponse, expectedScope: expectedScope, partition: partition)
1901
+ }
1902
+ return try consumeCatalogResponse(response, expectedScope: expectedScope, partition: partition)
1903
+ }
1904
+
1905
+ private func consumeCatalogResponse(
1906
+ _ response: NativeUpdateResponse,
1907
+ expectedScope: ReleaseCatalogScope,
1908
+ partition: String
1909
+ ) throws -> ReleaseCatalog? {
1910
+ // The server marks the 404 of a scope that has no catalog yet: no
1911
+ // update, not a failure. Any other 404 is a wrong baseURL or route.
1912
+ if response.status == 404,
1913
+ response.headers["x-hot-updater-catalog"]?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "none" {
1914
+ return nil
1915
+ }
1916
+ guard response.status == 200 else {
1917
+ throw NativeUpdateCheckError.http(status: response.status)
1918
+ }
1919
+ guard let catalog = parseReleaseCatalog(response.body, expectedScope: expectedScope) else {
1920
+ host.removeReleaseCatalogCache(partition)
1921
+ throw NativeUpdateCheckError.invalidResponse("Received an invalid Release catalog")
1922
+ }
1923
+
1924
+ let etag = response.headers["etag"]
1925
+ if let etag, Self.isValidETag(etag) {
1926
+ let serialized = "1\n\(etag)\n\(response.body)"
1927
+ if getUtf8ByteLength(serialized) > Self.maxCatalogCacheEntryBytes {
1928
+ host.removeReleaseCatalogCache(partition)
1929
+ } else {
1930
+ _ = host.setReleaseCatalogCache(partition, value: serialized)
1931
+ }
1932
+ } else {
1933
+ host.removeReleaseCatalogCache(partition)
1934
+ }
1935
+ return catalog
1936
+ }
1937
+
1938
+ // MARK: Events
1939
+
1940
+ private struct ErrorScope {
1941
+ let channel: String
1942
+ let bundleId: String
1943
+ let updateStrategy: HotUpdaterUpdateStrategy
1944
+ let targetBundleId: String?
1945
+ let targetReleaseId: String?
1946
+ }
1947
+
1948
+ private static let stages: Set<String> = ["check", "download", "install"]
1949
+ private static let reasons: Set<String> = [
1950
+ "network", "http", "invalid_response", "hash_mismatch", "signature",
1951
+ "patch", "extract", "storage", "unknown",
1952
+ ]
1953
+ private static let resources: Set<String> = ["catalog", "artifact", "manifest", "file", "patch", "archive"]
1954
+ private static let transports: Set<String> = ["timeout", "dns", "tls", "connection", "offline", "cancelled"]
1955
+
1956
+ /// Where and why an update failed, or nil when the error is not an update
1957
+ /// failure, as JavaScript's `classifyUpdateError`.
1958
+ static func classifyUpdateError(_ error: Error, stage: String, resource: String?) -> [String: Any]? {
1959
+ if let rejection = error as? NativeUpdateRejection {
1960
+ let userInfo = rejection.userInfo
1961
+ // Native classifies every failure of its download and install; a
1962
+ // rejection without it, such as a stale selection, is not one.
1963
+ guard let stage = userInfo["stage"] as? String, stages.contains(stage),
1964
+ let reason = userInfo["reason"] as? String, reasons.contains(reason) else {
1965
+ return nil
1966
+ }
1967
+ var classification: [String: Any] = ["stage": stage, "reason": reason]
1968
+ if let resource = userInfo["resource"] as? String, resources.contains(resource) {
1969
+ classification["resource"] = resource
1970
+ }
1971
+ if reason == "http", let status = userInfo["httpStatus"] as? NSNumber,
1972
+ status.doubleValue.rounded(.towardZero) == status.doubleValue {
1973
+ classification["httpStatus"] = status.intValue
1974
+ }
1975
+ if reason == "http", let originCode = userInfo["originCode"] as? String,
1976
+ originCode.range(of: "^[A-Za-z0-9._-]{1,64}$", options: .regularExpression) != nil {
1977
+ classification["originCode"] = originCode
1978
+ }
1979
+ if reason == "network", let transport = userInfo["transport"] as? String,
1980
+ transports.contains(transport) {
1981
+ classification["transport"] = transport
1982
+ }
1983
+ return classification
1984
+ }
1985
+
1986
+ var classification: [String: Any] = ["stage": stage]
1987
+ if let resource {
1988
+ classification["resource"] = resource
1989
+ }
1990
+ switch error {
1991
+ case NativeUpdateCheckError.staleReleaseCatalog:
1992
+ // A selection that became stale is a race between checks.
1993
+ return nil
1994
+ case NativeUpdateCheckError.http(let status):
1995
+ classification["reason"] = "http"
1996
+ classification["httpStatus"] = status
1997
+ case NativeUpdateCheckError.invalidResponse:
1998
+ classification["reason"] = "invalid_response"
1999
+ case let error as URLError:
2000
+ classification["reason"] = "network"
2001
+ if let transport = UpdateFailureTransport(error.code) {
2002
+ classification["transport"] = transport.rawValue
2003
+ }
2004
+ default:
2005
+ classification["reason"] = "unknown"
2006
+ }
2007
+ return classification
2008
+ }
2009
+
2010
+ /// `cohort` tags the event when it happened under another cohort than the
2011
+ /// running check's.
2012
+ private func reportUpdateError(
2013
+ _ error: Error,
2014
+ stage: String,
2015
+ resource: String?,
2016
+ scope: ErrorScope,
2017
+ cohort: String? = nil
2018
+ ) {
2019
+ // A caller that cancels its wait has not seen the update fail.
2020
+ if error is CancellationError { return }
2021
+ guard var payload = Self.classifyUpdateError(error, stage: stage, resource: resource) else {
2022
+ return
2023
+ }
2024
+ payload["channel"] = scope.channel
2025
+ payload["bundleId"] = scope.bundleId
2026
+ payload["releaseId"] = runningReleaseId(bundleId: scope.bundleId, channel: host.channel()) ?? NSNull()
2027
+ payload["updateStrategy"] = scope.updateStrategy.name
2028
+ if let targetBundleId = scope.targetBundleId {
2029
+ payload["targetBundleId"] = targetBundleId
2030
+ }
2031
+ if let targetReleaseId = scope.targetReleaseId {
2032
+ payload["targetReleaseId"] = targetReleaseId
2033
+ }
2034
+ payload["message"] = error.localizedDescription
2035
+ queueEvent("onUpdateError", payload, cohort: cohort)
2036
+ }
2037
+
2038
+ private func queueEvent(_ name: String, _ payload: [String: Any], cohort: String? = nil) {
2039
+ NativeUpdateEvents.append(
2040
+ name: name,
2041
+ payload: payload,
2042
+ occurredAt: now(),
2043
+ build: NativeUpdateEvents.build(
2044
+ appVersion: host.appVersion,
2045
+ minBundleId: host.minBundleId,
2046
+ cohort: cohort ?? requestCohort ?? host.cohort()
2047
+ ),
2048
+ to: host
2049
+ )
2050
+ }
2051
+ }