@hot-updater/react-native 0.25.0 → 0.25.2
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/LICENSE +12 -0
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +101 -18
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +7 -2
- package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +11 -38
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +104 -31
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +7 -2
- package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +24 -40
- package/package.json +6 -6
- package/plugin/build/withHotUpdater.js +60 -10
package/LICENSE
CHANGED
|
@@ -19,3 +19,15 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
19
19
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
20
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
21
|
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
ADDITIONAL DISCLAIMER
|
|
26
|
+
|
|
27
|
+
The authors of this software shall not be held responsible for any legal issues,
|
|
28
|
+
regulatory violations, policy violations, or disputes arising from the use,
|
|
29
|
+
distribution, or integration of this software.
|
|
30
|
+
|
|
31
|
+
Users are solely responsible for ensuring compliance with all applicable laws,
|
|
32
|
+
regulations, and third-party policies (including but not limited to app store
|
|
33
|
+
policies).
|
|
@@ -93,6 +93,14 @@ class BundleFileStorageService(
|
|
|
93
93
|
private const val TAG = "BundleStorage"
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
init {
|
|
97
|
+
// Ensure bundle store directory exists
|
|
98
|
+
getBundleStoreDir().mkdirs()
|
|
99
|
+
|
|
100
|
+
// Clean up old bundles if isolationKey format changed
|
|
101
|
+
checkAndCleanupIfIsolationKeyChanged()
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
// Session-only rollback tracking (in-memory)
|
|
97
105
|
private var sessionRollbackBundleId: String? = null
|
|
98
106
|
|
|
@@ -134,6 +142,67 @@ class BundleFileStorageService(
|
|
|
134
142
|
return regex.find(currentUrl)?.groupValues?.get(1)
|
|
135
143
|
}
|
|
136
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Checks if isolationKey has changed and cleans up old bundles if needed.
|
|
147
|
+
* This handles migration when isolationKey format changes.
|
|
148
|
+
*/
|
|
149
|
+
private fun checkAndCleanupIfIsolationKeyChanged() {
|
|
150
|
+
val metadataFile = getMetadataFile()
|
|
151
|
+
|
|
152
|
+
if (!metadataFile.exists()) {
|
|
153
|
+
// First launch - no cleanup needed
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
// Read metadata without validation to get stored isolationKey
|
|
159
|
+
val jsonString = metadataFile.readText()
|
|
160
|
+
val json = org.json.JSONObject(jsonString)
|
|
161
|
+
val storedIsolationKey = json.optString("isolationKey", null)
|
|
162
|
+
|
|
163
|
+
if (storedIsolationKey != null && storedIsolationKey != isolationKey) {
|
|
164
|
+
// isolationKey changed - migration needed
|
|
165
|
+
Log.d(TAG, "isolationKey changed: $storedIsolationKey -> $isolationKey")
|
|
166
|
+
Log.d(TAG, "Cleaning up old bundles for migration")
|
|
167
|
+
cleanupAllBundlesForMigration()
|
|
168
|
+
}
|
|
169
|
+
} catch (e: Exception) {
|
|
170
|
+
Log.e(TAG, "Error checking isolationKey: ${e.message}")
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Removes all bundle directories during migration.
|
|
176
|
+
* Called when isolationKey format changes.
|
|
177
|
+
*/
|
|
178
|
+
private fun cleanupAllBundlesForMigration() {
|
|
179
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
180
|
+
|
|
181
|
+
if (!bundleStoreDir.exists()) {
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
var cleanedCount = 0
|
|
187
|
+
bundleStoreDir.listFiles()?.forEach { file ->
|
|
188
|
+
if (file.isDirectory) {
|
|
189
|
+
try {
|
|
190
|
+
if (file.deleteRecursively()) {
|
|
191
|
+
cleanedCount++
|
|
192
|
+
Log.d(TAG, "Migration: removed old bundle ${file.name}")
|
|
193
|
+
}
|
|
194
|
+
} catch (e: Exception) {
|
|
195
|
+
Log.e(TAG, "Error removing bundle ${file.name}: ${e.message}")
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
Log.d(TAG, "Migration cleanup complete: removed $cleanedCount bundles")
|
|
201
|
+
} catch (e: Exception) {
|
|
202
|
+
Log.e(TAG, "Error during migration cleanup: ${e.message}")
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
137
206
|
// MARK: - State Machine
|
|
138
207
|
|
|
139
208
|
private fun isVerificationPending(metadata: BundleMetadata): Boolean = metadata.verificationPending && metadata.stagingBundleId != null
|
|
@@ -493,34 +562,48 @@ class BundleFileStorageService(
|
|
|
493
562
|
}
|
|
494
563
|
val tempBundleFile = File(tempDir, bundleFileName)
|
|
495
564
|
|
|
496
|
-
// Check file size before downloading
|
|
497
|
-
val fileSize = downloadService.getFileSize(downloadUrl)
|
|
498
|
-
if (fileSize > 0 && baseDir != null) {
|
|
499
|
-
// Check available disk space
|
|
500
|
-
val stat = StatFs(baseDir.absolutePath)
|
|
501
|
-
val availableBytes = stat.availableBlocksLong * stat.blockSizeLong
|
|
502
|
-
val requiredSpace = fileSize * 2 // ZIP + extracted files
|
|
503
|
-
|
|
504
|
-
Log.d("BundleStorage", "File size: $fileSize bytes, Available: $availableBytes bytes, Required: $requiredSpace bytes")
|
|
505
|
-
|
|
506
|
-
if (availableBytes < requiredSpace) {
|
|
507
|
-
Log.d("BundleStorage", "Insufficient disk space: need $requiredSpace bytes, available $availableBytes bytes")
|
|
508
|
-
throw HotUpdaterException.insufficientDiskSpace(requiredSpace, availableBytes)
|
|
509
|
-
}
|
|
510
|
-
} else {
|
|
511
|
-
Log.d("BundleStorage", "Unable to determine file size, proceeding with download")
|
|
512
|
-
}
|
|
513
|
-
|
|
514
565
|
// Download the file (0% - 80%)
|
|
566
|
+
// Disk space check will be performed in fileSizeCallback
|
|
567
|
+
var diskSpaceError: HotUpdaterException? = null
|
|
568
|
+
|
|
515
569
|
val downloadResult =
|
|
516
570
|
downloadService.downloadFile(
|
|
517
571
|
downloadUrl,
|
|
518
572
|
tempBundleFile,
|
|
573
|
+
fileSizeCallback = { fileSize ->
|
|
574
|
+
// Perform disk space check when file size is known
|
|
575
|
+
if (baseDir != null) {
|
|
576
|
+
val stat = StatFs(baseDir.absolutePath)
|
|
577
|
+
val availableBytes = stat.availableBlocksLong * stat.blockSizeLong
|
|
578
|
+
val requiredSpace = fileSize * 2 // ZIP + extracted files
|
|
579
|
+
|
|
580
|
+
Log.d(
|
|
581
|
+
"BundleStorage",
|
|
582
|
+
"File size: $fileSize bytes, Available: $availableBytes bytes, Required: $requiredSpace bytes",
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
if (availableBytes < requiredSpace) {
|
|
586
|
+
Log.d(
|
|
587
|
+
TAG,
|
|
588
|
+
"Insufficient disk space detected: need $requiredSpace bytes, available $availableBytes bytes",
|
|
589
|
+
)
|
|
590
|
+
// Store error to be thrown after download completes/cancels
|
|
591
|
+
diskSpaceError = HotUpdaterException.insufficientDiskSpace(requiredSpace, availableBytes)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
},
|
|
519
595
|
) { downloadProgress ->
|
|
520
596
|
// Map download progress to 0.0 - 0.8
|
|
521
597
|
progressCallback(downloadProgress * 0.8)
|
|
522
598
|
}
|
|
523
599
|
|
|
600
|
+
// Check for disk space error first before processing download result
|
|
601
|
+
diskSpaceError?.let {
|
|
602
|
+
Log.d(TAG, "Throwing disk space error")
|
|
603
|
+
tempDir.deleteRecursively()
|
|
604
|
+
throw it
|
|
605
|
+
}
|
|
606
|
+
|
|
524
607
|
when (downloadResult) {
|
|
525
608
|
is DownloadResult.Error -> {
|
|
526
609
|
Log.d("BundleStorage", "Download failed: ${downloadResult.exception.message}")
|
|
@@ -112,8 +112,13 @@ class HotUpdaterImpl {
|
|
|
112
112
|
val appVersion = getAppVersion(context) ?: "unknown"
|
|
113
113
|
val appChannel = getChannel(context)
|
|
114
114
|
|
|
115
|
-
//
|
|
116
|
-
val baseKey =
|
|
115
|
+
// Include both fingerprint hash and app version for complete isolation
|
|
116
|
+
val baseKey =
|
|
117
|
+
if (!fingerprintHash.isNullOrEmpty()) {
|
|
118
|
+
"${fingerprintHash}_$appVersion"
|
|
119
|
+
} else {
|
|
120
|
+
appVersion
|
|
121
|
+
}
|
|
117
122
|
|
|
118
123
|
return "HotUpdaterPrefs_${baseKey}_$appChannel"
|
|
119
124
|
}
|
|
@@ -45,23 +45,18 @@ sealed class DownloadResult {
|
|
|
45
45
|
* Interface for download operations
|
|
46
46
|
*/
|
|
47
47
|
interface DownloadService {
|
|
48
|
-
/**
|
|
49
|
-
* Gets the file size from the URL without downloading
|
|
50
|
-
* @param fileUrl The URL to check
|
|
51
|
-
* @return File size in bytes, or -1 if unavailable
|
|
52
|
-
*/
|
|
53
|
-
suspend fun getFileSize(fileUrl: URL): Long
|
|
54
|
-
|
|
55
48
|
/**
|
|
56
49
|
* Downloads a file from a URL
|
|
57
50
|
* @param fileUrl The URL to download from
|
|
58
51
|
* @param destination The local file to save to
|
|
52
|
+
* @param fileSizeCallback Optional callback called when file size is known
|
|
59
53
|
* @param progressCallback Callback for download progress updates
|
|
60
54
|
* @return Result indicating success or failure
|
|
61
55
|
*/
|
|
62
56
|
suspend fun downloadFile(
|
|
63
57
|
fileUrl: URL,
|
|
64
58
|
destination: File,
|
|
59
|
+
fileSizeCallback: ((Long) -> Unit)? = null,
|
|
65
60
|
progressCallback: (Double) -> Unit,
|
|
66
61
|
): DownloadResult
|
|
67
62
|
}
|
|
@@ -128,34 +123,10 @@ class OkHttpDownloadService : DownloadService {
|
|
|
128
123
|
.writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
|
129
124
|
.build()
|
|
130
125
|
|
|
131
|
-
override suspend fun getFileSize(fileUrl: URL): Long =
|
|
132
|
-
withContext(Dispatchers.IO) {
|
|
133
|
-
try {
|
|
134
|
-
val request =
|
|
135
|
-
Request
|
|
136
|
-
.Builder()
|
|
137
|
-
.url(fileUrl)
|
|
138
|
-
.head()
|
|
139
|
-
.build()
|
|
140
|
-
client.newCall(request).execute().use { response ->
|
|
141
|
-
if (response.isSuccessful) {
|
|
142
|
-
val contentLength = response.header("Content-Length")?.toLongOrNull() ?: -1L
|
|
143
|
-
Log.d(TAG, "File size from HEAD request: $contentLength bytes")
|
|
144
|
-
contentLength
|
|
145
|
-
} else {
|
|
146
|
-
Log.d(TAG, "HEAD request failed: ${response.code}")
|
|
147
|
-
-1L
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
} catch (e: Exception) {
|
|
151
|
-
Log.d(TAG, "Failed to get file size: ${e.message}")
|
|
152
|
-
-1L
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
126
|
override suspend fun downloadFile(
|
|
157
127
|
fileUrl: URL,
|
|
158
128
|
destination: File,
|
|
129
|
+
fileSizeCallback: ((Long) -> Unit)?,
|
|
159
130
|
progressCallback: (Double) -> Unit,
|
|
160
131
|
): DownloadResult =
|
|
161
132
|
withContext(Dispatchers.IO) {
|
|
@@ -167,6 +138,7 @@ class OkHttpDownloadService : DownloadService {
|
|
|
167
138
|
return@withContext attemptDownload(
|
|
168
139
|
fileUrl,
|
|
169
140
|
destination,
|
|
141
|
+
fileSizeCallback,
|
|
170
142
|
progressCallback,
|
|
171
143
|
)
|
|
172
144
|
} catch (e: Exception) {
|
|
@@ -193,6 +165,7 @@ class OkHttpDownloadService : DownloadService {
|
|
|
193
165
|
private suspend fun attemptDownload(
|
|
194
166
|
fileUrl: URL,
|
|
195
167
|
destination: File,
|
|
168
|
+
fileSizeCallback: ((Long) -> Unit)?,
|
|
196
169
|
progressCallback: (Double) -> Unit,
|
|
197
170
|
): DownloadResult =
|
|
198
171
|
withContext(Dispatchers.IO) {
|
|
@@ -231,14 +204,14 @@ class OkHttpDownloadService : DownloadService {
|
|
|
231
204
|
// Get total file size
|
|
232
205
|
val totalSize = body.contentLength()
|
|
233
206
|
|
|
234
|
-
if (totalSize
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
207
|
+
if (totalSize > 0) {
|
|
208
|
+
// Notify file size to caller for disk space check
|
|
209
|
+
fileSizeCallback?.invoke(totalSize)
|
|
210
|
+
Log.d(TAG, "Starting download: $totalSize bytes")
|
|
211
|
+
} else {
|
|
212
|
+
Log.d(TAG, "Content-Length not available ($totalSize), proceeding without disk space check")
|
|
238
213
|
}
|
|
239
214
|
|
|
240
|
-
Log.d(TAG, "Starting download: $totalSize bytes")
|
|
241
|
-
|
|
242
215
|
try {
|
|
243
216
|
// Wrap response body with progress tracking
|
|
244
217
|
val progressBody =
|
|
@@ -153,6 +153,12 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
153
153
|
self.fileOperationQueue = DispatchQueue(label: "com.hotupdater.fileoperations",
|
|
154
154
|
qos: .utility,
|
|
155
155
|
attributes: .concurrent)
|
|
156
|
+
|
|
157
|
+
// Ensure bundle store directory exists
|
|
158
|
+
_ = bundleStoreDir()
|
|
159
|
+
|
|
160
|
+
// Clean up old bundles if isolationKey format changed
|
|
161
|
+
checkAndCleanupIfIsolationKeyChanged()
|
|
156
162
|
}
|
|
157
163
|
|
|
158
164
|
// MARK: - Metadata File Paths
|
|
@@ -189,6 +195,73 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
189
195
|
return updatedMetadata.save(to: file)
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Checks if isolationKey has changed and cleans up old bundles if needed.
|
|
200
|
+
* This handles migration when isolationKey format changes.
|
|
201
|
+
*/
|
|
202
|
+
private func checkAndCleanupIfIsolationKeyChanged() {
|
|
203
|
+
guard let metadataURL = metadataFileURL() else {
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let metadataPath = metadataURL.path
|
|
208
|
+
|
|
209
|
+
guard fileSystem.fileExists(atPath: metadataPath) else {
|
|
210
|
+
// First launch - no cleanup needed
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
do {
|
|
215
|
+
let jsonString = try String(contentsOf: metadataURL, encoding: .utf8)
|
|
216
|
+
if let jsonData = jsonString.data(using: .utf8),
|
|
217
|
+
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
|
|
218
|
+
let storedKey = json["isolationKey"] as? String {
|
|
219
|
+
|
|
220
|
+
if storedKey != isolationKey {
|
|
221
|
+
NSLog("[BundleStorage] isolationKey changed: \(storedKey) -> \(isolationKey)")
|
|
222
|
+
NSLog("[BundleStorage] Cleaning up old bundles for migration")
|
|
223
|
+
cleanupAllBundlesForMigration()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
NSLog("[BundleStorage] Error checking isolationKey: \(error.localizedDescription)")
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Removes all bundle directories during migration.
|
|
233
|
+
* Called when isolationKey format changes.
|
|
234
|
+
*/
|
|
235
|
+
private func cleanupAllBundlesForMigration() {
|
|
236
|
+
guard case .success(let storeDir) = bundleStoreDir() else {
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
do {
|
|
241
|
+
let contents = try fileSystem.contentsOfDirectory(atPath: storeDir)
|
|
242
|
+
var cleanedCount = 0
|
|
243
|
+
|
|
244
|
+
for item in contents {
|
|
245
|
+
let fullPath = (storeDir as NSString).appendingPathComponent(item)
|
|
246
|
+
|
|
247
|
+
// Skip metadata files
|
|
248
|
+
if item == "metadata.json" || item == "crashed-history.json" {
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if fileSystem.fileExists(atPath: fullPath) {
|
|
253
|
+
try fileSystem.removeItem(atPath: fullPath)
|
|
254
|
+
cleanedCount += 1
|
|
255
|
+
NSLog("[BundleStorage] Migration: removed old bundle \(item)")
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
NSLog("[BundleStorage] Migration cleanup complete: removed \(cleanedCount) bundles")
|
|
260
|
+
} catch {
|
|
261
|
+
NSLog("[BundleStorage] Error during migration cleanup: \(error.localizedDescription)")
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
192
265
|
// MARK: - Crashed History Operations
|
|
193
266
|
|
|
194
267
|
private func loadCrashedHistory() -> CrashedHistory {
|
|
@@ -728,47 +801,43 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
728
801
|
let bundleFileName = fileUrl.lastPathComponent.isEmpty ? "bundle.zip" : fileUrl.lastPathComponent
|
|
729
802
|
let tempBundleFile = (tempDirectory as NSString).appendingPathComponent(bundleFileName)
|
|
730
803
|
|
|
731
|
-
NSLog("[BundleStorage]
|
|
804
|
+
NSLog("[BundleStorage] Starting download from \(fileUrl)")
|
|
805
|
+
|
|
806
|
+
// Download with integrated disk space check
|
|
807
|
+
var diskSpaceError: BundleStorageError? = nil
|
|
808
|
+
|
|
809
|
+
_ = self.downloadService.downloadFile(
|
|
810
|
+
from: fileUrl,
|
|
811
|
+
to: tempBundleFile,
|
|
812
|
+
fileSizeHandler: { [weak self] fileSize in
|
|
813
|
+
// This will be called when Content-Length is received
|
|
814
|
+
guard let self = self else { return }
|
|
732
815
|
|
|
733
|
-
|
|
734
|
-
self.downloadService.getFileSize(from: fileUrl) { [weak self] sizeResult in
|
|
735
|
-
guard let self = self else { return }
|
|
816
|
+
NSLog("[BundleStorage] File size received: \(fileSize) bytes")
|
|
736
817
|
|
|
737
|
-
if case .success(let fileSize) = sizeResult {
|
|
738
818
|
// Check available disk space
|
|
739
819
|
do {
|
|
740
820
|
let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
|
|
741
821
|
if let freeSize = attributes[.systemFreeSize] as? Int64 {
|
|
742
822
|
let requiredSpace = fileSize * 2 // ZIP + extracted files
|
|
743
823
|
|
|
744
|
-
NSLog("[BundleStorage]
|
|
824
|
+
NSLog("[BundleStorage] Available: \(freeSize) bytes, Required: \(requiredSpace) bytes")
|
|
745
825
|
|
|
746
826
|
if freeSize < requiredSpace {
|
|
747
|
-
NSLog("[BundleStorage] Insufficient disk space")
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
return
|
|
827
|
+
NSLog("[BundleStorage] Insufficient disk space detected: need \(requiredSpace) bytes, available \(freeSize) bytes")
|
|
828
|
+
// Store error to be returned in completion handler
|
|
829
|
+
diskSpaceError = .insufficientDiskSpace
|
|
751
830
|
}
|
|
752
831
|
}
|
|
753
832
|
} catch {
|
|
754
833
|
NSLog("[BundleStorage] Failed to check disk space: \(error.localizedDescription)")
|
|
755
|
-
// Continue with download despite disk check failure
|
|
756
834
|
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
// 6) DownloadService handles its own threading for the download task.
|
|
764
|
-
// The completion handler for downloadService.downloadFile is then dispatched to fileOperationQueue.
|
|
765
|
-
let task = self.downloadService.downloadFile(from: fileUrl,
|
|
766
|
-
to: tempBundleFile,
|
|
767
|
-
progressHandler: { downloadProgress in
|
|
768
|
-
// Map download progress to 0.0 - 0.8
|
|
769
|
-
progressHandler(downloadProgress * 0.8)
|
|
770
|
-
},
|
|
771
|
-
completion: { [weak self] result in
|
|
835
|
+
},
|
|
836
|
+
progressHandler: { downloadProgress in
|
|
837
|
+
// Map download progress to 0.0 - 0.8
|
|
838
|
+
progressHandler(downloadProgress * 0.8)
|
|
839
|
+
},
|
|
840
|
+
completion: { [weak self] result in
|
|
772
841
|
guard let self = self else {
|
|
773
842
|
let error = NSError(domain: "HotUpdaterError", code: 998,
|
|
774
843
|
userInfo: [NSLocalizedDescriptionKey: "Self deallocated during download"])
|
|
@@ -776,6 +845,14 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
776
845
|
return
|
|
777
846
|
}
|
|
778
847
|
|
|
848
|
+
// Check for disk space error first before processing download result
|
|
849
|
+
if let diskError = diskSpaceError {
|
|
850
|
+
NSLog("[BundleStorage] Throwing disk space error")
|
|
851
|
+
self.cleanupTemporaryFiles([tempDirectory])
|
|
852
|
+
completion(.failure(diskError))
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
|
|
779
856
|
// Dispatch the processing of the downloaded file to the file operation queue
|
|
780
857
|
let workItem = DispatchWorkItem {
|
|
781
858
|
switch result {
|
|
@@ -802,12 +879,8 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
802
879
|
}
|
|
803
880
|
}
|
|
804
881
|
self.fileOperationQueue.async(execute: workItem)
|
|
805
|
-
})
|
|
806
|
-
|
|
807
|
-
if let task = task {
|
|
808
|
-
self.activeTasks.append(task) // Manage active tasks
|
|
809
|
-
}
|
|
810
882
|
}
|
|
883
|
+
)
|
|
811
884
|
}
|
|
812
885
|
|
|
813
886
|
/**
|
|
@@ -73,8 +73,13 @@ import React
|
|
|
73
73
|
let appVersion = self.appVersion ?? "unknown"
|
|
74
74
|
let appChannel = self.appChannel
|
|
75
75
|
|
|
76
|
-
//
|
|
77
|
-
let baseKey
|
|
76
|
+
// Include both fingerprint hash and app version for complete isolation
|
|
77
|
+
let baseKey: String
|
|
78
|
+
if let hash = fingerprintHash, !hash.isEmpty {
|
|
79
|
+
baseKey = "\(hash)_\(appVersion)"
|
|
80
|
+
} else {
|
|
81
|
+
baseKey = appVersion
|
|
82
|
+
}
|
|
78
83
|
|
|
79
84
|
return "hotupdater_\(baseKey)_\(appChannel)_"
|
|
80
85
|
}
|
|
@@ -4,22 +4,16 @@ import UIKit
|
|
|
4
4
|
#endif
|
|
5
5
|
|
|
6
6
|
protocol DownloadService {
|
|
7
|
-
/**
|
|
8
|
-
* Gets the file size from the URL without downloading.
|
|
9
|
-
* @param url The URL to check
|
|
10
|
-
* @param completion Callback with file size or error
|
|
11
|
-
*/
|
|
12
|
-
func getFileSize(from url: URL, completion: @escaping (Result<Int64, Error>) -> Void)
|
|
13
|
-
|
|
14
7
|
/**
|
|
15
8
|
* Downloads a file from a URL.
|
|
16
9
|
* @param url The URL to download from
|
|
17
10
|
* @param destination The local path to save to
|
|
11
|
+
* @param fileSizeHandler Optional callback called when file size is known
|
|
18
12
|
* @param progressHandler Callback for download progress updates
|
|
19
13
|
* @param completion Callback with downloaded file URL or error
|
|
20
14
|
* @return The download task (optional)
|
|
21
15
|
*/
|
|
22
|
-
func downloadFile(from url: URL, to destination: String, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) -> URLSessionDownloadTask?
|
|
16
|
+
func downloadFile(from url: URL, to destination: String, fileSizeHandler: ((Int64) -> Void)?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) -> URLSessionDownloadTask?
|
|
23
17
|
}
|
|
24
18
|
|
|
25
19
|
|
|
@@ -42,6 +36,7 @@ class URLSessionDownloadService: NSObject, DownloadService {
|
|
|
42
36
|
private var progressHandlers: [URLSessionTask: (Double) -> Void] = [:]
|
|
43
37
|
private var completionHandlers: [URLSessionTask: (Result<URL, Error>) -> Void] = [:]
|
|
44
38
|
private var destinations: [URLSessionTask: String] = [:]
|
|
39
|
+
private var fileSizeHandlers: [URLSessionTask: (Int64) -> Void] = [:]
|
|
45
40
|
private var taskStates: [Int: TaskState] = [:]
|
|
46
41
|
|
|
47
42
|
override init() {
|
|
@@ -94,35 +89,7 @@ class URLSessionDownloadService: NSObject, DownloadService {
|
|
|
94
89
|
}
|
|
95
90
|
}
|
|
96
91
|
|
|
97
|
-
func
|
|
98
|
-
var request = URLRequest(url: url)
|
|
99
|
-
request.httpMethod = "HEAD"
|
|
100
|
-
|
|
101
|
-
let task = session.dataTask(with: request) { _, response, error in
|
|
102
|
-
if let error = error {
|
|
103
|
-
NSLog("[DownloadService] HEAD request failed: \(error.localizedDescription)")
|
|
104
|
-
completion(.failure(error))
|
|
105
|
-
return
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
guard let httpResponse = response as? HTTPURLResponse else {
|
|
109
|
-
completion(.failure(DownloadError.invalidContentLength))
|
|
110
|
-
return
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
let contentLength = httpResponse.expectedContentLength
|
|
114
|
-
if contentLength > 0 {
|
|
115
|
-
NSLog("[DownloadService] File size from HEAD request: \(contentLength) bytes")
|
|
116
|
-
completion(.success(contentLength))
|
|
117
|
-
} else {
|
|
118
|
-
NSLog("[DownloadService] Invalid content length: \(contentLength)")
|
|
119
|
-
completion(.failure(DownloadError.invalidContentLength))
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
task.resume()
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
func downloadFile(from url: URL, to destination: String, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) -> URLSessionDownloadTask? {
|
|
92
|
+
func downloadFile(from url: URL, to destination: String, fileSizeHandler: ((Int64) -> Void)?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) -> URLSessionDownloadTask? {
|
|
126
93
|
// Determine if we should use background session
|
|
127
94
|
#if !os(macOS)
|
|
128
95
|
let appState = UIApplication.shared.applicationState
|
|
@@ -141,6 +108,9 @@ class URLSessionDownloadService: NSObject, DownloadService {
|
|
|
141
108
|
progressHandlers[task] = progressHandler
|
|
142
109
|
completionHandlers[task] = completion
|
|
143
110
|
destinations[task] = destination
|
|
111
|
+
if let handler = fileSizeHandler {
|
|
112
|
+
fileSizeHandlers[task] = handler
|
|
113
|
+
}
|
|
144
114
|
|
|
145
115
|
// Extract bundleId from destination path (e.g., "bundle-store/{bundleId}/bundle.zip")
|
|
146
116
|
let bundleId = (destination as NSString).pathComponents
|
|
@@ -170,6 +140,7 @@ extension URLSessionDownloadService: URLSessionDownloadDelegate {
|
|
|
170
140
|
progressHandlers.removeValue(forKey: downloadTask)
|
|
171
141
|
completionHandlers.removeValue(forKey: downloadTask)
|
|
172
142
|
destinations.removeValue(forKey: downloadTask)
|
|
143
|
+
fileSizeHandlers.removeValue(forKey: downloadTask)
|
|
173
144
|
removeTaskState(downloadTask.taskIdentifier)
|
|
174
145
|
|
|
175
146
|
// 다운로드 완료 알림
|
|
@@ -223,6 +194,7 @@ extension URLSessionDownloadService: URLSessionDownloadDelegate {
|
|
|
223
194
|
progressHandlers.removeValue(forKey: task)
|
|
224
195
|
completionHandlers.removeValue(forKey: task)
|
|
225
196
|
destinations.removeValue(forKey: task)
|
|
197
|
+
fileSizeHandlers.removeValue(forKey: task)
|
|
226
198
|
removeTaskState(task.taskIdentifier)
|
|
227
199
|
|
|
228
200
|
NotificationCenter.default.post(name: .downloadDidFinish, object: task)
|
|
@@ -235,11 +207,23 @@ extension URLSessionDownloadService: URLSessionDownloadDelegate {
|
|
|
235
207
|
|
|
236
208
|
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
|
237
209
|
let progressHandler = progressHandlers[downloadTask]
|
|
238
|
-
|
|
210
|
+
|
|
211
|
+
// Call file size handler on first callback when size is known
|
|
212
|
+
if totalBytesWritten == bytesWritten && bytesWritten > 0 {
|
|
213
|
+
if let fileSizeHandler = fileSizeHandlers[downloadTask] {
|
|
214
|
+
if totalBytesExpectedToWrite > 0 {
|
|
215
|
+
fileSizeHandler(totalBytesExpectedToWrite)
|
|
216
|
+
} else {
|
|
217
|
+
NSLog("[DownloadService] Content-Length not available, proceeding without disk space check")
|
|
218
|
+
}
|
|
219
|
+
fileSizeHandlers.removeValue(forKey: downloadTask)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
239
223
|
if totalBytesExpectedToWrite > 0 {
|
|
240
224
|
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
|
241
225
|
progressHandler?(progress)
|
|
242
|
-
|
|
226
|
+
|
|
243
227
|
let progressInfo: [String: Any] = [
|
|
244
228
|
"progress": progress,
|
|
245
229
|
"totalBytesReceived": totalBytesWritten,
|
|
@@ -248,7 +232,7 @@ extension URLSessionDownloadService: URLSessionDownloadDelegate {
|
|
|
248
232
|
NotificationCenter.default.post(name: .downloadProgressUpdate, object: downloadTask, userInfo: progressInfo)
|
|
249
233
|
} else {
|
|
250
234
|
progressHandler?(0)
|
|
251
|
-
|
|
235
|
+
|
|
252
236
|
NotificationCenter.default.post(name: .downloadProgressUpdate, object: downloadTask, userInfo: ["progress": 0.0, "totalBytesReceived": 0, "totalBytesExpected": 0])
|
|
253
237
|
}
|
|
254
238
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/react-native",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.2",
|
|
4
4
|
"description": "React Native OTA solution for self-hosted",
|
|
5
5
|
"main": "lib/commonjs/index",
|
|
6
6
|
"module": "lib/module/index",
|
|
@@ -120,14 +120,14 @@
|
|
|
120
120
|
"react-native": "0.79.1",
|
|
121
121
|
"react-native-builder-bob": "^0.40.10",
|
|
122
122
|
"typescript": "^5.8.3",
|
|
123
|
-
"hot-updater": "0.25.
|
|
123
|
+
"hot-updater": "0.25.2"
|
|
124
124
|
},
|
|
125
125
|
"dependencies": {
|
|
126
126
|
"use-sync-external-store": "1.5.0",
|
|
127
|
-
"@hot-updater/cli-tools": "0.25.
|
|
128
|
-
"@hot-updater/core": "0.25.
|
|
129
|
-
"@hot-updater/js": "0.25.
|
|
130
|
-
"@hot-updater/plugin-core": "0.25.
|
|
127
|
+
"@hot-updater/cli-tools": "0.25.2",
|
|
128
|
+
"@hot-updater/core": "0.25.2",
|
|
129
|
+
"@hot-updater/js": "0.25.2",
|
|
130
|
+
"@hot-updater/plugin-core": "0.25.2"
|
|
131
131
|
},
|
|
132
132
|
"scripts": {
|
|
133
133
|
"build": "bob build && tsc -p plugin/tsconfig.build.json",
|
|
@@ -39,6 +39,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
39
39
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
40
40
|
};
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
var promises_1 = require("node:fs/promises");
|
|
42
43
|
var cli_tools_1 = require("@hot-updater/cli-tools");
|
|
43
44
|
var config_plugins_1 = require("expo/config-plugins");
|
|
44
45
|
var hot_updater_1 = require("hot-updater");
|
|
@@ -64,32 +65,81 @@ var getFingerprint = function () { return __awaiter(void 0, void 0, void 0, func
|
|
|
64
65
|
});
|
|
65
66
|
}); };
|
|
66
67
|
/**
|
|
67
|
-
* Extract public key
|
|
68
|
+
* Extract public key for embedding in native configs.
|
|
69
|
+
* Supports multiple sources with priority order:
|
|
70
|
+
* 1. HOT_UPDATER_PRIVATE_KEY environment variable
|
|
71
|
+
* 2. Private key file (extract public key)
|
|
72
|
+
* 3. Public key file (derived from privateKeyPath)
|
|
73
|
+
* 4. Skip with warning (graceful fallback)
|
|
68
74
|
*/
|
|
69
75
|
var getPublicKeyFromConfig = function (signingConfig) { return __awaiter(void 0, void 0, void 0, function () {
|
|
70
|
-
var privateKeyPath, privateKeyPEM, publicKeyPEM,
|
|
76
|
+
var envPrivateKey, publicKeyPEM, privateKeyPath, publicKeyPath, privateKeyPEM, publicKeyPEM, _privateKeyError_1, publicKeyPEM, _publicKeyError_1;
|
|
71
77
|
return __generator(this, function (_a) {
|
|
72
78
|
switch (_a.label) {
|
|
73
79
|
case 0:
|
|
74
|
-
|
|
80
|
+
// If signing not enabled, no public key needed
|
|
81
|
+
if (!(signingConfig === null || signingConfig === void 0 ? void 0 : signingConfig.enabled)) {
|
|
82
|
+
return [2 /*return*/, null];
|
|
83
|
+
}
|
|
84
|
+
envPrivateKey = process.env.HOT_UPDATER_PRIVATE_KEY;
|
|
85
|
+
if (envPrivateKey) {
|
|
86
|
+
try {
|
|
87
|
+
publicKeyPEM = (0, hot_updater_1.getPublicKeyFromPrivate)(envPrivateKey);
|
|
88
|
+
console.log("[hot-updater] Using public key extracted from HOT_UPDATER_PRIVATE_KEY environment variable");
|
|
89
|
+
return [2 /*return*/, publicKeyPEM.trim()];
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
console.warn("[hot-updater] WARNING: Failed to extract public key from HOT_UPDATER_PRIVATE_KEY:\n" +
|
|
93
|
+
"".concat(error instanceof Error ? error.message : String(error), "\n"));
|
|
94
|
+
// Continue to try other methods
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// If no privateKeyPath configured, can't proceed with file-based methods
|
|
98
|
+
if (!signingConfig.privateKeyPath) {
|
|
99
|
+
console.warn("[hot-updater] WARNING: signing.enabled is true but no privateKeyPath configured.\n" +
|
|
100
|
+
"Public key will not be embedded. Set HOT_UPDATER_PRIVATE_KEY environment variable or configure privateKeyPath.");
|
|
75
101
|
return [2 /*return*/, null];
|
|
76
102
|
}
|
|
77
|
-
_a.label = 1;
|
|
78
|
-
case 1:
|
|
79
|
-
_a.trys.push([1, 3, , 4]);
|
|
80
103
|
privateKeyPath = path_1.default.isAbsolute(signingConfig.privateKeyPath)
|
|
81
104
|
? signingConfig.privateKeyPath
|
|
82
105
|
: path_1.default.resolve(process.cwd(), signingConfig.privateKeyPath);
|
|
106
|
+
publicKeyPath = privateKeyPath.replace(/private-key\.pem$/, "public-key.pem");
|
|
107
|
+
_a.label = 1;
|
|
108
|
+
case 1:
|
|
109
|
+
_a.trys.push([1, 3, , 8]);
|
|
83
110
|
return [4 /*yield*/, (0, hot_updater_1.loadPrivateKey)(privateKeyPath)];
|
|
84
111
|
case 2:
|
|
85
112
|
privateKeyPEM = _a.sent();
|
|
86
113
|
publicKeyPEM = (0, hot_updater_1.getPublicKeyFromPrivate)(privateKeyPEM);
|
|
114
|
+
console.log("[hot-updater] Extracted public key from ".concat(privateKeyPath));
|
|
87
115
|
return [2 /*return*/, publicKeyPEM.trim()];
|
|
88
116
|
case 3:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
117
|
+
_privateKeyError_1 = _a.sent();
|
|
118
|
+
_a.label = 4;
|
|
119
|
+
case 4:
|
|
120
|
+
_a.trys.push([4, 6, , 7]);
|
|
121
|
+
return [4 /*yield*/, (0, promises_1.readFile)(publicKeyPath, "utf-8")];
|
|
122
|
+
case 5:
|
|
123
|
+
publicKeyPEM = _a.sent();
|
|
124
|
+
console.log("[hot-updater] Using public key from ".concat(publicKeyPath));
|
|
125
|
+
return [2 /*return*/, publicKeyPEM.trim()];
|
|
126
|
+
case 6:
|
|
127
|
+
_publicKeyError_1 = _a.sent();
|
|
128
|
+
// Priority 4: All sources failed - throw error
|
|
129
|
+
throw new Error("[hot-updater] Failed to load public key for bundle signing.\n\n" +
|
|
130
|
+
"Signing is enabled (signing.enabled: true) but no public key sources found.\n\n" +
|
|
131
|
+
"For EAS builds, use EAS Secrets:\n" +
|
|
132
|
+
' eas env:create --name HOT_UPDATER_PRIVATE_KEY --value "$(cat keys/private-key.pem)"\n\n' +
|
|
133
|
+
"Or add to eas.json:\n" +
|
|
134
|
+
' "env": { "HOT_UPDATER_PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----\\n..." }\n\n' +
|
|
135
|
+
"For local development:\n" +
|
|
136
|
+
" npx hot-updater keys generate\n\n" +
|
|
137
|
+
"Searched locations:\n" +
|
|
138
|
+
" - HOT_UPDATER_PRIVATE_KEY environment variable\n" +
|
|
139
|
+
" - Private key file: ".concat(privateKeyPath, "\n") +
|
|
140
|
+
" - Public key file: ".concat(publicKeyPath, "\n"));
|
|
141
|
+
case 7: return [3 /*break*/, 8];
|
|
142
|
+
case 8: return [2 /*return*/];
|
|
93
143
|
}
|
|
94
144
|
});
|
|
95
145
|
}); };
|