@hot-updater/react-native 0.29.2 → 0.29.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/HotUpdater.podspec +0 -4
  2. package/android/src/oldarch/HotUpdaterModule.kt +12 -4
  3. package/android/src/oldarch/HotUpdaterSpec.kt +3 -5
  4. package/ios/HotUpdater/Internal/ArchiveExtractionUtilities.swift +178 -0
  5. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +82 -47
  6. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +359 -0
  7. package/ios/HotUpdater/Internal/TarArchiveExtractor.swift +386 -0
  8. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +7 -213
  9. package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +8 -126
  10. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +13 -2
  11. package/ios/HotUpdater/Internal/ZipArchiveExtractor.swift +462 -0
  12. package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +4 -113
  13. package/lib/commonjs/DefaultResolver.js.map +1 -1
  14. package/lib/commonjs/checkForUpdate.js.map +1 -1
  15. package/lib/commonjs/index.js +0 -7
  16. package/lib/commonjs/index.js.map +1 -1
  17. package/lib/commonjs/native.js.map +1 -1
  18. package/lib/commonjs/native.spec.js.map +1 -1
  19. package/lib/commonjs/store.js.map +1 -1
  20. package/lib/commonjs/types.js.map +1 -1
  21. package/lib/commonjs/wrap.js.map +1 -1
  22. package/lib/module/DefaultResolver.js.map +1 -1
  23. package/lib/module/checkForUpdate.js.map +1 -1
  24. package/lib/module/index.js +0 -7
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/native.js.map +1 -1
  27. package/lib/module/native.spec.js.map +1 -1
  28. package/lib/module/store.js.map +1 -1
  29. package/lib/module/types.js.map +1 -1
  30. package/lib/module/wrap.js.map +1 -1
  31. package/lib/typescript/commonjs/DefaultResolver.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  38. package/lib/typescript/module/DefaultResolver.d.ts.map +1 -1
  39. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  40. package/lib/typescript/module/index.d.ts.map +1 -1
  41. package/lib/typescript/module/native.d.ts.map +1 -1
  42. package/lib/typescript/module/store.d.ts.map +1 -1
  43. package/lib/typescript/module/types.d.ts.map +1 -1
  44. package/lib/typescript/module/wrap.d.ts.map +1 -1
  45. package/package.json +9 -9
  46. package/plugin/build/transformers.js +83 -97
  47. package/plugin/build/withHotUpdater.js +159 -239
  48. package/src/DefaultResolver.ts +1 -0
  49. package/src/checkForUpdate.ts +1 -0
  50. package/src/index.ts +0 -7
  51. package/src/native.spec.ts +4 -6
  52. package/src/native.ts +1 -0
  53. package/src/store.ts +1 -0
  54. package/src/types.ts +1 -0
  55. package/src/wrap.tsx +1 -0
@@ -23,10 +23,6 @@ Pod::Spec.new do |s|
23
23
  "OTHER_SWIFT_FLAGS" => "-enable-experimental-feature AccessLevelOnImport"
24
24
  }
25
25
 
26
- # SWCompression dependency for ZIP/TAR/GZIP/Brotli extraction support
27
- # Native Compression framework is used for GZIP and Brotli decompression
28
- s.dependency "SWCompression", "~> 4.8.0"
29
-
30
26
  # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0.
31
27
  # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79.
32
28
  if respond_to?(:install_modules_dependencies, true)
@@ -7,7 +7,6 @@ import com.facebook.react.bridge.Promise
7
7
  import com.facebook.react.bridge.ReactApplicationContext
8
8
  import com.facebook.react.bridge.ReactMethod
9
9
  import com.facebook.react.bridge.ReadableMap
10
- import com.facebook.react.bridge.WritableNativeArray
11
10
  import com.facebook.react.bridge.WritableNativeMap
12
11
  import com.facebook.react.modules.core.DeviceEventManagerModule
13
12
  import kotlinx.coroutines.CoroutineScope
@@ -153,10 +152,16 @@ class HotUpdaterModule internal constructor(
153
152
  }
154
153
 
155
154
  @ReactMethod(isBlockingSynchronousMethod = true)
156
- override fun notifyAppReady(): WritableNativeMap = getInstance().notifyAppReady().toWritableNativeMap()
155
+ override fun notifyAppReady(): String {
156
+ val result = getInstance().notifyAppReady()
157
+ return org.json.JSONObject(result).toString()
158
+ }
157
159
 
158
160
  @ReactMethod(isBlockingSynchronousMethod = true)
159
- override fun getCrashHistory(): WritableNativeArray = getInstance().getCrashHistory().toWritableNativeArray()
161
+ override fun getCrashHistory(): String {
162
+ val history = getInstance().getCrashHistory()
163
+ return org.json.JSONArray(history).toString()
164
+ }
160
165
 
161
166
  @ReactMethod(isBlockingSynchronousMethod = true)
162
167
  override fun clearCrashHistory(): Boolean {
@@ -177,7 +182,10 @@ class HotUpdaterModule internal constructor(
177
182
  }
178
183
 
179
184
  @ReactMethod(isBlockingSynchronousMethod = true)
180
- override fun getManifest(): WritableNativeMap = getInstance().getManifest().toWritableNativeMap()
185
+ override fun getManifest(): String {
186
+ val result = getInstance().getManifest()
187
+ return org.json.JSONObject(result).toString()
188
+ }
181
189
 
182
190
  @ReactMethod
183
191
  override fun setCohort(cohort: String) {
@@ -4,8 +4,6 @@ import com.facebook.react.bridge.Promise
4
4
  import com.facebook.react.bridge.ReactApplicationContext
5
5
  import com.facebook.react.bridge.ReactContextBaseJavaModule
6
6
  import com.facebook.react.bridge.ReadableMap
7
- import com.facebook.react.bridge.WritableArray
8
- import com.facebook.react.bridge.WritableMap
9
7
 
10
8
  abstract class HotUpdaterSpec internal constructor(
11
9
  context: ReactApplicationContext,
@@ -19,9 +17,9 @@ abstract class HotUpdaterSpec internal constructor(
19
17
 
20
18
  abstract fun reloadProcess(promise: Promise)
21
19
 
22
- abstract fun notifyAppReady(): WritableMap
20
+ abstract fun notifyAppReady(): String
23
21
 
24
- abstract fun getCrashHistory(): WritableArray
22
+ abstract fun getCrashHistory(): String
25
23
 
26
24
  abstract fun clearCrashHistory(): Boolean
27
25
 
@@ -33,7 +31,7 @@ abstract class HotUpdaterSpec internal constructor(
33
31
 
34
32
  abstract fun getBundleId(): String?
35
33
 
36
- abstract fun getManifest(): WritableMap
34
+ abstract fun getManifest(): String
37
35
 
38
36
  abstract fun resetChannel(promise: Promise)
39
37
  }
@@ -0,0 +1,178 @@
1
+ import Foundation
2
+
3
+ enum ArchiveExtractionUtilities {
4
+ static let bufferSize = 64 * 1024
5
+
6
+ static func readUpToCount(from handle: FileHandle, count: Int) throws -> Data? {
7
+ guard count >= 0 else {
8
+ throw NSError(
9
+ domain: "ArchiveExtractionUtilities",
10
+ code: 5,
11
+ userInfo: [NSLocalizedDescriptionKey: "Invalid read size: \(count)"]
12
+ )
13
+ }
14
+
15
+ if count == 0 {
16
+ return Data()
17
+ }
18
+
19
+ if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) {
20
+ return try handle.read(upToCount: count)
21
+ }
22
+
23
+ return handle.readData(ofLength: count)
24
+ }
25
+
26
+ static func readExactly(from handle: FileHandle, count: Int) throws -> Data {
27
+ guard count >= 0 else {
28
+ throw NSError(
29
+ domain: "ArchiveExtractionUtilities",
30
+ code: 1,
31
+ userInfo: [NSLocalizedDescriptionKey: "Invalid read size: \(count)"]
32
+ )
33
+ }
34
+
35
+ if count == 0 {
36
+ return Data()
37
+ }
38
+
39
+ guard let data = try readUpToCount(from: handle, count: count), data.count == count else {
40
+ throw NSError(
41
+ domain: "ArchiveExtractionUtilities",
42
+ code: 2,
43
+ userInfo: [NSLocalizedDescriptionKey: "Unexpected end of archive while reading \(count) bytes"]
44
+ )
45
+ }
46
+
47
+ return data
48
+ }
49
+
50
+ static func currentOffset(for handle: FileHandle) -> UInt64 {
51
+ if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) {
52
+ return (try? handle.offset()) ?? 0
53
+ }
54
+
55
+ return handle.offsetInFile
56
+ }
57
+
58
+ static func seek(_ handle: FileHandle, to offset: UInt64) throws {
59
+ if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) {
60
+ try handle.seek(toOffset: offset)
61
+ return
62
+ }
63
+
64
+ handle.seek(toFileOffset: offset)
65
+ }
66
+
67
+ static func skipBytes(_ byteCount: UInt64, in handle: FileHandle) throws {
68
+ guard byteCount > 0 else {
69
+ return
70
+ }
71
+
72
+ let offset = currentOffset(for: handle)
73
+ let (targetOffset, overflowed) = offset.addingReportingOverflow(byteCount)
74
+
75
+ guard !overflowed else {
76
+ throw NSError(
77
+ domain: "ArchiveExtractionUtilities",
78
+ code: 6,
79
+ userInfo: [NSLocalizedDescriptionKey: "Archive offset overflow while skipping \(byteCount) bytes from offset \(offset)"]
80
+ )
81
+ }
82
+
83
+ try seek(handle, to: targetOffset)
84
+ }
85
+
86
+ static func normalizedRelativePath(from rawPath: String) -> String? {
87
+ var candidate = rawPath
88
+ .replacingOccurrences(of: "\\", with: "/")
89
+ .trimmingCharacters(in: .whitespacesAndNewlines)
90
+
91
+ while candidate.hasPrefix("/") {
92
+ candidate.removeFirst()
93
+ }
94
+
95
+ while candidate.hasPrefix("./") {
96
+ candidate.removeFirst(2)
97
+ }
98
+
99
+ let components = candidate
100
+ .split(separator: "/")
101
+ .map(String.init)
102
+
103
+ guard !components.isEmpty else {
104
+ return nil
105
+ }
106
+
107
+ guard !components.contains(".."), !components.contains(".") else {
108
+ return nil
109
+ }
110
+
111
+ return components.joined(separator: "/")
112
+ }
113
+
114
+ static func extractionURL(for relativePath: String, destinationRoot: String) throws -> URL {
115
+ let rootURL = URL(fileURLWithPath: destinationRoot, isDirectory: true)
116
+ let targetURL = rootURL.appendingPathComponent(relativePath)
117
+ let standardizedRoot = rootURL.standardizedFileURL.path
118
+ let standardizedTarget = targetURL.standardizedFileURL.path
119
+
120
+ guard standardizedTarget == standardizedRoot ||
121
+ standardizedTarget.hasPrefix(standardizedRoot + "/") else {
122
+ throw NSError(
123
+ domain: "ArchiveExtractionUtilities",
124
+ code: 3,
125
+ userInfo: [NSLocalizedDescriptionKey: "Path traversal attempt detected: \(relativePath)"]
126
+ )
127
+ }
128
+
129
+ return targetURL
130
+ }
131
+
132
+ static func ensureDirectory(at url: URL, fileManager: FileManager = .default) throws {
133
+ var isDirectory = ObjCBool(false)
134
+ if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) {
135
+ if isDirectory.boolValue {
136
+ return
137
+ }
138
+
139
+ try fileManager.removeItem(at: url)
140
+ }
141
+
142
+ try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
143
+ }
144
+
145
+ static func createOutputFile(at url: URL, fileManager: FileManager = .default) throws -> FileHandle {
146
+ try ensureDirectory(at: url.deletingLastPathComponent(), fileManager: fileManager)
147
+
148
+ if fileManager.fileExists(atPath: url.path) {
149
+ try fileManager.removeItem(at: url)
150
+ }
151
+
152
+ guard fileManager.createFile(atPath: url.path, contents: nil) else {
153
+ throw NSError(
154
+ domain: "ArchiveExtractionUtilities",
155
+ code: 4,
156
+ userInfo: [NSLocalizedDescriptionKey: "Failed to create output file at \(url.path)"]
157
+ )
158
+ }
159
+
160
+ return try FileHandle(forWritingTo: url)
161
+ }
162
+ }
163
+
164
+ extension Data {
165
+ func archiveUInt16LE(at offset: Int) -> UInt16 {
166
+ let byte0 = UInt16(self[offset])
167
+ let byte1 = UInt16(self[offset + 1]) << 8
168
+ return byte0 | byte1
169
+ }
170
+
171
+ func archiveUInt32LE(at offset: Int) -> UInt32 {
172
+ let byte0 = UInt32(self[offset])
173
+ let byte1 = UInt32(self[offset + 1]) << 8
174
+ let byte2 = UInt32(self[offset + 2]) << 16
175
+ let byte3 = UInt32(self[offset + 3]) << 24
176
+ return byte0 | byte1 | byte2 | byte3
177
+ }
178
+ }
@@ -147,6 +147,17 @@ class BundleFileStorageService: BundleStorageService {
147
147
  let manifest: ManifestAssets
148
148
  }
149
149
 
150
+ private enum UpdateProgress {
151
+ static let downloadEnd = 0.7
152
+ static let verificationStart = 0.72
153
+ static let verificationEnd = 0.82
154
+ static let extractionStart = 0.82
155
+ static let extractionEnd = 0.97
156
+ static let bundleValidation = 0.98
157
+ static let activationReady = 0.99
158
+ static let complete = 1.0
159
+ }
160
+
150
161
  private let fileSystem: FileSystemService
151
162
  private let downloadService: DownloadService
152
163
  private let decompressService: DecompressService
@@ -178,7 +189,7 @@ class BundleFileStorageService: BundleStorageService {
178
189
 
179
190
  // Create queue for file operations
180
191
  self.fileOperationQueue = DispatchQueue(label: "com.hotupdater.fileoperations",
181
- qos: .utility,
192
+ qos: .userInitiated,
182
193
  attributes: .concurrent)
183
194
 
184
195
  // Ensure bundle store directory exists
@@ -623,26 +634,23 @@ class BundleFileStorageService: BundleStorageService {
623
634
  */
624
635
  func findBundleFile(in directoryPath: String) -> Result<String?, Error> {
625
636
  NSLog("[BundleStorage] Searching for bundle file in directory: \(directoryPath)")
626
-
627
- // Check directory contents
637
+
638
+ let iosBundlePath = (directoryPath as NSString).appendingPathComponent("index.ios.bundle")
639
+ if self.fileSystem.fileExists(atPath: iosBundlePath) {
640
+ NSLog("[BundleStorage] Found iOS bundle atPath: \(iosBundlePath)")
641
+ return .success(iosBundlePath)
642
+ }
643
+
644
+ let mainBundlePath = (directoryPath as NSString).appendingPathComponent("main.jsbundle")
645
+ if self.fileSystem.fileExists(atPath: mainBundlePath) {
646
+ NSLog("[BundleStorage] Found main bundle atPath: \(mainBundlePath)")
647
+ return .success(mainBundlePath)
648
+ }
649
+
628
650
  do {
629
651
  let contents = try self.fileSystem.contentsOfDirectory(atPath: directoryPath)
630
652
  NSLog("[BundleStorage] Directory contents: \(contents)")
631
-
632
- // Check for iOS bundle file directly
633
- let iosBundlePath = (directoryPath as NSString).appendingPathComponent("index.ios.bundle")
634
- if self.fileSystem.fileExists(atPath: iosBundlePath) {
635
- NSLog("[BundleStorage] Found iOS bundle atPath: \(iosBundlePath)")
636
- return .success(iosBundlePath)
637
- }
638
-
639
- // Check for main bundle file
640
- let mainBundlePath = (directoryPath as NSString).appendingPathComponent("main.jsbundle")
641
- if self.fileSystem.fileExists(atPath: mainBundlePath) {
642
- NSLog("[BundleStorage] Found main bundle atPath: \(mainBundlePath)")
643
- return .success(mainBundlePath)
644
- }
645
-
653
+
646
654
  // Additional search: check all .bundle files
647
655
  for file in contents {
648
656
  if file.hasSuffix(".bundle") {
@@ -662,19 +670,23 @@ class BundleFileStorageService: BundleStorageService {
662
670
  }
663
671
 
664
672
  /**
665
- * Cleans up old bundles, keeping only the current and new bundles.
673
+ * Cleans up old bundles, keeping only the requested bundle IDs.
666
674
  * Executes synchronously on the calling thread.
667
675
  * @param currentBundleId ID of the current active bundle (optional)
668
676
  * @param bundleId ID of the new bundle to keep (optional)
669
677
  * @return Result of operation
670
678
  */
671
679
  func cleanupOldBundles(currentBundleId: String?, bundleId: String?) -> Result<Void, Error> {
680
+ cleanupOldBundles(bundleIdsToKeep: [currentBundleId, bundleId].compactMap { $0 })
681
+ }
682
+
683
+ private func cleanupOldBundles(bundleIdsToKeep: [String]) -> Result<Void, Error> {
672
684
  let storeDirResult = bundleStoreDir()
673
-
685
+
674
686
  guard case .success(let storeDir) = storeDirResult else {
675
687
  return .failure(storeDirResult.failureError ?? BundleStorageError.unknown(nil))
676
688
  }
677
-
689
+
678
690
  // List only directories that are not .tmp
679
691
  let contents: [String]
680
692
  do {
@@ -694,14 +706,12 @@ class BundleFileStorageService: BundleStorageService {
694
706
 
695
707
  return (!item.hasSuffix(".tmp") && self.fileSystem.fileExists(atPath: fullPath)) ? fullPath : nil
696
708
  }
697
-
698
- // Keep only the specified bundle IDs
699
- let bundleIdsToKeep = Set([currentBundleId, bundleId].compactMap { $0 })
700
-
709
+ let bundleIdsToKeepSet = Set(bundleIdsToKeep)
710
+
701
711
  bundles.forEach { bundlePath in
702
712
  let bundleName = (bundlePath as NSString).lastPathComponent
703
-
704
- if !bundleIdsToKeep.contains(bundleName) {
713
+
714
+ if !bundleIdsToKeepSet.contains(bundleName) {
705
715
  do {
706
716
  try self.fileSystem.removeItem(atPath: bundlePath)
707
717
  NSLog("[BundleStorage] Removing old bundle: \(bundleName)")
@@ -712,7 +722,7 @@ class BundleFileStorageService: BundleStorageService {
712
722
  NSLog("[BundleStorage] Keeping bundle: \(bundleName)")
713
723
  }
714
724
  }
715
-
725
+
716
726
  // Remove any leftover .tmp directories
717
727
  contents.forEach { item in
718
728
  if item.hasSuffix(".tmp") {
@@ -725,9 +735,23 @@ class BundleFileStorageService: BundleStorageService {
725
735
  }
726
736
  }
727
737
  }
728
-
738
+
729
739
  return .success(())
730
740
  }
741
+
742
+ private func scheduleCleanupOldBundles(bundleIdsToKeep: [String]) {
743
+ let uniqueBundleIdsToKeep = Array(Set(bundleIdsToKeep.filter { !$0.isEmpty }))
744
+ guard !uniqueBundleIdsToKeep.isEmpty else {
745
+ return
746
+ }
747
+
748
+ self.fileOperationQueue.async(flags: .barrier) {
749
+ let cleanupResult = self.cleanupOldBundles(bundleIdsToKeep: uniqueBundleIdsToKeep)
750
+ if case .failure(let error) = cleanupResult {
751
+ NSLog("[BundleStorage] Error during deferred cleanup: \(error)")
752
+ }
753
+ }
754
+ }
731
755
 
732
756
  /**
733
757
  * Sets the current bundle URL in preferences.
@@ -895,12 +919,10 @@ class BundleFileStorageService: BundleStorageService {
895
919
  let _ = self.saveMetadata(updatedMetadata)
896
920
  NSLog("[BundleStorage] Set staging bundle (cached): \(bundleId), verificationPending: true")
897
921
 
898
- // Clean up old bundles, preserving the fallback and new staging bundle.
899
- let bundleIdsToKeep = [updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
900
- if bundleIdsToKeep.count > 0 {
901
- let _ = self.cleanupOldBundles(currentBundleId: bundleIdsToKeep.first, bundleId: bundleIdsToKeep.count > 1 ? bundleIdsToKeep[1] : nil)
902
- }
903
-
922
+ progressHandler(UpdateProgress.complete)
923
+ self.scheduleCleanupOldBundles(
924
+ bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
925
+ )
904
926
  completion(.success(true))
905
927
  case .failure(let error):
906
928
  completion(.failure(error))
@@ -997,8 +1019,7 @@ class BundleFileStorageService: BundleStorageService {
997
1019
  }
998
1020
  },
999
1021
  progressHandler: { downloadProgress in
1000
- // Map download progress to 0.0 - 0.8
1001
- progressHandler(downloadProgress * 0.8)
1022
+ progressHandler(Self.mapProgress(downloadProgress, start: 0, end: UpdateProgress.downloadEnd))
1002
1023
  },
1003
1024
  completion: { [weak self] result in
1004
1025
  guard let self = self else {
@@ -1084,7 +1105,7 @@ class BundleFileStorageService: BundleStorageService {
1084
1105
  * @param storeDir Path to the bundle-store directory
1085
1106
  * @param bundleId ID of the bundle being processed
1086
1107
  * @param tempDirectory Temporary directory for processing
1087
- * @param progressHandler Callback for extraction progress (0.8 to 1.0)
1108
+ * @param progressHandler Callback for download/apply progress (0.0 to 1.0)
1088
1109
  * @param completion Callback with result of the operation
1089
1110
  */
1090
1111
  private func processDownloadedFileWithTmp(
@@ -1124,17 +1145,21 @@ class BundleFileStorageService: BundleStorageService {
1124
1145
  }
1125
1146
 
1126
1147
  // 4) Create tmpDir
1127
- try self.fileSystem.createDirectory(atPath: tmpDir)
1148
+ guard self.fileSystem.createDirectory(atPath: tmpDir) else {
1149
+ throw BundleStorageError.directoryCreationFailed
1150
+ }
1128
1151
  NSLog("[BundleStorage] Created tmpDir: \(tmpDir)")
1129
1152
  logFileSystemDiagnostics(path: tmpDir, context: "TmpDir Created")
1130
1153
 
1131
1154
  // 5) Verify bundle integrity (hash or signature based on fileHash format)
1132
1155
  NSLog("[BundleStorage] Verifying bundle integrity...")
1156
+ progressHandler(UpdateProgress.verificationStart)
1133
1157
  let tempBundleURL = URL(fileURLWithPath: tempBundleFile)
1134
1158
  let verificationResult = SignatureVerifier.verifyBundle(fileURL: tempBundleURL, fileHash: fileHash)
1135
1159
  switch verificationResult {
1136
1160
  case .success:
1137
1161
  NSLog("[BundleStorage] Bundle verification completed successfully")
1162
+ progressHandler(UpdateProgress.verificationEnd)
1138
1163
  case .failure(let error):
1139
1164
  NSLog("[BundleStorage] Bundle verification failed: \(error)")
1140
1165
  try? self.fileSystem.removeItem(atPath: tmpDir)
@@ -1148,8 +1173,12 @@ class BundleFileStorageService: BundleStorageService {
1148
1173
  logFileSystemDiagnostics(path: tempBundleFile, context: "Before Extraction")
1149
1174
  do {
1150
1175
  try self.decompressService.unzip(file: tempBundleFile, to: tmpDir, progressHandler: { unzipProgress in
1151
- // Map unzip progress (0.0 - 1.0) to overall progress (0.8 - 1.0)
1152
- progressHandler(0.8 + (unzipProgress * 0.2))
1176
+ let progress = Self.mapProgress(
1177
+ unzipProgress,
1178
+ start: UpdateProgress.extractionStart,
1179
+ end: UpdateProgress.extractionEnd
1180
+ )
1181
+ progressHandler(progress)
1153
1182
  })
1154
1183
  NSLog("[BundleStorage] Extraction complete at \(tmpDir)")
1155
1184
  logFileSystemDiagnostics(path: tmpDir, context: "After Extraction")
@@ -1167,6 +1196,7 @@ class BundleFileStorageService: BundleStorageService {
1167
1196
  try? self.fileSystem.removeItem(atPath: tempBundleFile)
1168
1197
 
1169
1198
  // 8) Verify that a valid bundle file exists inside tmpDir
1199
+ progressHandler(UpdateProgress.bundleValidation)
1170
1200
  switch self.findBundleFile(in: tmpDir) {
1171
1201
  case .success(let maybeBundlePath):
1172
1202
  if let bundlePathInTmp = maybeBundlePath {
@@ -1210,13 +1240,13 @@ class BundleFileStorageService: BundleStorageService {
1210
1240
  // 14) Clean up the temporary directory
1211
1241
  self.cleanupTemporaryFiles([tempDirectory])
1212
1242
 
1213
- // 15) Clean up old bundles, preserving the fallback and new staging bundle.
1214
- let bundleIdsToKeep = [updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
1215
- if bundleIdsToKeep.count > 0 {
1216
- let _ = self.cleanupOldBundles(currentBundleId: bundleIdsToKeep.first, bundleId: bundleIdsToKeep.count > 1 ? bundleIdsToKeep[1] : nil)
1217
- }
1243
+ progressHandler(UpdateProgress.activationReady)
1244
+ self.scheduleCleanupOldBundles(
1245
+ bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
1246
+ )
1218
1247
 
1219
- // 16) Complete with success
1248
+ // 15) Complete with success
1249
+ progressHandler(UpdateProgress.complete)
1220
1250
  completion(.success(true))
1221
1251
  case .failure(let err):
1222
1252
  let nsError = err as NSError
@@ -1259,6 +1289,11 @@ class BundleFileStorageService: BundleStorageService {
1259
1289
  }
1260
1290
  }
1261
1291
 
1292
+ private static func mapProgress(_ value: Double, start: Double, end: Double) -> Double {
1293
+ let clampedValue = min(max(value, 0), 1)
1294
+ return start + (clampedValue * (end - start))
1295
+ }
1296
+
1262
1297
  // MARK: - Rollback Support
1263
1298
 
1264
1299
  /**