@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 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
- // Use fingerprint if available, otherwise use app version
116
- val baseKey = if (!fingerprintHash.isNullOrEmpty()) fingerprintHash else appVersion
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 <= 0) {
235
- Log.d(TAG, "Invalid content length: $totalSize")
236
- response.close()
237
- return@withContext DownloadResult.Error(Exception("Invalid content length: $totalSize"))
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] Checking file size and disk space...")
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
- // 5) Check file size and disk space before download
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] File size: \(fileSize) bytes, Available: \(freeSize) bytes, Required: \(requiredSpace) bytes")
824
+ NSLog("[BundleStorage] Available: \(freeSize) bytes, Required: \(requiredSpace) bytes")
745
825
 
746
826
  if freeSize < requiredSpace {
747
- NSLog("[BundleStorage] Insufficient disk space")
748
- self.cleanupTemporaryFiles([tempDirectory])
749
- completion(.failure(BundleStorageError.insufficientDiskSpace))
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
- } else {
758
- NSLog("[BundleStorage] Unable to determine file size, proceeding with download")
759
- }
760
-
761
- NSLog("[BundleStorage] Starting download from \(fileUrl)")
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
- // Use fingerprint if available, otherwise use app version
77
- let baseKey = (fingerprintHash != nil && !fingerprintHash!.isEmpty) ? fingerprintHash! : appVersion
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 getFileSize(from url: URL, completion: @escaping (Result<Int64, Error>) -> Void) {
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.0",
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.0"
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.0",
128
- "@hot-updater/core": "0.25.0",
129
- "@hot-updater/js": "0.25.0",
130
- "@hot-updater/plugin-core": "0.25.0"
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 from private key in signing config
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, error_1;
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
- if (!(signingConfig === null || signingConfig === void 0 ? void 0 : signingConfig.enabled) || !(signingConfig === null || signingConfig === void 0 ? void 0 : signingConfig.privateKeyPath)) {
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
- error_1 = _a.sent();
90
- throw new Error("[hot-updater] Failed to extract public key: ".concat(error_1 instanceof Error ? error_1.message : String(error_1), "\n") +
91
- "Run 'npx hot-updater keys generate' to create signing keys");
92
- case 4: return [2 /*return*/];
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
  }); };