@hot-updater/react-native 1.0.5 → 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.
- 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 +46 -2
- package/android/src/oldarch/HotUpdaterModule.kt +46 -2
- package/ios/HotUpdater/Interface/HotUpdaterInterface.h +66 -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 +41 -90
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +25 -11
- 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 +8 -0
- 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 +64 -5
- package/lib/commonjs/httpClient.js.map +1 -1
- package/lib/commonjs/native.js +9 -0
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/checkForUpdate.js +8 -0
- 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 +67 -7
- package/lib/module/httpClient.js.map +1 -1
- package/lib/module/native.js +9 -0
- package/lib/module/native.js.map +1 -1
- package/lib/module/sdkVersion.js +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/fetchUpdateResponse.d.ts +2 -0
- package/lib/typescript/commonjs/fetchUpdateResponse.d.ts.map +1 -1
- package/lib/typescript/commonjs/httpClient.d.ts +18 -2
- package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
- 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 +13 -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 +18 -2
- package/lib/typescript/module/httpClient.d.ts.map +1 -1
- 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 +13 -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 +11 -0
- package/src/fetchUpdateResponse.ts +4 -1
- package/src/httpClient.ts +84 -9
- package/src/native.ts +7 -0
- package/src/sdkVersion.ts +1 -1
- 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 &&
|
|
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
|
-
|
|
1949
|
-
val
|
|
1950
|
-
|
|
1951
|
-
if (
|
|
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
|
-
|
|
2139
|
-
|
|
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
|
|
2222
|
+
val descriptor = changedAssets[path]
|
|
2142
2223
|
if (RelativePathResolver.normalizeRelativePath(path) != path ||
|
|
2143
2224
|
path == "manifest.json" ||
|
|
2144
|
-
descriptor
|
|
2145
|
-
!descriptor
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
166
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|