@hot-updater/react-native 1.0.8 → 1.1.1
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/HotUpdater.podspec +14 -2
- package/Package.swift +24 -3
- package/android/build.gradle +2 -6
- package/android/hot-updater-fingerprint.gradle +135 -0
- package/android/src/main/cpp/HotUpdaterRecovery.cpp +291 -21
- package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +176 -15
- package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +20 -8
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +145 -31
- package/android/src/main/java/com/hotupdater/CohortService.kt +10 -2
- package/android/src/main/java/com/hotupdater/FileManagerService.kt +3 -1
- package/android/src/main/java/com/hotupdater/HotUpdaterConfig.kt +249 -21
- package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +8 -4
- package/android/src/main/java/com/hotupdater/HotUpdaterHeldContent.kt +92 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +319 -113
- package/android/src/main/java/com/hotupdater/HotUpdaterInstance.kt +87 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterInstanceEnvironment.kt +323 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterMainThread.kt +46 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterProcess.kt +30 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +493 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
- package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
- package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
- package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
- package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
- package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
- package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
- package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
- package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
- package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
- package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
- package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
- package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
- package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
- package/android/src/newarch/HotUpdater.kt +53 -15
- package/android/src/newarch/HotUpdaterModule.kt +42 -17
- package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
- package/android/src/oldarch/HotUpdater.kt +51 -12
- package/android/src/oldarch/HotUpdaterModule.kt +43 -17
- package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
- package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
- package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
- package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
- package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
- package/ios/HotUpdater/Internal/CohortService.swift +8 -0
- package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
- package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
- package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
- package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
- package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
- package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
- package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
- package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
- package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
- package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
- package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
- package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
- package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
- package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
- package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
- package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
- package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
- package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
- package/ios/HotUpdater/Package.swift +11 -0
- package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
- package/ios/HotUpdater/Public/HotUpdater.h +21 -2
- package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
- package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
- package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
- package/lib/commonjs/checkForUpdate.js +3 -1
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/index.js +9 -12
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/native.js +24 -79
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/nativeUpdateEvents.js +66 -0
- package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/checkForUpdate.js +3 -1
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/index.js +10 -13
- package/lib/module/index.js.map +1 -1
- package/lib/module/native.js +19 -73
- package/lib/module/native.js.map +1 -1
- package/lib/module/nativeUpdateEvents.js +61 -0
- package/lib/module/nativeUpdateEvents.js.map +1 -0
- package/lib/module/sdkVersion.js +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -14
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +9 -46
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
- package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -14
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +9 -46
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
- package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/checkForUpdate.ts +7 -1
- package/src/index.ts +9 -17
- package/src/native.ts +19 -91
- package/src/nativeUpdateEvents.ts +77 -0
- package/src/sdkVersion.ts +1 -1
- package/src/specs/NativeHotUpdater.ts +20 -3
- package/android/libs/README.md +0 -50
- package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
- package/android/src/bridge/BridgeReactInstance.kt +0 -38
- package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
- package/android/src/main/java/com/hotupdater/ContentAppearedLog.kt +0 -31
- package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
- package/android/src/newarch/ReactIntegrationManager.kt +0 -212
- 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
|
+
}
|