@hot-updater/react-native 1.0.5 → 1.0.7
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 +24 -3
- package/Package.swift +100 -0
- package/android/build.gradle +3 -4
- package/android/hot-updater-builtin-assets.gradle +67 -0
- package/android/hot-updater-min-bundle-id.gradle +69 -0
- package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +85 -0
- package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +116 -11
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +117 -12
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +5 -52
- package/android/src/newarch/HotUpdaterModule.kt +129 -34
- package/android/src/oldarch/HotUpdaterModule.kt +130 -34
- package/android/src/oldarch/HotUpdaterSpec.kt +5 -0
- package/ios/HotUpdater/Interface/HotUpdaterInterface.h +69 -0
- package/ios/HotUpdater/Interface/HotUpdaterInterface.m +3 -0
- package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +87 -0
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +88 -13
- package/ios/HotUpdater/Internal/HotUpdater.mm +90 -90
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +154 -52
- package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +26 -0
- package/ios/HotUpdater/Internal/HotUpdaterMinBundleId.m +10 -0
- package/ios/HotUpdater/Package.swift +88 -0
- package/ios/Plugins/HotUpdaterMinBundleIdPlugin/HotUpdaterMinBundleIdPlugin.swift +28 -0
- package/ios/scripts/generate-min-bundle-id.zsh +49 -0
- package/lib/commonjs/checkForUpdate.js +17 -2
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/fetchUpdateResponse.js +5 -2
- package/lib/commonjs/fetchUpdateResponse.js.map +1 -1
- package/lib/commonjs/httpClient.js +58 -5
- package/lib/commonjs/httpClient.js.map +1 -1
- package/lib/commonjs/native.js +42 -7
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js +5 -0
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/checkForUpdate.js +18 -3
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/fetchUpdateResponse.js +4 -1
- package/lib/module/fetchUpdateResponse.js.map +1 -1
- package/lib/module/httpClient.js +61 -7
- package/lib/module/httpClient.js.map +1 -1
- package/lib/module/native.js +41 -7
- package/lib/module/native.js.map +1 -1
- package/lib/module/sdkVersion.js +1 -1
- package/lib/module/specs/NativeHotUpdater.js +7 -0
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/fetchUpdateResponse.d.ts +2 -0
- package/lib/typescript/commonjs/fetchUpdateResponse.d.ts.map +1 -1
- package/lib/typescript/commonjs/httpClient.d.ts +26 -2
- package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +17 -3
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +37 -1
- 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/fetchUpdateResponse.d.ts +2 -0
- package/lib/typescript/module/fetchUpdateResponse.d.ts.map +1 -1
- package/lib/typescript/module/httpClient.d.ts +26 -2
- package/lib/typescript/module/httpClient.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +17 -3
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +37 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/package.json +5 -4
- package/react-native.config.js +7 -0
- package/src/checkForUpdate.ts +19 -3
- package/src/fetchUpdateResponse.ts +4 -1
- package/src/httpClient.ts +88 -9
- package/src/native.ts +53 -9
- package/src/sdkVersion.ts +1 -1
- package/src/specs/NativeHotUpdater.ts +39 -1
|
@@ -47,7 +47,14 @@ class HotUpdaterModule internal constructor(
|
|
|
47
47
|
*/
|
|
48
48
|
private fun getInstance(): HotUpdaterImpl = HotUpdater.getInstance(mReactApplicationContext)
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Every changed file with its hash and URL or Hermes patch; with
|
|
52
|
+
* [requireFileHash] false, also entries that hold only the Hermes patch.
|
|
53
|
+
*/
|
|
54
|
+
private fun parseAssets(
|
|
55
|
+
params: ReadableMap,
|
|
56
|
+
requireFileHash: Boolean,
|
|
57
|
+
): Map<String, ChangedAssetDescriptor>? {
|
|
51
58
|
if (!params.hasKey("assets") || params.isNull("assets")) {
|
|
52
59
|
return null
|
|
53
60
|
}
|
|
@@ -59,38 +66,10 @@ class HotUpdaterModule internal constructor(
|
|
|
59
66
|
while (iterator.hasNextKey()) {
|
|
60
67
|
val assetPath = iterator.nextKey()
|
|
61
68
|
val assetMap = changedAssetsMap.getMap(assetPath) ?: continue
|
|
62
|
-
val assetHash = assetMap.getString("fileHash")
|
|
69
|
+
val assetHash = if (assetMap.hasKey("fileHash")) assetMap.getString("fileHash") else null
|
|
70
|
+
if (assetHash == null && requireFileHash) continue
|
|
63
71
|
val patchMap = assetMap.getMap("patch")
|
|
64
|
-
val patch =
|
|
65
|
-
if (patchMap != null) {
|
|
66
|
-
val algorithm = patchMap.getString("algorithm")
|
|
67
|
-
val baseBundleId = patchMap.getString("baseBundleId")
|
|
68
|
-
val baseFileHash = patchMap.getString("baseFileHash")
|
|
69
|
-
val patchFileHash = patchMap.getString("patchFileHash")
|
|
70
|
-
val patchUrl = patchMap.getString("patchUrl")
|
|
71
|
-
val byteSize = readSafeInteger(patchMap, "byteSize")
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
algorithm != null &&
|
|
75
|
-
baseBundleId != null &&
|
|
76
|
-
baseFileHash != null &&
|
|
77
|
-
patchFileHash != null &&
|
|
78
|
-
patchUrl != null
|
|
79
|
-
) {
|
|
80
|
-
BsdiffPatchDescriptor(
|
|
81
|
-
algorithm = algorithm,
|
|
82
|
-
baseBundleId = baseBundleId,
|
|
83
|
-
baseFileHash = baseFileHash,
|
|
84
|
-
patchFileHash = patchFileHash,
|
|
85
|
-
patchUrl = patchUrl,
|
|
86
|
-
byteSize = byteSize,
|
|
87
|
-
)
|
|
88
|
-
} else {
|
|
89
|
-
null
|
|
90
|
-
}
|
|
91
|
-
} else {
|
|
92
|
-
null
|
|
93
|
-
}
|
|
72
|
+
val patch = parsePatch(patchMap)
|
|
94
73
|
val fileMap =
|
|
95
74
|
if (assetMap.hasKey("file") && !assetMap.isNull("file")) {
|
|
96
75
|
assetMap.getMap("file")
|
|
@@ -107,10 +86,13 @@ class HotUpdaterModule internal constructor(
|
|
|
107
86
|
if (assetUrl == null && patch == null) {
|
|
108
87
|
continue
|
|
109
88
|
}
|
|
89
|
+
if (assetHash == null && assetUrl != null) {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
110
92
|
parsedAssets[assetPath] =
|
|
111
93
|
ChangedAssetDescriptor(
|
|
112
94
|
fileUrl = assetUrl,
|
|
113
|
-
fileHash = assetHash,
|
|
95
|
+
fileHash = assetHash ?: "",
|
|
114
96
|
fileCompression = fileCompression,
|
|
115
97
|
patch = patch,
|
|
116
98
|
)
|
|
@@ -119,6 +101,94 @@ class HotUpdaterModule internal constructor(
|
|
|
119
101
|
return parsedAssets
|
|
120
102
|
}
|
|
121
103
|
|
|
104
|
+
/** The update check's request headers, which the artifact files request repeats. */
|
|
105
|
+
private fun parseRequestHeaders(params: ReadableMap): Map<String, String> {
|
|
106
|
+
if (!params.hasKey("requestHeaders") || params.isNull("requestHeaders")) return emptyMap()
|
|
107
|
+
val headers = params.getMap("requestHeaders") ?: return emptyMap()
|
|
108
|
+
val parsed = linkedMapOf<String, String>()
|
|
109
|
+
val iterator = headers.keySetIterator()
|
|
110
|
+
while (iterator.hasNextKey()) {
|
|
111
|
+
val name = iterator.nextKey()
|
|
112
|
+
if (headers.getType(name) == com.facebook.react.bridge.ReadableType.String) {
|
|
113
|
+
headers.getString(name)?.let { parsed[name] = it }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return parsed
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private fun parsePatch(patchMap: ReadableMap?): BsdiffPatchDescriptor? {
|
|
120
|
+
if (patchMap == null) return null
|
|
121
|
+
val algorithm = patchMap.getString("algorithm")
|
|
122
|
+
val baseBundleId = patchMap.getString("baseBundleId")
|
|
123
|
+
val baseFileHash = patchMap.getString("baseFileHash")
|
|
124
|
+
val patchFileHash = patchMap.getString("patchFileHash")
|
|
125
|
+
val patchUrl = patchMap.getString("patchUrl")
|
|
126
|
+
val byteSize = readSafeInteger(patchMap, "byteSize")
|
|
127
|
+
|
|
128
|
+
return if (
|
|
129
|
+
algorithm != null &&
|
|
130
|
+
baseBundleId != null &&
|
|
131
|
+
baseFileHash != null &&
|
|
132
|
+
patchFileHash != null &&
|
|
133
|
+
patchUrl != null
|
|
134
|
+
) {
|
|
135
|
+
BsdiffPatchDescriptor(
|
|
136
|
+
algorithm = algorithm,
|
|
137
|
+
baseBundleId = baseBundleId,
|
|
138
|
+
baseFileHash = baseFileHash,
|
|
139
|
+
patchFileHash = patchFileHash,
|
|
140
|
+
patchUrl = patchUrl,
|
|
141
|
+
byteSize = byteSize,
|
|
142
|
+
)
|
|
143
|
+
} else {
|
|
144
|
+
null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The resolver that asks `filesUrl` for the files native downloads one by
|
|
150
|
+
* one; null when `filesUrl` is missing.
|
|
151
|
+
*/
|
|
152
|
+
private fun parseFileResolver(params: ReadableMap): ArtifactFileResolver? {
|
|
153
|
+
val filesUrl =
|
|
154
|
+
if (params.hasKey("filesUrl") && !params.isNull("filesUrl")) {
|
|
155
|
+
params.getString("filesUrl")?.takeIf { it.isNotEmpty() }
|
|
156
|
+
} else {
|
|
157
|
+
null
|
|
158
|
+
} ?: return null
|
|
159
|
+
try {
|
|
160
|
+
java.net.URL(filesUrl)
|
|
161
|
+
} catch (e: java.net.MalformedURLException) {
|
|
162
|
+
throw HotUpdaterException.invalidFileUrl("filesUrl", filesUrl, UpdateFailureResource.FILE)
|
|
163
|
+
}
|
|
164
|
+
val requestTimeoutMs =
|
|
165
|
+
if (params.hasKey("requestTimeout") && !params.isNull("requestTimeout")) {
|
|
166
|
+
params.getDouble("requestTimeout").toLong().takeIf { it > 0 }
|
|
167
|
+
} else {
|
|
168
|
+
null
|
|
169
|
+
} ?: HttpArtifactFileResolver.DEFAULT_TIMEOUT_MS
|
|
170
|
+
return HttpArtifactFileResolver(filesUrl, parseRequestHeaders(params), requestTimeoutMs)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Artifact protocol v2: the Hermes patch as the only planned asset, and
|
|
175
|
+
* the resolver that asks `filesUrl` for the files native downloads one by
|
|
176
|
+
* one; null when `filesUrl` is missing.
|
|
177
|
+
*/
|
|
178
|
+
private fun parseArtifactInstall(params: ReadableMap): Pair<Map<String, ChangedAssetDescriptor>, ArtifactFileResolver?>? {
|
|
179
|
+
val fileResolver = parseFileResolver(params) ?: return null
|
|
180
|
+
val patchMap = if (params.hasKey("patch") && !params.isNull("patch")) params.getMap("patch") else null
|
|
181
|
+
val assetPath = patchMap?.getString("assetPath")
|
|
182
|
+
val patch = parsePatch(patchMap)
|
|
183
|
+
val assets =
|
|
184
|
+
if (assetPath != null && patch != null) {
|
|
185
|
+
mapOf(assetPath to ChangedAssetDescriptor(fileUrl = null, fileHash = "", patch = patch))
|
|
186
|
+
} else {
|
|
187
|
+
emptyMap()
|
|
188
|
+
}
|
|
189
|
+
return assets to fileResolver
|
|
190
|
+
}
|
|
191
|
+
|
|
122
192
|
private fun parseSelection(params: ReadableMap): PersistedSelection? {
|
|
123
193
|
if (!params.hasKey("selection") || params.isNull("selection")) return null
|
|
124
194
|
val selection = params.getMap("selection") ?: return null
|
|
@@ -173,6 +243,29 @@ class HotUpdaterModule internal constructor(
|
|
|
173
243
|
override fun updateBundle(
|
|
174
244
|
params: ReadableMap,
|
|
175
245
|
promise: Promise,
|
|
246
|
+
) {
|
|
247
|
+
// Legacy: artifact protocol v1, where every file comes with its URL.
|
|
248
|
+
// JavaScript from the first protocol v2 SDK also sends `filesUrl`
|
|
249
|
+
// here, with only the Hermes patch in `assets`; its OTA Bundles must
|
|
250
|
+
// still install.
|
|
251
|
+
stage(params, promise) {
|
|
252
|
+
val fileResolver = parseFileResolver(params)
|
|
253
|
+
parseAssets(params, requireFileHash = fileResolver == null)?.let { it to fileResolver }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@ReactMethod
|
|
258
|
+
override fun installArtifact(
|
|
259
|
+
params: ReadableMap,
|
|
260
|
+
promise: Promise,
|
|
261
|
+
) {
|
|
262
|
+
stage(params, promise) { parseArtifactInstall(params) }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private fun stage(
|
|
266
|
+
params: ReadableMap,
|
|
267
|
+
promise: Promise,
|
|
268
|
+
plan: () -> Pair<Map<String, ChangedAssetDescriptor>, ArtifactFileResolver?>?,
|
|
176
269
|
) {
|
|
177
270
|
moduleScope.launch {
|
|
178
271
|
try {
|
|
@@ -184,7 +277,8 @@ class HotUpdaterModule internal constructor(
|
|
|
184
277
|
|
|
185
278
|
val manifestUrl = params.getString("manifestUrl")
|
|
186
279
|
val manifestFileHash = params.getString("manifestFileHash")
|
|
187
|
-
val
|
|
280
|
+
val planned = plan()
|
|
281
|
+
val assets = planned?.first
|
|
188
282
|
if (manifestUrl.isNullOrEmpty() || manifestFileHash.isNullOrEmpty() || assets == null) {
|
|
189
283
|
promise.rejectUpdateBundle(HotUpdaterException.invalidManifestParams())
|
|
190
284
|
return@launch
|
|
@@ -215,6 +309,7 @@ class HotUpdaterModule internal constructor(
|
|
|
215
309
|
}
|
|
216
310
|
}
|
|
217
311
|
val selection = parseSelection(params)
|
|
312
|
+
val fileResolver = planned.second
|
|
218
313
|
|
|
219
314
|
val impl = getInstance()
|
|
220
315
|
|
|
@@ -227,6 +322,7 @@ class HotUpdaterModule internal constructor(
|
|
|
227
322
|
channel,
|
|
228
323
|
selection,
|
|
229
324
|
archiveUrl,
|
|
325
|
+
fileResolver,
|
|
230
326
|
) { progress ->
|
|
231
327
|
// Post to Main thread for React Native event emission
|
|
232
328
|
Handler(Looper.getMainLooper()).post {
|
|
@@ -13,6 +13,11 @@ abstract class HotUpdaterSpec internal constructor(
|
|
|
13
13
|
promise: Promise,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
+
abstract fun installArtifact(
|
|
17
|
+
params: ReadableMap,
|
|
18
|
+
promise: Promise,
|
|
19
|
+
)
|
|
20
|
+
|
|
16
21
|
abstract fun acceptReleaseCatalog(params: ReadableMap): Boolean
|
|
17
22
|
|
|
18
23
|
abstract fun getActiveUpdateState(): String
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
#import <React/RCTBridgeModule.h>
|
|
3
|
+
|
|
4
|
+
// Swift Package Manager builds HotUpdater's Swift implementation as its own
|
|
5
|
+
// target, and HotUpdater.mm (Objective-C++) can't import that target's
|
|
6
|
+
// generated header: it is reachable only as a Clang module, and React
|
|
7
|
+
// Native's headers don't build as C++ modules. HotUpdater.mm calls the Swift
|
|
8
|
+
// classes through these protocols instead, and the Swift classes conform to
|
|
9
|
+
// them in HotUpdaterInterface.swift, so the compiler checks both sides.
|
|
10
|
+
// CocoaPods builds import HotUpdater-Swift.h and don't use this file.
|
|
11
|
+
|
|
12
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
13
|
+
|
|
14
|
+
@protocol HotUpdaterImplInterface <NSObject>
|
|
15
|
+
- (NSString *)getChannel;
|
|
16
|
+
- (NSString *)getDefaultChannel;
|
|
17
|
+
- (nullable NSString *)getFingerprintHash;
|
|
18
|
+
- (void)setCohort:(NSString *)cohort;
|
|
19
|
+
- (NSString *)getCohort;
|
|
20
|
+
- (nullable NSURL *)bundleURLWithBundle:(NSBundle *)bundle NS_SWIFT_NAME(bundleURL(bundle:));
|
|
21
|
+
- (void)updateBundle:(nullable NSDictionary *)params
|
|
22
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
23
|
+
rejecter:(RCTPromiseRejectBlock)reject;
|
|
24
|
+
- (void)installArtifact:(nullable NSDictionary *)params
|
|
25
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
26
|
+
rejecter:(RCTPromiseRejectBlock)reject;
|
|
27
|
+
- (BOOL)acceptReleaseCatalog:(nullable NSDictionary *)params;
|
|
28
|
+
- (NSDictionary<NSString *, id> *)getActiveUpdateState;
|
|
29
|
+
- (void)getReleaseCatalogCache:(NSString *)partition
|
|
30
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
31
|
+
rejecter:(RCTPromiseRejectBlock)reject;
|
|
32
|
+
- (void)setReleaseCatalogCache:(NSString *)partition
|
|
33
|
+
payload:(NSString *)payload
|
|
34
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
35
|
+
rejecter:(RCTPromiseRejectBlock)reject;
|
|
36
|
+
- (void)removeReleaseCatalogCache:(NSString *)partition
|
|
37
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
38
|
+
rejecter:(RCTPromiseRejectBlock)reject;
|
|
39
|
+
- (BOOL)isReleaseSelectionCurrent:(nullable NSDictionary *)params;
|
|
40
|
+
- (void)commitReleaseSelection:(nullable NSDictionary *)params
|
|
41
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
42
|
+
rejecter:(RCTPromiseRejectBlock)reject;
|
|
43
|
+
- (NSDictionary<NSString *, id> *)notifyAppReady;
|
|
44
|
+
- (NSArray<NSString *> *)getCrashHistory;
|
|
45
|
+
- (BOOL)clearCrashHistory;
|
|
46
|
+
- (NSString *)getInstallId;
|
|
47
|
+
- (nullable NSString *)getStorageItem:(NSString *)key;
|
|
48
|
+
- (void)setStorageItem:(NSString *)key value:(nullable NSString *)value;
|
|
49
|
+
- (nullable NSString *)getBaseURL;
|
|
50
|
+
- (nullable NSString *)getBundleId;
|
|
51
|
+
- (NSDictionary<NSString *, id> *)getManifest;
|
|
52
|
+
- (void)resetLaunchPreparation;
|
|
53
|
+
- (void)resetChannel:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject;
|
|
54
|
+
@end
|
|
55
|
+
|
|
56
|
+
@protocol HotUpdaterConfigInterface <NSObject>
|
|
57
|
+
- (void)configureWithFingerprintHash:(nullable NSString *)fingerprintHash
|
|
58
|
+
publicKey:(nullable NSString *)publicKey
|
|
59
|
+
channel:(nullable NSString *)channel
|
|
60
|
+
isolationKey:(nullable NSString *)isolationKey
|
|
61
|
+
NS_SWIFT_NAME(configureWithFingerprintHash(_:publicKey:channel:isolationKey:));
|
|
62
|
+
@end
|
|
63
|
+
|
|
64
|
+
// Implemented in HotUpdaterInterface.swift.
|
|
65
|
+
FOUNDATION_EXTERN id<HotUpdaterImplInterface> HotUpdaterMakeImpl(void);
|
|
66
|
+
FOUNDATION_EXTERN NSString *_Nullable HotUpdaterAppVersion(void);
|
|
67
|
+
FOUNDATION_EXTERN id<HotUpdaterConfigInterface> HotUpdaterSharedConfig(void);
|
|
68
|
+
|
|
69
|
+
NS_ASSUME_NONNULL_END
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A download URL the server signed for one manifest file.
|
|
4
|
+
public struct ResolvedArtifactFile: Equatable {
|
|
5
|
+
public let url: URL
|
|
6
|
+
public let compression: String?
|
|
7
|
+
|
|
8
|
+
public init(url: URL, compression: String?) {
|
|
9
|
+
self.url = url
|
|
10
|
+
self.compression = compression
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// Artifact protocol v2: the server signs only the files an install
|
|
15
|
+
/// downloads one by one, after reusing local files and choosing the archive.
|
|
16
|
+
public protocol ArtifactFileResolver {
|
|
17
|
+
/// Download URLs for `paths`, at most `artifactFilesRequestLimit`.
|
|
18
|
+
func resolve(paths: [String]) throws -> [String: ResolvedArtifactFile]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// `ARTIFACT_FILES_REQUEST_LIMIT` in @hot-updater/protocol.
|
|
22
|
+
public let artifactFilesRequestLimit = 100
|
|
23
|
+
|
|
24
|
+
enum ArtifactFileResolverError: Error {
|
|
25
|
+
case invalidResponse
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// POSTs `{ paths }` to the files URL the SDK composed, with the update
|
|
29
|
+
/// check's headers and request timeout.
|
|
30
|
+
final class HTTPArtifactFileResolver: ArtifactFileResolver {
|
|
31
|
+
/// The SDK's request timeout when the app sets none.
|
|
32
|
+
static let defaultTimeout: TimeInterval = 5
|
|
33
|
+
|
|
34
|
+
private let filesUrl: URL
|
|
35
|
+
private let headers: [String: String]
|
|
36
|
+
private let timeout: TimeInterval
|
|
37
|
+
private let session: URLSession
|
|
38
|
+
|
|
39
|
+
init(filesUrl: URL, headers: [String: String], timeout: TimeInterval = defaultTimeout, session: URLSession = .shared) {
|
|
40
|
+
self.filesUrl = filesUrl
|
|
41
|
+
self.headers = headers
|
|
42
|
+
self.timeout = timeout
|
|
43
|
+
self.session = session
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func resolve(paths: [String]) throws -> [String: ResolvedArtifactFile] {
|
|
47
|
+
var request = URLRequest(url: filesUrl, timeoutInterval: timeout)
|
|
48
|
+
request.httpMethod = "POST"
|
|
49
|
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
50
|
+
for (name, value) in headers {
|
|
51
|
+
request.setValue(value, forHTTPHeaderField: name)
|
|
52
|
+
}
|
|
53
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
54
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: ["paths": paths])
|
|
55
|
+
|
|
56
|
+
let finished = DispatchSemaphore(value: 0)
|
|
57
|
+
var result: Result<Data, Error> = .failure(ArtifactFileResolverError.invalidResponse)
|
|
58
|
+
let task = session.dataTask(with: request) { data, response, error in
|
|
59
|
+
defer { finished.signal() }
|
|
60
|
+
if let error {
|
|
61
|
+
result = .failure(error)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
guard let httpResponse = response as? HTTPURLResponse else { return }
|
|
65
|
+
guard (200..<300).contains(httpResponse.statusCode) else {
|
|
66
|
+
result = .failure(DownloadError.httpStatus(httpResponse.statusCode))
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
result = .success(data ?? Data())
|
|
70
|
+
}
|
|
71
|
+
task.resume()
|
|
72
|
+
finished.wait()
|
|
73
|
+
|
|
74
|
+
guard let json = try JSONSerialization.jsonObject(with: result.get()) as? [String: Any],
|
|
75
|
+
let files = json["files"] as? [String: [String: Any]] else {
|
|
76
|
+
throw ArtifactFileResolverError.invalidResponse
|
|
77
|
+
}
|
|
78
|
+
return try paths.reduce(into: [:]) { resolved, path in
|
|
79
|
+
guard let file = files[path],
|
|
80
|
+
let urlString = file["url"] as? String,
|
|
81
|
+
let url = URL(string: urlString) else {
|
|
82
|
+
throw ArtifactFileResolverError.invalidResponse
|
|
83
|
+
}
|
|
84
|
+
resolved[path] = ResolvedArtifactFile(url: url, compression: file["compression"] as? String)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -285,7 +285,7 @@ public protocol BundleStorageService {
|
|
|
285
285
|
func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection
|
|
286
286
|
|
|
287
287
|
// Bundle update
|
|
288
|
-
func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL?, assets: [String: ChangedAssetDescriptor], progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void)
|
|
288
|
+
func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL?, assets: [String: ChangedAssetDescriptor], fileResolver: ArtifactFileResolver?, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void)
|
|
289
289
|
func stageReleaseSelection(_ selection: PersistedSelection) -> Bool
|
|
290
290
|
func acceptReleaseCatalog(catalogId: String, scopeKey: String, generation: Int64, catalogHash: String, channel: String, selectionContextHash: String) -> Bool
|
|
291
291
|
func getActiveUpdateState() -> [String: Any]
|
|
@@ -336,6 +336,12 @@ public extension BundleStorageService {
|
|
|
336
336
|
|
|
337
337
|
class BundleFileStorageService: BundleStorageService {
|
|
338
338
|
private static let maximumSafeByteSize: Int64 = 9_007_199_254_740_991
|
|
339
|
+
/// Files downloaded at once when the archive is not used.
|
|
340
|
+
static let parallelFileDownloads = 16
|
|
341
|
+
/// What one request costs beyond the first `parallelFileDownloads`, in
|
|
342
|
+
/// bytes: each waits a round trip for a free slot, about 100 ms, which at
|
|
343
|
+
/// 5 MB/s over 16 slots is the time of 32 KiB.
|
|
344
|
+
private static let requestOverheadByteSize: Int64 = 32 * 1024
|
|
339
345
|
|
|
340
346
|
private struct ActiveBundleMetadataSnapshot {
|
|
341
347
|
let activeBundleId: String
|
|
@@ -1168,7 +1174,15 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
1168
1174
|
guard !overheadUnderflowed,
|
|
1169
1175
|
tarOverhead >= 0 else { return false }
|
|
1170
1176
|
|
|
1171
|
-
|
|
1177
|
+
// Many small files cost more in requests than in bytes.
|
|
1178
|
+
let requestOverhead = Int64(max(0, downloads.count - Self.parallelFileDownloads))
|
|
1179
|
+
* Self.requestOverheadByteSize
|
|
1180
|
+
let (individualCost, costOverflowed) = individualLowerBound
|
|
1181
|
+
.addingReportingOverflow(requestOverhead)
|
|
1182
|
+
guard !costOverflowed,
|
|
1183
|
+
individualCost <= Self.maximumSafeByteSize else { return false }
|
|
1184
|
+
|
|
1185
|
+
if archive.downloadByteSize <= individualCost {
|
|
1172
1186
|
return true
|
|
1173
1187
|
}
|
|
1174
1188
|
|
|
@@ -1176,7 +1190,7 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
1176
1190
|
!hasOfferedPatch else {
|
|
1177
1191
|
return false
|
|
1178
1192
|
}
|
|
1179
|
-
let (archiveBound, boundOverflowed) =
|
|
1193
|
+
let (archiveBound, boundOverflowed) = individualCost
|
|
1180
1194
|
.addingReportingOverflow(tarOverhead)
|
|
1181
1195
|
guard !boundOverflowed,
|
|
1182
1196
|
archiveBound <= Self.maximumSafeByteSize else { return false }
|
|
@@ -2015,7 +2029,7 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2015
2029
|
* @param progressHandler Callback for download and extraction progress (0.0 to 1.0)
|
|
2016
2030
|
* @param completion Callback with result of the operation
|
|
2017
2031
|
*/
|
|
2018
|
-
func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL? = nil, assets: [String: ChangedAssetDescriptor], progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void) {
|
|
2032
|
+
func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL? = nil, assets: [String: ChangedAssetDescriptor], fileResolver: ArtifactFileResolver? = nil, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void) {
|
|
2019
2033
|
// Check if bundle is in crashed history
|
|
2020
2034
|
if getCrashHistory().contains(bundleId) {
|
|
2021
2035
|
NSLog("[BundleStorage] Bundle '\(bundleId)' is in crashed history, rejecting update")
|
|
@@ -2038,6 +2052,7 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2038
2052
|
manifestFileHash: manifestFileHash,
|
|
2039
2053
|
archiveUrl: archiveUrl,
|
|
2040
2054
|
changedAssets: assets,
|
|
2055
|
+
fileResolver: fileResolver,
|
|
2041
2056
|
storeDir: storeDir,
|
|
2042
2057
|
progressHandler: progressHandler,
|
|
2043
2058
|
completion: completion
|
|
@@ -2045,6 +2060,35 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2045
2060
|
}
|
|
2046
2061
|
}
|
|
2047
2062
|
|
|
2063
|
+
/// Asks `fileResolver` for the URLs of `downloads`, in requests of at
|
|
2064
|
+
/// most `artifactFilesRequestLimit` paths.
|
|
2065
|
+
private func resolveFileUrls(
|
|
2066
|
+
fileResolver: ArtifactFileResolver,
|
|
2067
|
+
downloads: inout [String: ChangedAssetDescriptor]
|
|
2068
|
+
) throws {
|
|
2069
|
+
let paths = downloads.keys.sorted()
|
|
2070
|
+
for start in stride(from: 0, to: paths.count, by: artifactFilesRequestLimit) {
|
|
2071
|
+
let chunk = Array(paths[start..<min(start + artifactFilesRequestLimit, paths.count)])
|
|
2072
|
+
let files: [String: ResolvedArtifactFile]
|
|
2073
|
+
do {
|
|
2074
|
+
files = try fileResolver.resolve(paths: chunk)
|
|
2075
|
+
} catch {
|
|
2076
|
+
throw BundleStorageError.downloadFailed(error)
|
|
2077
|
+
}
|
|
2078
|
+
for path in chunk {
|
|
2079
|
+
guard let file = files[path], let descriptor = downloads[path] else {
|
|
2080
|
+
throw BundleStorageError.invalidBundle
|
|
2081
|
+
}
|
|
2082
|
+
downloads[path] = ChangedAssetDescriptor(
|
|
2083
|
+
fileUrl: file.url,
|
|
2084
|
+
fileHash: descriptor.fileHash,
|
|
2085
|
+
fileCompression: file.compression,
|
|
2086
|
+
patch: descriptor.patch
|
|
2087
|
+
)
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2048
2092
|
/// How the per-file install produced one file.
|
|
2049
2093
|
private enum AssetInstallOutcome {
|
|
2050
2094
|
case patched
|
|
@@ -2490,6 +2534,7 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2490
2534
|
manifestFileHash: String,
|
|
2491
2535
|
archiveUrl: URL?,
|
|
2492
2536
|
changedAssets: [String: ChangedAssetDescriptor],
|
|
2537
|
+
fileResolver: ArtifactFileResolver?,
|
|
2493
2538
|
storeDir: String,
|
|
2494
2539
|
progressHandler: @escaping (UpdateProgressPayload) -> Void,
|
|
2495
2540
|
completion: @escaping (Result<UpdateDelivery, Error>) -> Void
|
|
@@ -2576,20 +2621,43 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2576
2621
|
else {
|
|
2577
2622
|
throw BundleStorageError.invalidBundle
|
|
2578
2623
|
}
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2624
|
+
if fileResolver == nil {
|
|
2625
|
+
// Legacy: artifact protocol v1, where every file comes with its URL.
|
|
2626
|
+
// Validate the entire wire contract even when every byte can be reused.
|
|
2627
|
+
guard Set(targetManifest.assets.keys) == Set(changedAssets.keys) else {
|
|
2628
|
+
throw BundleStorageError.invalidBundle
|
|
2629
|
+
}
|
|
2630
|
+
} else {
|
|
2631
|
+
// In v2, `assets` holds only the Hermes patch.
|
|
2632
|
+
guard Set(changedAssets.keys).isSubset(of: Set(targetManifest.assets.keys)) else {
|
|
2633
|
+
throw BundleStorageError.invalidBundle
|
|
2634
|
+
}
|
|
2582
2635
|
}
|
|
2583
2636
|
for (path, asset) in targetManifest.assets {
|
|
2584
2637
|
guard FileUtilities.normalizedRelativePath(from: path) == path,
|
|
2585
|
-
path != "manifest.json"
|
|
2586
|
-
let descriptor = changedAssets[path],
|
|
2587
|
-
descriptor.fileUrl != nil,
|
|
2588
|
-
descriptor.fileHash.caseInsensitiveCompare(asset.fileHash) == .orderedSame else {
|
|
2638
|
+
path != "manifest.json" else {
|
|
2589
2639
|
throw BundleStorageError.invalidBundle
|
|
2590
2640
|
}
|
|
2641
|
+
if fileResolver == nil {
|
|
2642
|
+
guard let descriptor = changedAssets[path],
|
|
2643
|
+
descriptor.fileUrl != nil,
|
|
2644
|
+
descriptor.fileHash.caseInsensitiveCompare(asset.fileHash) == .orderedSame else {
|
|
2645
|
+
throw BundleStorageError.invalidBundle
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2591
2648
|
_ = try FileUtilities.fileURL(for: path, destinationRoot: tmpDir)
|
|
2592
2649
|
}
|
|
2650
|
+
// In v2, the manifest names every file; URLs come once the install
|
|
2651
|
+
// knows which files it downloads.
|
|
2652
|
+
let plannedAssets: [String: ChangedAssetDescriptor] = fileResolver == nil
|
|
2653
|
+
? changedAssets
|
|
2654
|
+
: targetManifest.assets.reduce(into: [:]) { planned, entry in
|
|
2655
|
+
planned[entry.key] = ChangedAssetDescriptor(
|
|
2656
|
+
fileUrl: nil,
|
|
2657
|
+
fileHash: entry.value.fileHash,
|
|
2658
|
+
patch: changedAssets[entry.key]?.patch
|
|
2659
|
+
)
|
|
2660
|
+
}
|
|
2593
2661
|
|
|
2594
2662
|
// Downloads, patches, and extraction below classify their own errors.
|
|
2595
2663
|
failurePhase = .storage
|
|
@@ -2642,7 +2710,7 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2642
2710
|
}
|
|
2643
2711
|
continue
|
|
2644
2712
|
}
|
|
2645
|
-
downloads[assetPath] =
|
|
2713
|
+
downloads[assetPath] = plannedAssets[assetPath]
|
|
2646
2714
|
}
|
|
2647
2715
|
|
|
2648
2716
|
var archiveInstalled = false
|
|
@@ -2677,11 +2745,18 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2677
2745
|
}
|
|
2678
2746
|
|
|
2679
2747
|
if !archiveInstalled {
|
|
2748
|
+
if let fileResolver {
|
|
2749
|
+
failurePhase = .transfer
|
|
2750
|
+
failureResource = .file
|
|
2751
|
+
try resolveFileUrls(fileResolver: fileResolver, downloads: &downloads)
|
|
2752
|
+
failurePhase = .storage
|
|
2753
|
+
failureResource = nil
|
|
2754
|
+
}
|
|
2680
2755
|
diffFiles = createDiffProgressFiles(changedAssets: downloads)
|
|
2681
2756
|
emitDiffProgress(progressHandler: progressHandler, phase: "downloading", files: diffFiles)
|
|
2682
2757
|
let progressLock = NSLock()
|
|
2683
2758
|
let workers = OperationQueue()
|
|
2684
|
-
workers.maxConcurrentOperationCount =
|
|
2759
|
+
workers.maxConcurrentOperationCount = Self.parallelFileDownloads
|
|
2685
2760
|
var installError: Error?
|
|
2686
2761
|
var installOutcomes: [AssetInstallOutcome] = []
|
|
2687
2762
|
for progressFile in diffFiles {
|