@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.
Files changed (73) hide show
  1. package/HotUpdater.podspec +24 -3
  2. package/Package.swift +100 -0
  3. package/android/build.gradle +3 -4
  4. package/android/hot-updater-builtin-assets.gradle +67 -0
  5. package/android/hot-updater-min-bundle-id.gradle +69 -0
  6. package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +85 -0
  7. package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +116 -11
  8. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +117 -12
  9. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +5 -52
  10. package/android/src/newarch/HotUpdaterModule.kt +129 -34
  11. package/android/src/oldarch/HotUpdaterModule.kt +130 -34
  12. package/android/src/oldarch/HotUpdaterSpec.kt +5 -0
  13. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +69 -0
  14. package/ios/HotUpdater/Interface/HotUpdaterInterface.m +3 -0
  15. package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +87 -0
  16. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +88 -13
  17. package/ios/HotUpdater/Internal/HotUpdater.mm +90 -90
  18. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +154 -52
  19. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +26 -0
  20. package/ios/HotUpdater/Internal/HotUpdaterMinBundleId.m +10 -0
  21. package/ios/HotUpdater/Package.swift +88 -0
  22. package/ios/Plugins/HotUpdaterMinBundleIdPlugin/HotUpdaterMinBundleIdPlugin.swift +28 -0
  23. package/ios/scripts/generate-min-bundle-id.zsh +49 -0
  24. package/lib/commonjs/checkForUpdate.js +17 -2
  25. package/lib/commonjs/checkForUpdate.js.map +1 -1
  26. package/lib/commonjs/fetchUpdateResponse.js +5 -2
  27. package/lib/commonjs/fetchUpdateResponse.js.map +1 -1
  28. package/lib/commonjs/httpClient.js +58 -5
  29. package/lib/commonjs/httpClient.js.map +1 -1
  30. package/lib/commonjs/native.js +42 -7
  31. package/lib/commonjs/native.js.map +1 -1
  32. package/lib/commonjs/sdkVersion.js +1 -1
  33. package/lib/commonjs/specs/NativeHotUpdater.js +5 -0
  34. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  35. package/lib/module/checkForUpdate.js +18 -3
  36. package/lib/module/checkForUpdate.js.map +1 -1
  37. package/lib/module/fetchUpdateResponse.js +4 -1
  38. package/lib/module/fetchUpdateResponse.js.map +1 -1
  39. package/lib/module/httpClient.js +61 -7
  40. package/lib/module/httpClient.js.map +1 -1
  41. package/lib/module/native.js +41 -7
  42. package/lib/module/native.js.map +1 -1
  43. package/lib/module/sdkVersion.js +1 -1
  44. package/lib/module/specs/NativeHotUpdater.js +7 -0
  45. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  46. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts +2 -0
  48. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/httpClient.d.ts +26 -2
  50. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/native.d.ts +17 -3
  52. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  54. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +37 -1
  55. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  56. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  57. package/lib/typescript/module/fetchUpdateResponse.d.ts +2 -0
  58. package/lib/typescript/module/fetchUpdateResponse.d.ts.map +1 -1
  59. package/lib/typescript/module/httpClient.d.ts +26 -2
  60. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  61. package/lib/typescript/module/native.d.ts +17 -3
  62. package/lib/typescript/module/native.d.ts.map +1 -1
  63. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  64. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +37 -1
  65. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  66. package/package.json +5 -4
  67. package/react-native.config.js +7 -0
  68. package/src/checkForUpdate.ts +19 -3
  69. package/src/fetchUpdateResponse.ts +4 -1
  70. package/src/httpClient.ts +88 -9
  71. package/src/native.ts +53 -9
  72. package/src/sdkVersion.ts +1 -1
  73. 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
- private fun parseAssets(params: ReadableMap): Map<String, ChangedAssetDescriptor>? {
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") ?: continue
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 assets = parseAssets(params)
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,3 @@
1
+ // Xcode links every Swift Package Manager target's object file, so the
2
+ // header-only HotUpdaterInterface target needs one translation unit.
3
+ #import "HotUpdaterInterface.h"
@@ -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
- if archive.downloadByteSize <= individualLowerBound {
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) = individualLowerBound
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
- // Validate the entire wire contract even when every byte can be reused.
2580
- guard Set(targetManifest.assets.keys) == Set(changedAssets.keys) else {
2581
- throw BundleStorageError.invalidBundle
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] = changedAssets[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 = 4
2759
+ workers.maxConcurrentOperationCount = Self.parallelFileDownloads
2685
2760
  var installError: Error?
2686
2761
  var installOutcomes: [AssetInstallOutcome] = []
2687
2762
  for progressFile in diffFiles {