@hot-updater/react-native 1.0.4 → 1.0.6

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 (68) 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 +46 -2
  11. package/android/src/oldarch/HotUpdaterModule.kt +46 -2
  12. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +66 -0
  13. package/ios/HotUpdater/Interface/HotUpdaterInterface.m +3 -0
  14. package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +87 -0
  15. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +88 -13
  16. package/ios/HotUpdater/Internal/HotUpdater.mm +41 -90
  17. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +25 -11
  18. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +26 -0
  19. package/ios/HotUpdater/Internal/HotUpdaterMinBundleId.m +10 -0
  20. package/ios/HotUpdater/Package.swift +88 -0
  21. package/ios/Plugins/HotUpdaterMinBundleIdPlugin/HotUpdaterMinBundleIdPlugin.swift +28 -0
  22. package/ios/scripts/generate-min-bundle-id.zsh +49 -0
  23. package/lib/commonjs/checkForUpdate.js +8 -0
  24. package/lib/commonjs/checkForUpdate.js.map +1 -1
  25. package/lib/commonjs/fetchUpdateResponse.js +5 -2
  26. package/lib/commonjs/fetchUpdateResponse.js.map +1 -1
  27. package/lib/commonjs/httpClient.js +64 -5
  28. package/lib/commonjs/httpClient.js.map +1 -1
  29. package/lib/commonjs/native.js +9 -0
  30. package/lib/commonjs/native.js.map +1 -1
  31. package/lib/commonjs/sdkVersion.js +1 -1
  32. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  33. package/lib/module/checkForUpdate.js +8 -0
  34. package/lib/module/checkForUpdate.js.map +1 -1
  35. package/lib/module/fetchUpdateResponse.js +4 -1
  36. package/lib/module/fetchUpdateResponse.js.map +1 -1
  37. package/lib/module/httpClient.js +67 -7
  38. package/lib/module/httpClient.js.map +1 -1
  39. package/lib/module/native.js +9 -0
  40. package/lib/module/native.js.map +1 -1
  41. package/lib/module/sdkVersion.js +1 -1
  42. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  43. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts +2 -0
  45. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/httpClient.d.ts +18 -2
  47. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  50. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +13 -1
  51. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  52. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  53. package/lib/typescript/module/fetchUpdateResponse.d.ts +2 -0
  54. package/lib/typescript/module/fetchUpdateResponse.d.ts.map +1 -1
  55. package/lib/typescript/module/httpClient.d.ts +18 -2
  56. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  57. package/lib/typescript/module/native.d.ts.map +1 -1
  58. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  59. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +13 -1
  60. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  61. package/package.json +5 -4
  62. package/react-native.config.js +7 -0
  63. package/src/checkForUpdate.ts +11 -0
  64. package/src/fetchUpdateResponse.ts +4 -1
  65. package/src/httpClient.ts +84 -9
  66. package/src/native.ts +7 -0
  67. package/src/sdkVersion.ts +1 -1
  68. package/src/specs/NativeHotUpdater.ts +13 -1
@@ -124,6 +124,7 @@ interface BundleStorageService {
124
124
  manifestFileHash: String,
125
125
  assets: Map<String, ChangedAssetDescriptor>,
126
126
  archiveUrl: String? = null,
127
+ fileResolver: ArtifactFileResolver? = null,
127
128
  progressCallback: (UpdateProgressPayload) -> Unit,
128
129
  ): UpdateBundleResult
129
130
 
@@ -239,6 +240,16 @@ class BundleFileStorageService(
239
240
  private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991L
240
241
  private val SHA256_PATTERN = Regex("^[0-9a-f]{64}$")
241
242
 
243
+ /** Files downloaded at once when the archive is not used. */
244
+ private const val PARALLEL_FILE_DOWNLOADS = 16
245
+
246
+ /**
247
+ * What one request costs beyond the first [PARALLEL_FILE_DOWNLOADS], in
248
+ * bytes: each waits a round trip for a free slot, about 100 ms, which
249
+ * at 5 MB/s over 16 slots is the time of 32 KiB.
250
+ */
251
+ private const val REQUEST_OVERHEAD_BYTES = 32L * 1024
252
+
242
253
  /** A patch produced a file that does not match the target, or none at all. */
243
254
  private val PATCH_FAILURE =
244
255
  UpdateFailure(UpdateFailureStage.INSTALL, UpdateFailureReason.PATCH, resource = UpdateFailureResource.PATCH)
@@ -718,10 +729,35 @@ class BundleFileStorageService(
718
729
  if (resolveBundleFile(bundleDir, bundleId) == null) return false
719
730
  return manifest.assets.all { (assetPath, asset) ->
720
731
  val file = RelativePathResolver.resolveInside(bundleDir, assetPath)
721
- file?.isFile == true && runCatching { verifyManifestAssetFileOrThrow(file, asset) }.isSuccess
732
+ file?.isFile == true &&
733
+ (
734
+ runCatching { verifyManifestAssetFileOrThrow(file, asset) }.isSuccess ||
735
+ isBuiltInEquivalent(assetPath, file, asset)
736
+ )
722
737
  }
723
738
  }
724
739
 
740
+ /**
741
+ * Whether [file] holds the packaged bytes of the built-in asset the native
742
+ * build recorded with the manifest's source hash, as AAPT2 re-encodes a
743
+ * PNG. Both come from the APK, never from app storage. The manifest's
744
+ * signature still covers that source hash.
745
+ */
746
+ private fun isBuiltInEquivalent(
747
+ assetPath: String,
748
+ file: File,
749
+ asset: ParsedManifestAsset,
750
+ ): Boolean {
751
+ val packagedHash = builtInAssetResolver.packagedHashIfSourceMatches(assetPath, asset.fileHash) ?: return false
752
+ return HashUtils.calculateSHA256(file).equals(packagedHash, ignoreCase = true) &&
753
+ runCatching {
754
+ if (SignatureVerifier.isSigningEnabled(context)) {
755
+ val signature = asset.signature ?: throw SignatureVerificationException.InvalidSignatureFormat()
756
+ SignatureVerifier.verifyHashSignature(context, asset.fileHash, signature)
757
+ }
758
+ }.isSuccess
759
+ }
760
+
725
761
  private fun recoverInterruptedPromotions(): Boolean {
726
762
  val bundleStoreDir = getBundleStoreDir()
727
763
  val backups =
@@ -1610,6 +1646,7 @@ class BundleFileStorageService(
1610
1646
  manifestFileHash: String,
1611
1647
  assets: Map<String, ChangedAssetDescriptor>,
1612
1648
  archiveUrl: String?,
1649
+ fileResolver: ArtifactFileResolver?,
1613
1650
  progressCallback: (UpdateProgressPayload) -> Unit,
1614
1651
  ): UpdateBundleResult =
1615
1652
  updateMutex.withLock {
@@ -1619,6 +1656,7 @@ class BundleFileStorageService(
1619
1656
  manifestFileHash = manifestFileHash,
1620
1657
  changedAssets = assets,
1621
1658
  archiveUrl = archiveUrl,
1659
+ fileResolver = fileResolver,
1622
1660
  progressCallback = progressCallback,
1623
1661
  )
1624
1662
  }
@@ -1629,6 +1667,7 @@ class BundleFileStorageService(
1629
1667
  manifestFileHash: String,
1630
1668
  changedAssets: Map<String, ChangedAssetDescriptor>,
1631
1669
  archiveUrl: String?,
1670
+ fileResolver: ArtifactFileResolver?,
1632
1671
  progressCallback: (UpdateProgressPayload) -> Unit,
1633
1672
  ): UpdateBundleResult {
1634
1673
  Log.d(
@@ -1660,6 +1699,7 @@ class BundleFileStorageService(
1660
1699
  manifestFileHash = manifestFileHash,
1661
1700
  changedAssets = changedAssets,
1662
1701
  archiveUrl = archiveUrl,
1702
+ fileResolver = fileResolver,
1663
1703
  bundleStoreDir = bundleStoreDir,
1664
1704
  finalBundleDir = finalBundleDir,
1665
1705
  progressCallback = progressCallback,
@@ -1710,6 +1750,35 @@ class BundleFileStorageService(
1710
1750
  }
1711
1751
  }
1712
1752
 
1753
+ /**
1754
+ * Asks [fileResolver] for the URLs of [downloads], in requests of at most
1755
+ * [ArtifactFileResolver.ARTIFACT_FILES_REQUEST_LIMIT] paths.
1756
+ */
1757
+ private suspend fun resolveFileUrls(
1758
+ fileResolver: ArtifactFileResolver,
1759
+ downloads: MutableMap<String, ChangedAssetDescriptor>,
1760
+ ) {
1761
+ for (paths in downloads.keys.sorted().chunked(ArtifactFileResolver.ARTIFACT_FILES_REQUEST_LIMIT)) {
1762
+ val files =
1763
+ try {
1764
+ fileResolver.resolve(paths)
1765
+ } catch (error: CancellationException) {
1766
+ throw error
1767
+ } catch (error: Exception) {
1768
+ throw downloadError(error)
1769
+ }
1770
+ for (path in paths) {
1771
+ val file = files[path] ?: throw HotUpdaterException.invalidBundle()
1772
+ try {
1773
+ URL(file.url)
1774
+ } catch (error: java.net.MalformedURLException) {
1775
+ throw HotUpdaterException.invalidFileUrl(path, file.url, UpdateFailureResource.FILE)
1776
+ }
1777
+ downloads[path] = downloads.getValue(path).copy(fileUrl = file.url, fileCompression = file.compression)
1778
+ }
1779
+ }
1780
+ }
1781
+
1713
1782
  /** The rejection for a download that returned [error]. */
1714
1783
  private fun downloadError(error: Exception): HotUpdaterException =
1715
1784
  if (error is IncompleteDownloadException) {
@@ -1945,10 +2014,15 @@ class BundleFileStorageService(
1945
2014
  }
1946
2015
  if (archive.tarByteSize < logicalBytes) return null
1947
2016
  val tarFramingBytes = archive.tarByteSize - logicalBytes
1948
- if (individualBytes > MAX_SAFE_INTEGER - tarFramingBytes) return null
1949
- val framedByteLimit = individualBytes + tarFramingBytes
1950
-
1951
- if (archive.downloadByteSize <= individualBytes) return archive
2017
+ // Many small files cost more in requests than in bytes.
2018
+ val requestOverheadBytes =
2019
+ (downloads.size - PARALLEL_FILE_DOWNLOADS).coerceAtLeast(0) * REQUEST_OVERHEAD_BYTES
2020
+ if (individualBytes > MAX_SAFE_INTEGER - requestOverheadBytes) return null
2021
+ val individualCost = individualBytes + requestOverheadBytes
2022
+ if (individualCost > MAX_SAFE_INTEGER - tarFramingBytes) return null
2023
+ val framedByteLimit = individualCost + tarFramingBytes
2024
+
2025
+ if (archive.downloadByteSize <= individualCost) return archive
1952
2026
  if (downloads.size != manifest.assets.size || downloads.values.any { it.patch != null }) return null
1953
2027
  return archive.takeIf { it.downloadByteSize <= framedByteLimit }
1954
2028
  }
@@ -2074,6 +2148,7 @@ class BundleFileStorageService(
2074
2148
  manifestFileHash: String,
2075
2149
  changedAssets: Map<String, ChangedAssetDescriptor>,
2076
2150
  archiveUrl: String?,
2151
+ fileResolver: ArtifactFileResolver?,
2077
2152
  bundleStoreDir: File,
2078
2153
  finalBundleDir: File,
2079
2154
  progressCallback: (UpdateProgressPayload) -> Unit,
@@ -2135,14 +2210,20 @@ class BundleFileStorageService(
2135
2210
  if (manifest.bundleId != bundleId) {
2136
2211
  throw HotUpdaterException.invalidBundle()
2137
2212
  }
2138
- // Reuse never bypasses the complete artifact contract.
2139
- if (manifest.assets.keys != changedAssets.keys) throw HotUpdaterException.invalidBundle()
2213
+ if (fileResolver == null) {
2214
+ // Legacy: artifact protocol v1, where every file comes with its URL.
2215
+ // Reuse never bypasses the complete artifact contract.
2216
+ if (manifest.assets.keys != changedAssets.keys) throw HotUpdaterException.invalidBundle()
2217
+ } else if (!manifest.assets.keys.containsAll(changedAssets.keys)) {
2218
+ // In v2, `assets` holds only the Hermes patch.
2219
+ throw HotUpdaterException.invalidBundle()
2220
+ }
2140
2221
  manifest.assets.forEach { (path, asset) ->
2141
- val descriptor = changedAssets.getValue(path)
2222
+ val descriptor = changedAssets[path]
2142
2223
  if (RelativePathResolver.normalizeRelativePath(path) != path ||
2143
2224
  path == "manifest.json" ||
2144
- descriptor.fileUrl.isNullOrBlank() ||
2145
- !descriptor.fileHash.equals(asset.fileHash, ignoreCase = true) ||
2225
+ (fileResolver == null && descriptor?.fileUrl.isNullOrBlank()) ||
2226
+ (fileResolver == null && !descriptor!!.fileHash.equals(asset.fileHash, ignoreCase = true)) ||
2146
2227
  RelativePathResolver.resolveInside(tmpDir, path) == null
2147
2228
  ) {
2148
2229
  throw HotUpdaterException.invalidBundle()
@@ -2150,6 +2231,20 @@ class BundleFileStorageService(
2150
2231
  }
2151
2232
  manifest
2152
2233
  }
2234
+ // In v2, the manifest names every file; URLs come once the install
2235
+ // knows which files it downloads.
2236
+ val plannedAssets =
2237
+ if (fileResolver == null) {
2238
+ changedAssets
2239
+ } else {
2240
+ targetManifest.assets.mapValues { (path, asset) ->
2241
+ ChangedAssetDescriptor(
2242
+ fileUrl = null,
2243
+ fileHash = asset.fileHash,
2244
+ patch = changedAssets[path]?.patch,
2245
+ )
2246
+ }
2247
+ }
2153
2248
  updateStep(UpdateFailureStage.INSTALL, resource = null) {
2154
2249
  if (!tmpDir.isDirectory && !tmpDir.mkdirs()) throw HotUpdaterException.directoryCreationFailed()
2155
2250
  pruneStagingDirectory(tmpDir, targetManifest.assets.keys)
@@ -2194,7 +2289,12 @@ class BundleFileStorageService(
2194
2289
  verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
2195
2290
  return@forEachIndexed
2196
2291
  }
2197
- downloads[assetPath] = changedAssets.getValue(assetPath)
2292
+ val packagedHash = builtInAssetResolver.copyIfSourceMatches(assetPath, expectedAsset.fileHash, targetFile)
2293
+ if (packagedHash != null && isBuiltInEquivalent(assetPath, targetFile, expectedAsset)) {
2294
+ return@forEachIndexed
2295
+ }
2296
+ if (packagedHash != null) targetFile.delete()
2297
+ downloads[assetPath] = plannedAssets.getValue(assetPath)
2198
2298
  }
2199
2299
  downloads
2200
2300
  }
@@ -2222,11 +2322,16 @@ class BundleFileStorageService(
2222
2322
  diffFiles = archiveProgress
2223
2323
  emptyList()
2224
2324
  } else {
2325
+ if (fileResolver != null) {
2326
+ updateStep(UpdateFailureStage.DOWNLOAD, UpdateFailureResource.FILE) {
2327
+ resolveFileUrls(fileResolver, downloads)
2328
+ }
2329
+ }
2225
2330
  diffFiles = createDiffProgressFiles(downloads)
2226
2331
  emitDiffProgress(progressCallback, "downloading", diffFiles)
2227
2332
  val progressLock = Any()
2228
2333
  coroutineScope {
2229
- val permits = Semaphore(4)
2334
+ val permits = Semaphore(PARALLEL_FILE_DOWNLOADS)
2230
2335
  diffFiles
2231
2336
  .toList()
2232
2337
  .map { progressFile ->
@@ -162,65 +162,16 @@ class HotUpdaterImpl {
162
162
  ) ?: DEFAULT_CHANNEL
163
163
 
164
164
  /**
165
- * Get minimum bundle ID string
166
- * @return The minimum bundle ID string
165
+ * The bundle ID of the bundle this build ships (MIN_BUNDLE_ID),
166
+ * written by the generateHotUpdaterMinBundleId build task
167
167
  */
168
168
  fun getMinBundleId(): String {
169
169
  if (BuildConfig.DEBUG) {
170
170
  return "00000000-0000-0000-0000-000000000000"
171
171
  }
172
- return BuildConfig.MIN_BUNDLE_ID.takeIf { it != "null" } ?: generateMinBundleIdFromBuildTimestamp()
172
+ return HotUpdaterMinBundleId.get()
173
173
  }
174
174
 
175
- /**
176
- * Generates a bundle ID based on build timestamp
177
- * @return The generated minimum bundle ID string
178
- */
179
- private fun generateMinBundleIdFromBuildTimestamp(): String =
180
- try {
181
- val buildTimestampMs = BuildConfig.BUILD_TIMESTAMP
182
- val bytes =
183
- ByteArray(16).apply {
184
- this[0] = ((buildTimestampMs shr 40) and 0xFF).toByte()
185
- this[1] = ((buildTimestampMs shr 32) and 0xFF).toByte()
186
- this[2] = ((buildTimestampMs shr 24) and 0xFF).toByte()
187
- this[3] = ((buildTimestampMs shr 16) and 0xFF).toByte()
188
- this[4] = ((buildTimestampMs shr 8) and 0xFF).toByte()
189
- this[5] = (buildTimestampMs and 0xFF).toByte()
190
- this[6] = 0x70.toByte()
191
- this[7] = 0x00.toByte()
192
- this[8] = 0x80.toByte()
193
- this[9] = 0x00.toByte()
194
- this[10] = 0x00.toByte()
195
- this[11] = 0x00.toByte()
196
- this[12] = 0x00.toByte()
197
- this[13] = 0x00.toByte()
198
- this[14] = 0x00.toByte()
199
- this[15] = 0x00.toByte()
200
- }
201
- String.format(
202
- "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
203
- bytes[0].toInt() and 0xFF,
204
- bytes[1].toInt() and 0xFF,
205
- bytes[2].toInt() and 0xFF,
206
- bytes[3].toInt() and 0xFF,
207
- bytes[4].toInt() and 0xFF,
208
- bytes[5].toInt() and 0xFF,
209
- bytes[6].toInt() and 0xFF,
210
- bytes[7].toInt() and 0xFF,
211
- bytes[8].toInt() and 0xFF,
212
- bytes[9].toInt() and 0xFF,
213
- bytes[10].toInt() and 0xFF,
214
- bytes[11].toInt() and 0xFF,
215
- bytes[12].toInt() and 0xFF,
216
- bytes[13].toInt() and 0xFF,
217
- bytes[14].toInt() and 0xFF,
218
- bytes[15].toInt() and 0xFF,
219
- )
220
- } catch (e: Exception) {
221
- "00000000-0000-0000-0000-000000000000"
222
- }
223
-
224
175
  /**
225
176
  * Gets the current fingerprint hash
226
177
  * @param context Application context
@@ -290,6 +241,7 @@ class HotUpdaterImpl {
290
241
  channel: String?,
291
242
  selection: PersistedSelection? = null,
292
243
  archiveUrl: String? = null,
244
+ fileResolver: ArtifactFileResolver? = null,
293
245
  progressCallback: (UpdateProgressPayload) -> Unit,
294
246
  ): UpdateBundleResult {
295
247
  if (selection != null && !bundleStorage.stageReleaseSelection(selection)) {
@@ -302,6 +254,7 @@ class HotUpdaterImpl {
302
254
  manifestFileHash,
303
255
  assets,
304
256
  archiveUrl,
257
+ fileResolver,
305
258
  progressCallback,
306
259
  )
307
260
 
@@ -59,7 +59,8 @@ class HotUpdaterModule internal constructor(
59
59
  while (iterator.hasNextKey()) {
60
60
  val assetPath = iterator.nextKey()
61
61
  val assetMap = changedAssetsMap.getMap(assetPath) ?: continue
62
- val assetHash = assetMap.getString("fileHash") ?: continue
62
+ // In artifact protocol v2, an entry holds only the Hermes patch.
63
+ val assetHash = if (assetMap.hasKey("fileHash")) assetMap.getString("fileHash") else null
63
64
  val patchMap = assetMap.getMap("patch")
64
65
  val patch =
65
66
  if (patchMap != null) {
@@ -107,10 +108,13 @@ class HotUpdaterModule internal constructor(
107
108
  if (assetUrl == null && patch == null) {
108
109
  continue
109
110
  }
111
+ if (assetHash == null && assetUrl != null) {
112
+ continue
113
+ }
110
114
  parsedAssets[assetPath] =
111
115
  ChangedAssetDescriptor(
112
116
  fileUrl = assetUrl,
113
- fileHash = assetHash,
117
+ fileHash = assetHash ?: "",
114
118
  fileCompression = fileCompression,
115
119
  patch = patch,
116
120
  )
@@ -119,6 +123,21 @@ class HotUpdaterModule internal constructor(
119
123
  return parsedAssets
120
124
  }
121
125
 
126
+ /** The update check's request headers, which the artifact files request repeats. */
127
+ private fun parseRequestHeaders(params: ReadableMap): Map<String, String> {
128
+ if (!params.hasKey("requestHeaders") || params.isNull("requestHeaders")) return emptyMap()
129
+ val headers = params.getMap("requestHeaders") ?: return emptyMap()
130
+ val parsed = linkedMapOf<String, String>()
131
+ val iterator = headers.keySetIterator()
132
+ while (iterator.hasNextKey()) {
133
+ val name = iterator.nextKey()
134
+ if (headers.getType(name) == com.facebook.react.bridge.ReadableType.String) {
135
+ headers.getString(name)?.let { parsed[name] = it }
136
+ }
137
+ }
138
+ return parsed
139
+ }
140
+
122
141
  private fun parseSelection(params: ReadableMap): PersistedSelection? {
123
142
  if (!params.hasKey("selection") || params.isNull("selection")) return null
124
143
  val selection = params.getMap("selection") ?: return null
@@ -211,6 +230,30 @@ class HotUpdaterModule internal constructor(
211
230
  }
212
231
  }
213
232
  val selection = parseSelection(params)
233
+ val filesUrl =
234
+ if (params.hasKey("filesUrl") && !params.isNull("filesUrl")) {
235
+ params.getString("filesUrl")
236
+ } else {
237
+ null
238
+ }
239
+ if (filesUrl != null) {
240
+ try {
241
+ java.net.URL(filesUrl)
242
+ } catch (e: java.net.MalformedURLException) {
243
+ promise.rejectUpdateBundle(
244
+ HotUpdaterException.invalidFileUrl("filesUrl", filesUrl, UpdateFailureResource.FILE),
245
+ )
246
+ return@launch
247
+ }
248
+ }
249
+ val requestTimeoutMs =
250
+ if (params.hasKey("requestTimeout") && !params.isNull("requestTimeout")) {
251
+ params.getDouble("requestTimeout").toLong().takeIf { it > 0 }
252
+ } else {
253
+ null
254
+ } ?: HttpArtifactFileResolver.DEFAULT_TIMEOUT_MS
255
+ val fileResolver =
256
+ filesUrl?.let { HttpArtifactFileResolver(it, parseRequestHeaders(params), requestTimeoutMs) }
214
257
 
215
258
  val impl = getInstance()
216
259
 
@@ -223,6 +266,7 @@ class HotUpdaterModule internal constructor(
223
266
  channel,
224
267
  selection,
225
268
  archiveUrl,
269
+ fileResolver,
226
270
  ) { progress ->
227
271
  // Post to Main thread for React Native event emission
228
272
  Handler(Looper.getMainLooper()).post {
@@ -59,7 +59,8 @@ class HotUpdaterModule internal constructor(
59
59
  while (iterator.hasNextKey()) {
60
60
  val assetPath = iterator.nextKey()
61
61
  val assetMap = changedAssetsMap.getMap(assetPath) ?: continue
62
- val assetHash = assetMap.getString("fileHash") ?: continue
62
+ // In artifact protocol v2, an entry holds only the Hermes patch.
63
+ val assetHash = if (assetMap.hasKey("fileHash")) assetMap.getString("fileHash") else null
63
64
  val patchMap = assetMap.getMap("patch")
64
65
  val patch =
65
66
  if (patchMap != null) {
@@ -107,10 +108,13 @@ class HotUpdaterModule internal constructor(
107
108
  if (assetUrl == null && patch == null) {
108
109
  continue
109
110
  }
111
+ if (assetHash == null && assetUrl != null) {
112
+ continue
113
+ }
110
114
  parsedAssets[assetPath] =
111
115
  ChangedAssetDescriptor(
112
116
  fileUrl = assetUrl,
113
- fileHash = assetHash,
117
+ fileHash = assetHash ?: "",
114
118
  fileCompression = fileCompression,
115
119
  patch = patch,
116
120
  )
@@ -119,6 +123,21 @@ class HotUpdaterModule internal constructor(
119
123
  return parsedAssets
120
124
  }
121
125
 
126
+ /** The update check's request headers, which the artifact files request repeats. */
127
+ private fun parseRequestHeaders(params: ReadableMap): Map<String, String> {
128
+ if (!params.hasKey("requestHeaders") || params.isNull("requestHeaders")) return emptyMap()
129
+ val headers = params.getMap("requestHeaders") ?: return emptyMap()
130
+ val parsed = linkedMapOf<String, String>()
131
+ val iterator = headers.keySetIterator()
132
+ while (iterator.hasNextKey()) {
133
+ val name = iterator.nextKey()
134
+ if (headers.getType(name) == com.facebook.react.bridge.ReadableType.String) {
135
+ headers.getString(name)?.let { parsed[name] = it }
136
+ }
137
+ }
138
+ return parsed
139
+ }
140
+
122
141
  private fun parseSelection(params: ReadableMap): PersistedSelection? {
123
142
  if (!params.hasKey("selection") || params.isNull("selection")) return null
124
143
  val selection = params.getMap("selection") ?: return null
@@ -215,6 +234,30 @@ class HotUpdaterModule internal constructor(
215
234
  }
216
235
  }
217
236
  val selection = parseSelection(params)
237
+ val filesUrl =
238
+ if (params.hasKey("filesUrl") && !params.isNull("filesUrl")) {
239
+ params.getString("filesUrl")
240
+ } else {
241
+ null
242
+ }
243
+ if (filesUrl != null) {
244
+ try {
245
+ java.net.URL(filesUrl)
246
+ } catch (e: java.net.MalformedURLException) {
247
+ promise.rejectUpdateBundle(
248
+ HotUpdaterException.invalidFileUrl("filesUrl", filesUrl, UpdateFailureResource.FILE),
249
+ )
250
+ return@launch
251
+ }
252
+ }
253
+ val requestTimeoutMs =
254
+ if (params.hasKey("requestTimeout") && !params.isNull("requestTimeout")) {
255
+ params.getDouble("requestTimeout").toLong().takeIf { it > 0 }
256
+ } else {
257
+ null
258
+ } ?: HttpArtifactFileResolver.DEFAULT_TIMEOUT_MS
259
+ val fileResolver =
260
+ filesUrl?.let { HttpArtifactFileResolver(it, parseRequestHeaders(params), requestTimeoutMs) }
218
261
 
219
262
  val impl = getInstance()
220
263
 
@@ -227,6 +270,7 @@ class HotUpdaterModule internal constructor(
227
270
  channel,
228
271
  selection,
229
272
  archiveUrl,
273
+ fileResolver,
230
274
  ) { progress ->
231
275
  // Post to Main thread for React Native event emission
232
276
  Handler(Looper.getMainLooper()).post {
@@ -0,0 +1,66 @@
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
+ - (BOOL)acceptReleaseCatalog:(nullable NSDictionary *)params;
25
+ - (NSDictionary<NSString *, id> *)getActiveUpdateState;
26
+ - (void)getReleaseCatalogCache:(NSString *)partition
27
+ resolver:(RCTPromiseResolveBlock)resolve
28
+ rejecter:(RCTPromiseRejectBlock)reject;
29
+ - (void)setReleaseCatalogCache:(NSString *)partition
30
+ payload:(NSString *)payload
31
+ resolver:(RCTPromiseResolveBlock)resolve
32
+ rejecter:(RCTPromiseRejectBlock)reject;
33
+ - (void)removeReleaseCatalogCache:(NSString *)partition
34
+ resolver:(RCTPromiseResolveBlock)resolve
35
+ rejecter:(RCTPromiseRejectBlock)reject;
36
+ - (BOOL)isReleaseSelectionCurrent:(nullable NSDictionary *)params;
37
+ - (void)commitReleaseSelection:(nullable NSDictionary *)params
38
+ resolver:(RCTPromiseResolveBlock)resolve
39
+ rejecter:(RCTPromiseRejectBlock)reject;
40
+ - (NSDictionary<NSString *, id> *)notifyAppReady;
41
+ - (NSArray<NSString *> *)getCrashHistory;
42
+ - (BOOL)clearCrashHistory;
43
+ - (NSString *)getInstallId;
44
+ - (nullable NSString *)getStorageItem:(NSString *)key;
45
+ - (void)setStorageItem:(NSString *)key value:(nullable NSString *)value;
46
+ - (nullable NSString *)getBaseURL;
47
+ - (nullable NSString *)getBundleId;
48
+ - (NSDictionary<NSString *, id> *)getManifest;
49
+ - (void)resetLaunchPreparation;
50
+ - (void)resetChannel:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject;
51
+ @end
52
+
53
+ @protocol HotUpdaterConfigInterface <NSObject>
54
+ - (void)configureWithFingerprintHash:(nullable NSString *)fingerprintHash
55
+ publicKey:(nullable NSString *)publicKey
56
+ channel:(nullable NSString *)channel
57
+ isolationKey:(nullable NSString *)isolationKey
58
+ NS_SWIFT_NAME(configureWithFingerprintHash(_:publicKey:channel:isolationKey:));
59
+ @end
60
+
61
+ // Implemented in HotUpdaterInterface.swift.
62
+ FOUNDATION_EXTERN id<HotUpdaterImplInterface> HotUpdaterMakeImpl(void);
63
+ FOUNDATION_EXTERN NSString *_Nullable HotUpdaterAppVersion(void);
64
+ FOUNDATION_EXTERN id<HotUpdaterConfigInterface> HotUpdaterSharedConfig(void);
65
+
66
+ 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
+ }