@hot-updater/react-native 1.0.0-rc.14 → 1.0.0-rc.15

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 (181) hide show
  1. package/android/proguard-rules.pro +2 -2
  2. package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +286 -0
  3. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +650 -636
  4. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +3 -0
  5. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +0 -13
  6. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +10 -14
  7. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +19 -0
  8. package/android/src/main/java/com/hotupdater/TarArchiveInputStream.kt +214 -375
  9. package/android/src/main/java/com/hotupdater/TarBrArchiveExtractor.kt +119 -0
  10. package/android/src/newarch/HotUpdater.kt +7 -9
  11. package/android/src/newarch/HotUpdaterModule.kt +40 -20
  12. package/android/src/oldarch/HotUpdater.kt +7 -9
  13. package/android/src/oldarch/HotUpdaterModule.kt +40 -20
  14. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +1126 -74
  15. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +4 -5
  16. package/android/src/test/java/com/hotupdater/TarBrArchiveExtractorTest.kt +206 -0
  17. package/ios/HotUpdater/Internal/BrotliFileDecompressor.swift +167 -0
  18. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +196 -0
  19. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +773 -1016
  20. package/ios/HotUpdater/Internal/BundleMetadata.swift +5 -0
  21. package/ios/HotUpdater/Internal/FileUtilities.swift +64 -0
  22. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +0 -1
  23. package/ios/HotUpdater/Internal/HotUpdater.mm +5 -27
  24. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +47 -31
  25. package/ios/HotUpdater/Internal/NotificationExtension.swift +0 -2
  26. package/ios/HotUpdater/Internal/TarArchiveExtractor.swift +182 -311
  27. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +13 -72
  28. package/lib/commonjs/checkForUpdate.js +7 -10
  29. package/lib/commonjs/checkForUpdate.js.map +1 -1
  30. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +9 -4
  31. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +1 -1
  32. package/lib/commonjs/error.js +4 -13
  33. package/lib/commonjs/error.js.map +1 -1
  34. package/lib/commonjs/fetchJSON.js +9 -2
  35. package/lib/commonjs/fetchJSON.js.map +1 -1
  36. package/lib/commonjs/httpClient.js +44 -27
  37. package/lib/commonjs/httpClient.js.map +1 -1
  38. package/lib/commonjs/httpClient.spec.js +57 -5
  39. package/lib/commonjs/httpClient.spec.js.map +1 -1
  40. package/lib/commonjs/index.js +2 -6
  41. package/lib/commonjs/index.js.map +1 -1
  42. package/lib/commonjs/native.js +7 -8
  43. package/lib/commonjs/native.js.map +1 -1
  44. package/lib/commonjs/native.spec.js +21 -16
  45. package/lib/commonjs/native.spec.js.map +1 -1
  46. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js +7 -0
  47. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js.map +1 -1
  48. package/lib/commonjs/notifyAppReadyInsights.js +4 -1
  49. package/lib/commonjs/notifyAppReadyInsights.js.map +1 -1
  50. package/lib/commonjs/notifyAppReadyInsights.spec.js +59 -0
  51. package/lib/commonjs/notifyAppReadyInsights.spec.js.map +1 -1
  52. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js +7 -0
  53. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js.map +1 -1
  54. package/lib/commonjs/sdkVersion.js +1 -1
  55. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  56. package/lib/commonjs/store.js +3 -10
  57. package/lib/commonjs/store.js.map +1 -1
  58. package/lib/commonjs/store.notification.spec.js +12 -4
  59. package/lib/commonjs/store.notification.spec.js.map +1 -1
  60. package/lib/commonjs/store.spec.js +2 -136
  61. package/lib/commonjs/store.spec.js.map +1 -1
  62. package/lib/commonjs/wrap.js +1 -3
  63. package/lib/commonjs/wrap.js.map +1 -1
  64. package/lib/commonjs/wrap.spec.js +7 -0
  65. package/lib/commonjs/wrap.spec.js.map +1 -1
  66. package/lib/module/checkForUpdate.js +7 -10
  67. package/lib/module/checkForUpdate.js.map +1 -1
  68. package/lib/module/checkForUpdate.releaseCatalog.spec.js +9 -4
  69. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +1 -1
  70. package/lib/module/error.js +4 -13
  71. package/lib/module/error.js.map +1 -1
  72. package/lib/module/fetchJSON.js +7 -1
  73. package/lib/module/fetchJSON.js.map +1 -1
  74. package/lib/module/httpClient.js +46 -29
  75. package/lib/module/httpClient.js.map +1 -1
  76. package/lib/module/httpClient.spec.js +57 -5
  77. package/lib/module/httpClient.spec.js.map +1 -1
  78. package/lib/module/index.js +2 -6
  79. package/lib/module/index.js.map +1 -1
  80. package/lib/module/native.js +7 -8
  81. package/lib/module/native.js.map +1 -1
  82. package/lib/module/native.spec.js +21 -16
  83. package/lib/module/native.spec.js.map +1 -1
  84. package/lib/module/notifyAppReadyInsights.boundary.spec.js +7 -0
  85. package/lib/module/notifyAppReadyInsights.boundary.spec.js.map +1 -1
  86. package/lib/module/notifyAppReadyInsights.js +5 -2
  87. package/lib/module/notifyAppReadyInsights.js.map +1 -1
  88. package/lib/module/notifyAppReadyInsights.spec.js +59 -0
  89. package/lib/module/notifyAppReadyInsights.spec.js.map +1 -1
  90. package/lib/module/notifyAppReadyInsights.transport.spec.js +7 -0
  91. package/lib/module/notifyAppReadyInsights.transport.spec.js.map +1 -1
  92. package/lib/module/sdkVersion.js +1 -1
  93. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  94. package/lib/module/store.js +3 -10
  95. package/lib/module/store.js.map +1 -1
  96. package/lib/module/store.notification.spec.js +12 -4
  97. package/lib/module/store.notification.spec.js.map +1 -1
  98. package/lib/module/store.spec.js +2 -136
  99. package/lib/module/store.spec.js.map +1 -1
  100. package/lib/module/wrap.js +1 -3
  101. package/lib/module/wrap.js.map +1 -1
  102. package/lib/module/wrap.spec.js +7 -0
  103. package/lib/module/wrap.spec.js.map +1 -1
  104. package/lib/typescript/commonjs/checkForUpdate.d.ts +1 -2
  105. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/error.d.ts +4 -13
  107. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/fetchJSON.d.ts +4 -0
  109. package/lib/typescript/commonjs/fetchJSON.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/index.d.ts +0 -2
  112. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/native.d.ts +1 -6
  114. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts +1 -1
  116. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  118. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +8 -35
  119. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/store.d.ts +1 -3
  121. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  123. package/lib/typescript/commonjs/wrap.types.d.ts +0 -2
  124. package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -1
  125. package/lib/typescript/module/checkForUpdate.d.ts +1 -2
  126. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  127. package/lib/typescript/module/error.d.ts +4 -13
  128. package/lib/typescript/module/error.d.ts.map +1 -1
  129. package/lib/typescript/module/fetchJSON.d.ts +4 -0
  130. package/lib/typescript/module/fetchJSON.d.ts.map +1 -1
  131. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  132. package/lib/typescript/module/index.d.ts +0 -2
  133. package/lib/typescript/module/index.d.ts.map +1 -1
  134. package/lib/typescript/module/native.d.ts +1 -6
  135. package/lib/typescript/module/native.d.ts.map +1 -1
  136. package/lib/typescript/module/notifyAppReadyInsights.d.ts +1 -1
  137. package/lib/typescript/module/notifyAppReadyInsights.d.ts.map +1 -1
  138. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  139. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +8 -35
  140. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  141. package/lib/typescript/module/store.d.ts +1 -3
  142. package/lib/typescript/module/store.d.ts.map +1 -1
  143. package/lib/typescript/module/wrap.d.ts.map +1 -1
  144. package/lib/typescript/module/wrap.types.d.ts +0 -2
  145. package/lib/typescript/module/wrap.types.d.ts.map +1 -1
  146. package/package.json +3 -3
  147. package/src/checkForUpdate.releaseCatalog.spec.ts +10 -4
  148. package/src/checkForUpdate.ts +5 -11
  149. package/src/error.ts +4 -14
  150. package/src/fetchJSON.ts +12 -1
  151. package/src/httpClient.spec.ts +70 -5
  152. package/src/httpClient.ts +67 -53
  153. package/src/index.ts +1 -5
  154. package/src/native.spec.ts +23 -16
  155. package/src/native.ts +12 -24
  156. package/src/notifyAppReadyInsights.boundary.spec.ts +8 -0
  157. package/src/notifyAppReadyInsights.spec.ts +60 -0
  158. package/src/notifyAppReadyInsights.transport.spec.ts +8 -0
  159. package/src/notifyAppReadyInsights.ts +13 -1
  160. package/src/sdkVersion.ts +1 -1
  161. package/src/specs/NativeHotUpdater.ts +8 -36
  162. package/src/store.notification.spec.ts +13 -3
  163. package/src/store.spec.ts +0 -157
  164. package/src/store.ts +2 -23
  165. package/src/wrap.spec.ts +8 -0
  166. package/src/wrap.tsx +0 -2
  167. package/src/wrap.types.ts +0 -2
  168. package/android/src/main/java/com/hotupdater/DecompressService.kt +0 -89
  169. package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +0 -26
  170. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +0 -117
  171. package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +0 -117
  172. package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +0 -192
  173. package/android/src/test/java/com/hotupdater/TarArchiveInputStreamTest.kt +0 -68
  174. package/ios/HotUpdater/Internal/ArchiveExtractionUtilities.swift +0 -178
  175. package/ios/HotUpdater/Internal/DecompressService.swift +0 -112
  176. package/ios/HotUpdater/Internal/DecompressionStrategy.swift +0 -22
  177. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +0 -370
  178. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +0 -39
  179. package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +0 -59
  180. package/ios/HotUpdater/Internal/ZipArchiveExtractor.swift +0 -482
  181. package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +0 -56
@@ -52,27 +52,28 @@ public struct BsdiffPatchDescriptor {
52
52
  public let baseFileHash: String
53
53
  public let patchFileHash: String
54
54
  public let patchUrl: URL
55
+ public let byteSize: Int64?
55
56
 
56
57
  public init(
57
58
  algorithm: String,
58
59
  baseBundleId: String,
59
60
  baseFileHash: String,
60
61
  patchFileHash: String,
61
- patchUrl: URL
62
+ patchUrl: URL,
63
+ byteSize: Int64? = nil
62
64
  ) {
63
65
  self.algorithm = algorithm
64
66
  self.baseBundleId = baseBundleId
65
67
  self.baseFileHash = baseFileHash
66
68
  self.patchFileHash = patchFileHash
67
69
  self.patchUrl = patchUrl
70
+ self.byteSize = byteSize
68
71
  }
69
72
  }
70
73
 
71
74
  public struct UpdateProgressPayload {
72
75
  public let progress: Double
73
76
  public let artifactType: String
74
- public let downloadedBytes: Int64?
75
- public let totalBytes: Int64?
76
77
  public let details: DiffProgressDetails?
77
78
 
78
79
  public struct DiffProgressFileSnapshot {
@@ -147,30 +148,19 @@ public struct UpdateProgressPayload {
147
148
  public init(
148
149
  progress: Double,
149
150
  artifactType: String,
150
- downloadedBytes: Int64? = nil,
151
- totalBytes: Int64? = nil,
152
151
  details: DiffProgressDetails? = nil
153
152
  ) {
154
153
  self.progress = progress
155
154
  self.artifactType = artifactType
156
- self.downloadedBytes = downloadedBytes
157
- self.totalBytes = totalBytes
158
155
  self.details = details
159
156
  }
160
157
 
161
158
  public var userInfo: [String: Any] {
162
- var info: [String: Any] = [
159
+ [
163
160
  "artifactType": artifactType,
164
161
  "progress": progress,
165
162
  "details": details?.userInfo ?? NSNull()
166
163
  ]
167
- if let downloadedBytes {
168
- info["downloadedBytes"] = downloadedBytes
169
- }
170
- if let totalBytes {
171
- info["totalBytes"] = totalBytes
172
- }
173
- return info
174
164
  }
175
165
  }
176
166
 
@@ -178,9 +168,7 @@ public enum BundleStorageError: Error, CustomNSError {
178
168
  case directoryCreationFailed
179
169
  case downloadFailed(Error)
180
170
  case incompleteDownload(expected: Int64, actual: Int64)
181
- case extractionFormatError(Error)
182
171
  case invalidBundle
183
- case insufficientDiskSpace
184
172
  case signatureVerificationFailed(SignatureVerificationError)
185
173
  case moveOperationFailed(Error)
186
174
  case bundleInCrashedHistory(String)
@@ -200,9 +188,7 @@ public enum BundleStorageError: Error, CustomNSError {
200
188
  case .directoryCreationFailed: return "DIRECTORY_CREATION_FAILED"
201
189
  case .downloadFailed: return "DOWNLOAD_FAILED"
202
190
  case .incompleteDownload: return "INCOMPLETE_DOWNLOAD"
203
- case .extractionFormatError: return "EXTRACTION_FORMAT_ERROR"
204
191
  case .invalidBundle: return "INVALID_BUNDLE"
205
- case .insufficientDiskSpace: return "INSUFFICIENT_DISK_SPACE"
206
192
  case .signatureVerificationFailed: return "SIGNATURE_VERIFICATION_FAILED"
207
193
  case .moveOperationFailed: return "MOVE_OPERATION_FAILED"
208
194
  case .bundleInCrashedHistory: return "BUNDLE_IN_CRASHED_HISTORY"
@@ -227,19 +213,10 @@ public enum BundleStorageError: Error, CustomNSError {
227
213
  userInfo[NSLocalizedDescriptionKey] = "Download incomplete: received \(actual) bytes, expected \(expected) bytes"
228
214
  userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The download was interrupted. Check network connection and try again"
229
215
 
230
- case .extractionFormatError(let underlyingError):
231
- userInfo[NSLocalizedDescriptionKey] = "The downloaded bundle file is not a valid compressed archive"
232
- userInfo[NSUnderlyingErrorKey] = underlyingError
233
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The downloaded file is not a supported bundle archive. Try downloading again"
234
-
235
216
  case .invalidBundle:
236
217
  userInfo[NSLocalizedDescriptionKey] = "Bundle missing required platform files (index.ios.bundle or main.jsbundle)"
237
218
  userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Verify the bundle was built correctly with metro bundler"
238
219
 
239
- case .insufficientDiskSpace:
240
- userInfo[NSLocalizedDescriptionKey] = "Insufficient disk space to download and extract bundle"
241
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Free up device storage and try again"
242
-
243
220
  case .signatureVerificationFailed(let underlyingError):
244
221
  userInfo[NSLocalizedDescriptionKey] = "Bundle signature verification failed"
245
222
  userInfo[NSUnderlyingErrorKey] = underlyingError
@@ -280,7 +257,7 @@ public protocol BundleStorageService {
280
257
  func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection
281
258
 
282
259
  // Bundle update
283
- func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, manifestUrl: URL?, manifestFileHash: String?, changedAssets: [String: ChangedAssetDescriptor]?, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<Bool, Error>) -> Void)
260
+ func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL?, assets: [String: ChangedAssetDescriptor], progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<Bool, Error>) -> Void)
284
261
  func stageReleaseSelection(_ selection: PersistedSelection) -> Bool
285
262
  func acceptReleaseCatalog(catalogId: String, scopeKey: String, generation: Int64, catalogHash: String, channel: String, selectionContextHash: String) -> Bool
286
263
  func getActiveUpdateState() -> [String: Any]
@@ -306,7 +283,7 @@ public protocol BundleStorageService {
306
283
 
307
284
  /**
308
285
  * Gets the current active bundle ID from bundle storage.
309
- * Reads manifest.json first and falls back to older metadata when needed.
286
+ * Reads the required OTA manifest.json.
310
287
  */
311
288
  func getBundleId() -> String?
312
289
 
@@ -332,6 +309,8 @@ public extension BundleStorageService {
332
309
  }
333
310
 
334
311
  class BundleFileStorageService: BundleStorageService {
312
+ private static let maximumSafeByteSize: Int64 = 9_007_199_254_740_991
313
+
335
314
  private struct ActiveBundleMetadataSnapshot {
336
315
  let activeBundleId: String
337
316
  let bundleId: String?
@@ -341,41 +320,36 @@ class BundleFileStorageService: BundleStorageService {
341
320
  private struct ParsedBundleManifest {
342
321
  let bundleId: String
343
322
  let assets: [String: ParsedManifestAsset]
323
+ let archive: ParsedArchive?
344
324
  }
345
325
 
346
326
  private struct ParsedManifestAsset {
347
327
  let fileHash: String
348
328
  let signature: String?
329
+ let byteSize: Int64?
330
+ let downloadByteSize: Int64?
349
331
  }
350
332
 
351
- private enum UpdateProgress {
352
- static let downloadEnd = 0.7
353
- static let verificationStart = 0.72
354
- static let verificationEnd = 0.82
355
- static let extractionStart = 0.82
356
- static let extractionEnd = 0.97
357
- static let bundleValidation = 0.98
358
- static let activationReady = 0.99
359
- static let complete = 1.0
333
+ private struct ParsedArchive {
334
+ let downloadFileHash: String
335
+ let downloadByteSize: Int64
336
+ let tarByteSize: Int64
360
337
  }
361
338
 
362
339
  private let fileSystem: FileSystemService
363
340
  private let downloadService: DownloadService
364
- private let decompressService: DecompressService
365
341
  private let preferences: PreferencesService
366
342
  private let isolationKey: String
367
343
 
368
- private let id = Int.random(in: 1..<100)
369
-
370
344
  // Queue for potentially long-running sequences within updateBundle or for explicit background tasks.
371
345
  private let fileOperationQueue: DispatchQueue
372
346
 
373
- private var activeTasks: [URLSessionTask] = []
374
-
347
+ private var hasPreparedLaunch = false
375
348
  private var currentLaunchReport: LaunchReport?
376
349
  private let activeBundleMetadataLock = NSLock()
377
350
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot?
378
351
  private let builtInBundleIdProvider: () -> String
352
+ private let builtInAssetResolver: BuiltInAssetResolver
379
353
  private let updateStrategyProvider: () -> UpdateStrategy
380
354
  private let releaseStateLock = NSRecursiveLock()
381
355
  private var pendingInstallSelections: [String: PersistedSelection] = [:]
@@ -389,6 +363,7 @@ class BundleFileStorageService: BundleStorageService {
389
363
  LaunchReport.launchReportFilename,
390
364
  InstallIdentity.installIdentityFilename,
391
365
  UserIdentity.userIdentityFilename,
366
+ "builtin-index-v1.json",
392
367
  ]
393
368
 
394
369
  private func normalizeIdentityValue(_ value: String?) -> String? {
@@ -399,22 +374,6 @@ class BundleFileStorageService: BundleStorageService {
399
374
  return trimmed
400
375
  }
401
376
 
402
- private func emitArchiveProgress(
403
- progressHandler: @escaping (UpdateProgressPayload) -> Void,
404
- progress: Double,
405
- downloadedBytes: Int64? = nil,
406
- totalBytes: Int64? = nil
407
- ) {
408
- progressHandler(
409
- UpdateProgressPayload(
410
- progress: max(0, min(progress, 1)),
411
- artifactType: "archive",
412
- downloadedBytes: downloadedBytes,
413
- totalBytes: totalBytes
414
- )
415
- )
416
- }
417
-
418
377
  private func createDiffProgressFiles(
419
378
  changedAssets: [String: ChangedAssetDescriptor]
420
379
  ) -> [UpdateProgressPayload.DiffProgressFileSnapshot] {
@@ -576,10 +535,7 @@ class BundleFileStorageService: BundleStorageService {
576
535
  return
577
536
  }
578
537
 
579
- try StreamingTarArchiveExtractor.decompressBrotliFile(
580
- from: downloadedPath,
581
- to: destinationPath
582
- )
538
+ try BrotliFileDecompressor.decompress(from: downloadedPath, to: destinationPath)
583
539
  try? fileSystem.removeItem(atPath: downloadedPath)
584
540
  }
585
541
 
@@ -604,7 +560,7 @@ class BundleFileStorageService: BundleStorageService {
604
560
 
605
561
  let sourcePath: String
606
562
  do {
607
- sourcePath = try ArchiveExtractionUtilities.extractionURL(
563
+ sourcePath = try FileUtilities.fileURL(
608
564
  for: assetPath,
609
565
  destinationRoot: currentBundleDir
610
566
  ).path
@@ -722,6 +678,14 @@ class BundleFileStorageService: BundleStorageService {
722
678
  atPath path: String,
723
679
  asset: ParsedManifestAsset
724
680
  ) throws {
681
+ if let expectedByteSize = asset.byteSize {
682
+ let attributes = try fileSystem.attributesOfItem(atPath: path)
683
+ guard let size = attributes[.size] as? NSNumber,
684
+ size.int64Value == expectedByteSize else {
685
+ throw BundleStorageError.signatureVerificationFailed(.fileHashMismatch)
686
+ }
687
+ }
688
+
725
689
  guard let actualHash = HashUtils.calculateSHA256(fileURL: URL(fileURLWithPath: path)) else {
726
690
  throw BundleStorageError.signatureVerificationFailed(.fileReadFailed)
727
691
  }
@@ -751,10 +715,10 @@ class BundleFileStorageService: BundleStorageService {
751
715
 
752
716
  public init(fileSystem: FileSystemService,
753
717
  downloadService: DownloadService,
754
- decompressService: DecompressService,
755
718
  preferences: PreferencesService,
756
719
  isolationKey: String,
757
720
  builtInBundleIdProvider: @escaping () -> String = { "" },
721
+ builtInAssetResolver: BuiltInAssetResolver? = nil,
758
722
  updateStrategyProvider: @escaping () -> UpdateStrategy = {
759
723
  let fingerprintHash = HotUpdaterConfig.shared.fingerprintHash
760
724
  ?? Bundle.main.object(forInfoDictionaryKey: "HOT_UPDATER_FINGERPRINT_HASH") as? String
@@ -763,10 +727,14 @@ class BundleFileStorageService: BundleStorageService {
763
727
 
764
728
  self.fileSystem = fileSystem
765
729
  self.downloadService = downloadService
766
- self.decompressService = decompressService
767
730
  self.preferences = preferences
768
731
  self.isolationKey = isolationKey
769
732
  self.builtInBundleIdProvider = builtInBundleIdProvider
733
+ self.builtInAssetResolver = builtInAssetResolver ?? IOSBuiltInAssetResolver(
734
+ cacheURL: URL(fileURLWithPath: fileSystem.documentsPath())
735
+ .appendingPathComponent("bundle-store", isDirectory: true)
736
+ .appendingPathComponent("builtin-index-v1.json")
737
+ )
770
738
  self.updateStrategyProvider = updateStrategyProvider
771
739
 
772
740
  // Create queue for file operations
@@ -779,6 +747,7 @@ class BundleFileStorageService: BundleStorageService {
779
747
 
780
748
  // Clean up old bundles if isolationKey format changed
781
749
  checkAndCleanupIfIsolationKeyChanged()
750
+ recoverInterruptedPromotions()
782
751
  }
783
752
 
784
753
  // MARK: - Metadata File Paths
@@ -938,11 +907,10 @@ class BundleFileStorageService: BundleStorageService {
938
907
  }
939
908
 
940
909
  private func createInitialMetadata() -> BundleMetadata {
941
- let currentBundleId = getCachedBundleId()
942
910
  return BundleMetadata(
943
911
  isolationKey: isolationKey,
944
912
  stableBundleId: nil,
945
- stagingBundleId: currentBundleId,
913
+ stagingBundleId: nil,
946
914
  verificationPending: false,
947
915
  pendingTransition: nil
948
916
  )
@@ -956,18 +924,18 @@ class BundleFileStorageService: BundleStorageService {
956
924
  }
957
925
 
958
926
  private func getActiveBundleId() -> String? {
959
- if let cachedBundleId = getCachedBundleId() {
927
+ guard let metadata = loadMetadataOrNull() else { return nil }
928
+ if let cachedBundleId = getCachedBundleId(),
929
+ cachedBundleId == metadata.stagingBundleId || cachedBundleId == metadata.stableBundleId {
960
930
  return cachedBundleId
961
931
  }
962
932
 
963
- let metadata = loadMetadataOrNull()
964
-
965
- if let stagingBundleId = metadata?.stagingBundleId,
966
- metadata?.verificationPending == false {
933
+ if let stagingBundleId = metadata.stagingBundleId,
934
+ metadata.verificationPending == false {
967
935
  return stagingBundleId
968
936
  }
969
937
 
970
- if let stableBundleId = metadata?.stableBundleId {
938
+ if let stableBundleId = metadata.stableBundleId {
971
939
  return stableBundleId
972
940
  }
973
941
 
@@ -1017,22 +985,6 @@ class BundleFileStorageService: BundleStorageService {
1017
985
  }
1018
986
  }
1019
987
 
1020
- func canUseManifestDrivenInstall() -> Bool {
1021
- guard let currentBundleId = getCachedBundleId(),
1022
- case .success(let storeDir) = bundleStoreDir() else {
1023
- return false
1024
- }
1025
-
1026
- let currentBundleDir = (storeDir as NSString).appendingPathComponent(currentBundleId)
1027
- guard fileSystem.fileExists(atPath: currentBundleDir),
1028
- let snapshot = getActiveBundleMetadataSnapshot(),
1029
- let currentManifest = parseBundleManifest(from: snapshot.manifest) else {
1030
- return false
1031
- }
1032
-
1033
- return currentManifest.assets.isEmpty == false
1034
- }
1035
-
1036
988
  private func resolveActiveBundleMetadataSnapshot(
1037
989
  activeBundleId: String,
1038
990
  bundleDirectory: String
@@ -1041,9 +993,7 @@ class BundleFileStorageService: BundleStorageService {
1041
993
  let manifestBundleId =
1042
994
  (manifest["bundleId"] as? String)?
1043
995
  .trimmingCharacters(in: .whitespacesAndNewlines)
1044
- let resolvedBundleId =
1045
- (manifestBundleId?.isEmpty == false ? manifestBundleId : nil) ??
1046
- readCompatibilityBundleId(in: bundleDirectory)
996
+ let resolvedBundleId = manifestBundleId?.isEmpty == false ? manifestBundleId : nil
1047
997
 
1048
998
  return ActiveBundleMetadataSnapshot(
1049
999
  activeBundleId: activeBundleId,
@@ -1070,34 +1020,6 @@ class BundleFileStorageService: BundleStorageService {
1070
1020
  )
1071
1021
  }
1072
1022
 
1073
- private func readCompatibilityBundleId(in bundleDirectory: String) -> String? {
1074
- let compatibilityBundleIdPath = (bundleDirectory as NSString)
1075
- .appendingPathComponent(compatibilityBundleIdFilename())
1076
- if fileSystem.fileExists(atPath: compatibilityBundleIdPath) {
1077
- do {
1078
- let compatibilityBundleId = try String(
1079
- contentsOfFile: compatibilityBundleIdPath,
1080
- encoding: .utf8
1081
- )
1082
- .trimmingCharacters(in: .whitespacesAndNewlines)
1083
-
1084
- if !compatibilityBundleId.isEmpty {
1085
- return compatibilityBundleId
1086
- }
1087
- } catch {
1088
- NSLog(
1089
- "[BundleStorage] Failed to read compatibility bundle metadata at \(compatibilityBundleIdPath): \(error.localizedDescription)"
1090
- )
1091
- }
1092
- }
1093
-
1094
- return nil
1095
- }
1096
-
1097
- private func compatibilityBundleIdFilename() -> String {
1098
- "BUNDLE_ID"
1099
- }
1100
-
1101
1023
  private func readManifest(in bundleDirectory: String) -> [String: Any]? {
1102
1024
  let manifestPath = (bundleDirectory as NSString).appendingPathComponent("manifest.json")
1103
1025
  guard fileSystem.fileExists(atPath: manifestPath) else {
@@ -1124,7 +1046,7 @@ class BundleFileStorageService: BundleStorageService {
1124
1046
 
1125
1047
  var assets: [String: ParsedManifestAsset] = [:]
1126
1048
  for (assetPath, assetValue) in rawAssets {
1127
- guard ArchiveExtractionUtilities.normalizedRelativePath(from: assetPath) == assetPath else {
1049
+ guard FileUtilities.normalizedRelativePath(from: assetPath) == assetPath else {
1128
1050
  return nil
1129
1051
  }
1130
1052
  guard let asset = assetValue as? [String: Any],
@@ -1137,42 +1059,136 @@ class BundleFileStorageService: BundleStorageService {
1137
1059
  .trimmingCharacters(in: .whitespacesAndNewlines)
1138
1060
  assets[assetPath] = ParsedManifestAsset(
1139
1061
  fileHash: fileHash,
1140
- signature: signature?.isEmpty == false ? signature : nil
1062
+ signature: signature?.isEmpty == false ? signature : nil,
1063
+ byteSize: Self.parseSafeByteSize(asset["byteSize"]),
1064
+ downloadByteSize: Self.parseSafeByteSize(asset["downloadByteSize"])
1065
+ )
1066
+ }
1067
+
1068
+ let archive: ParsedArchive?
1069
+ if let payload = manifest["archive"] as? [String: Any],
1070
+ let downloadFileHash = payload["downloadFileHash"] as? String,
1071
+ Self.isPlainSHA256(downloadFileHash),
1072
+ let downloadByteSize = Self.parseSafeByteSize(payload["downloadByteSize"]),
1073
+ downloadByteSize > 0,
1074
+ let tarByteSize = Self.parseSafeByteSize(payload["tarByteSize"]),
1075
+ tarByteSize >= 1024 {
1076
+ archive = ParsedArchive(
1077
+ downloadFileHash: downloadFileHash,
1078
+ downloadByteSize: downloadByteSize,
1079
+ tarByteSize: tarByteSize
1141
1080
  )
1081
+ } else {
1082
+ archive = nil
1142
1083
  }
1143
1084
 
1144
- return ParsedBundleManifest(bundleId: manifestBundleId, assets: assets)
1085
+ return ParsedBundleManifest(
1086
+ bundleId: manifestBundleId,
1087
+ assets: assets,
1088
+ archive: archive
1089
+ )
1145
1090
  }
1146
1091
 
1147
- private func parseBundleManifest(fromFile manifestPath: String) -> ParsedBundleManifest? {
1148
- guard let manifest = readManifest(in: (manifestPath as NSString).deletingLastPathComponent) else {
1092
+ private static func parseSafeByteSize(_ value: Any?) -> Int64? {
1093
+ guard let number = value as? NSNumber,
1094
+ String(cString: number.objCType) != "c" else {
1095
+ return nil
1096
+ }
1097
+ let doubleValue = number.doubleValue
1098
+ guard doubleValue.isFinite,
1099
+ doubleValue >= 0,
1100
+ doubleValue <= Double(maximumSafeByteSize),
1101
+ doubleValue.rounded(.towardZero) == doubleValue else {
1149
1102
  return nil
1150
1103
  }
1104
+ return Int64(doubleValue)
1105
+ }
1151
1106
 
1152
- return parseBundleManifest(from: manifest)
1107
+ private static func isPlainSHA256(_ value: String) -> Bool {
1108
+ value.count == 64 && value.allSatisfy {
1109
+ ("0"..."9").contains($0) || ("a"..."f").contains($0)
1110
+ }
1153
1111
  }
1154
1112
 
1155
- private func writeManifestFile(_ manifest: ParsedBundleManifest, to destination: String) throws {
1113
+ private func copyVerifiedManifest(from source: String, to destination: String) throws {
1156
1114
  let manifestDirectory = (destination as NSString).deletingLastPathComponent
1157
1115
  guard fileSystem.createDirectory(atPath: manifestDirectory) else {
1158
1116
  throw BundleStorageError.directoryCreationFailed
1159
1117
  }
1118
+ if fileSystem.fileExists(atPath: destination) {
1119
+ try fileSystem.removeItem(atPath: destination)
1120
+ }
1121
+ do {
1122
+ try fileSystem.copyItem(atPath: source, toPath: destination)
1123
+ } catch {
1124
+ throw BundleStorageError.moveOperationFailed(error)
1125
+ }
1126
+ }
1127
+
1128
+ private func shouldUseArchive(
1129
+ archive: ParsedArchive?,
1130
+ archiveUrl: URL?,
1131
+ downloads: [String: ChangedAssetDescriptor],
1132
+ assets: [String: ParsedManifestAsset]
1133
+ ) -> Bool {
1134
+ guard downloads.count >= 2,
1135
+ archive != nil,
1136
+ archiveUrl != nil,
1137
+ assets.values.allSatisfy({ $0.byteSize != nil }) else {
1138
+ return false
1139
+ }
1160
1140
 
1161
- let assets = manifest.assets
1162
- .sorted { $0.key < $1.key }
1163
- .reduce(into: [String: [String: String]]()) { partialResult, entry in
1164
- var assetPayload = ["fileHash": entry.value.fileHash]
1165
- if let signature = entry.value.signature, !signature.isEmpty {
1166
- assetPayload["signature"] = signature
1141
+ var individualLowerBound: Int64 = 0
1142
+ var hasOfferedPatch = false
1143
+ for (path, descriptor) in downloads {
1144
+ guard let originalByteSize = assets[path]?.downloadByteSize else {
1145
+ return false
1146
+ }
1147
+ let plannedByteSize: Int64
1148
+ if let patch = descriptor.patch {
1149
+ hasOfferedPatch = true
1150
+ guard let patchByteSize = patch.byteSize,
1151
+ patchByteSize >= 0,
1152
+ patchByteSize <= Self.maximumSafeByteSize else {
1153
+ return false
1167
1154
  }
1168
- partialResult[entry.key] = assetPayload
1155
+ plannedByteSize = min(originalByteSize, patchByteSize)
1156
+ } else {
1157
+ plannedByteSize = originalByteSize
1169
1158
  }
1170
- let payload: [String: Any] = [
1171
- "bundleId": manifest.bundleId,
1172
- "assets": assets,
1173
- ]
1174
- let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])
1175
- try data.write(to: URL(fileURLWithPath: destination))
1159
+ let (sum, overflowed) = individualLowerBound.addingReportingOverflow(plannedByteSize)
1160
+ guard !overflowed,
1161
+ sum <= Self.maximumSafeByteSize else { return false }
1162
+ individualLowerBound = sum
1163
+ }
1164
+
1165
+ guard let archive else { return false }
1166
+ var logicalByteSize: Int64 = 0
1167
+ for asset in assets.values {
1168
+ guard let byteSize = asset.byteSize else { return false }
1169
+ let (sum, overflowed) = logicalByteSize.addingReportingOverflow(byteSize)
1170
+ guard !overflowed,
1171
+ sum <= Self.maximumSafeByteSize else { return false }
1172
+ logicalByteSize = sum
1173
+ }
1174
+ let (tarOverhead, overheadUnderflowed) = archive.tarByteSize
1175
+ .subtractingReportingOverflow(logicalByteSize)
1176
+ guard !overheadUnderflowed,
1177
+ tarOverhead >= 0 else { return false }
1178
+
1179
+ if archive.downloadByteSize <= individualLowerBound {
1180
+ return true
1181
+ }
1182
+
1183
+ guard downloads.count == assets.count,
1184
+ !hasOfferedPatch else {
1185
+ return false
1186
+ }
1187
+ let (archiveBound, boundOverflowed) = individualLowerBound
1188
+ .addingReportingOverflow(tarOverhead)
1189
+ guard !boundOverflowed,
1190
+ archiveBound <= Self.maximumSafeByteSize else { return false }
1191
+ return archive.downloadByteSize <= archiveBound
1176
1192
  }
1177
1193
 
1178
1194
  /**
@@ -1539,6 +1555,7 @@ class BundleFileStorageService: BundleStorageService {
1539
1555
  metadata.stagingBundleId = nil
1540
1556
  metadata.stagingSelection = selection
1541
1557
  metadata.verificationPending = false
1558
+ metadata.launchInProgress = false
1542
1559
  metadata.pendingTransition = nil
1543
1560
  metadata.pendingSelectionTransition = nil
1544
1561
  }
@@ -1666,7 +1683,7 @@ class BundleFileStorageService: BundleStorageService {
1666
1683
  do {
1667
1684
  var bundlePaths: [String] = []
1668
1685
  for assetPath in parsedManifest.assets.keys {
1669
- let assetURL = try ArchiveExtractionUtilities.extractionURL(
1686
+ let assetURL = try FileUtilities.fileURL(
1670
1687
  for: assetPath,
1671
1688
  destinationRoot: directoryPath
1672
1689
  )
@@ -1688,38 +1705,7 @@ class BundleFileStorageService: BundleStorageService {
1688
1705
  }
1689
1706
  }
1690
1707
 
1691
- let iosBundlePath = (directoryPath as NSString).appendingPathComponent("index.ios.bundle")
1692
- if self.fileSystem.fileExists(atPath: iosBundlePath) {
1693
- NSLog("[BundleStorage] Found iOS bundle atPath: \(iosBundlePath)")
1694
- return .success(iosBundlePath)
1695
- }
1696
-
1697
- let mainBundlePath = (directoryPath as NSString).appendingPathComponent("main.jsbundle")
1698
- if self.fileSystem.fileExists(atPath: mainBundlePath) {
1699
- NSLog("[BundleStorage] Found main bundle atPath: \(mainBundlePath)")
1700
- return .success(mainBundlePath)
1701
- }
1702
-
1703
- do {
1704
- let contents = try self.fileSystem.contentsOfDirectory(atPath: directoryPath)
1705
- NSLog("[BundleStorage] Directory contents: \(contents)")
1706
-
1707
- // Additional search: check all .bundle files
1708
- for file in contents {
1709
- if file.hasSuffix(".bundle") {
1710
- let bundlePath = (directoryPath as NSString).appendingPathComponent(file)
1711
- NSLog("[BundleStorage] Found alternative bundle atPath: \(bundlePath)")
1712
- return .success(bundlePath)
1713
- }
1714
- }
1715
-
1716
- NSLog("[BundleStorage] No bundle file found in directory: \(directoryPath)")
1717
- NSLog("[BundleStorage] Available files: \(contents)")
1718
- return .success(nil)
1719
- } catch let error {
1720
- NSLog("[BundleStorage] Error reading directory contents: \(error.localizedDescription)")
1721
- return .failure(error)
1722
- }
1708
+ return .success(nil)
1723
1709
  }
1724
1710
 
1725
1711
  /**
@@ -1752,7 +1738,7 @@ class BundleFileStorageService: BundleStorageService {
1752
1738
  let bundles = contents.compactMap { item -> String? in
1753
1739
  let fullPath = (storeDir as NSString).appendingPathComponent(item)
1754
1740
 
1755
- if protectedBundleStoreEntries.contains(item) {
1741
+ if protectedBundleStoreEntries.contains(item) || item.hasSuffix(".install-backup") {
1756
1742
  return nil
1757
1743
  }
1758
1744
 
@@ -1864,11 +1850,11 @@ class BundleFileStorageService: BundleStorageService {
1864
1850
  }
1865
1851
 
1866
1852
  let relativePath = String(bundleURL.path.dropFirst(sourcePrefix.count))
1867
- guard ArchiveExtractionUtilities.normalizedRelativePath(from: relativePath) == relativePath else {
1853
+ guard FileUtilities.normalizedRelativePath(from: relativePath) == relativePath else {
1868
1854
  throw BundleStorageError.invalidBundle
1869
1855
  }
1870
1856
 
1871
- return try ArchiveExtractionUtilities.extractionURL(
1857
+ return try FileUtilities.fileURL(
1872
1858
  for: relativePath,
1873
1859
  destinationRoot: destinationDirectory
1874
1860
  ).path
@@ -1924,8 +1910,8 @@ class BundleFileStorageService: BundleStorageService {
1924
1910
  private func selectLaunch(bundle: Bundle) -> LaunchSelection {
1925
1911
  guard let metadata = loadMetadataOrNull() else {
1926
1912
  return LaunchSelection(
1927
- bundleURL: getCachedBundleURL() ?? getFallbackBundleURL(bundle: bundle),
1928
- launchedBundleId: getCachedBundleId(),
1913
+ bundleURL: getFallbackBundleURL(bundle: bundle),
1914
+ launchedBundleId: nil,
1929
1915
  shouldRollbackOnCrash: false
1930
1916
  )
1931
1917
  }
@@ -1972,13 +1958,29 @@ class BundleFileStorageService: BundleStorageService {
1972
1958
  }
1973
1959
 
1974
1960
  func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection {
1961
+ builtInAssetResolver.use(bundle: bundle)
1975
1962
  saveLaunchReport(nil)
1976
1963
  if loadInstallIdentity() == nil {
1977
1964
  let identity = InstallIdentity()
1978
1965
  _ = saveInstallIdentity(identity)
1979
1966
  }
1980
1967
  applyPendingRecoveryIfNeeded(pendingRecovery)
1981
- return selectLaunch(bundle: bundle)
1968
+ // Only a new storage instance consumes an unfinished launch. Repeated bundle
1969
+ // lookups or bridge reloads in this process must not reject its own launch.
1970
+ if !hasPreparedLaunch,
1971
+ let metadata = loadMetadataOrNull(),
1972
+ metadata.verificationPending,
1973
+ metadata.launchInProgress,
1974
+ let stagingBundleId = metadata.stagingBundleId {
1975
+ rollbackPendingBundle(stagingBundleId)
1976
+ }
1977
+ hasPreparedLaunch = true
1978
+ let selection = selectLaunch(bundle: bundle)
1979
+ if selection.shouldRollbackOnCrash, var metadata = loadMetadataOrNull() {
1980
+ metadata.launchInProgress = true
1981
+ _ = saveMetadata(metadata)
1982
+ }
1983
+ return selection
1982
1984
  }
1983
1985
 
1984
1986
  // MARK: - Bundle Update
@@ -1986,12 +1988,12 @@ class BundleFileStorageService: BundleStorageService {
1986
1988
  /**
1987
1989
  * Updates the bundle from the specified URL. This operation is asynchronous.
1988
1990
  * @param bundleId ID of the bundle to update
1989
- * @param fileUrl URL of the bundle file to download (or nil to reset)
1990
- * @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
1991
+ * @param manifestUrl URL of the complete target manifest
1992
+ * @param manifestFileHash Hash or signature of the target manifest
1991
1993
  * @param progressHandler Callback for download and extraction progress (0.0 to 1.0)
1992
1994
  * @param completion Callback with result of the operation
1993
1995
  */
1994
- func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, manifestUrl: URL?, manifestFileHash: String?, changedAssets: [String: ChangedAssetDescriptor]?, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<Bool, Error>) -> Void) {
1996
+ func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL? = nil, assets: [String: ChangedAssetDescriptor], progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<Bool, Error>) -> Void) {
1995
1997
  // Check if bundle is in crashed history
1996
1998
  let crashedHistory = loadCrashedHistory()
1997
1999
  if crashedHistory.contains(bundleId) {
@@ -2000,928 +2002,682 @@ class BundleFileStorageService: BundleStorageService {
2000
2002
  return
2001
2003
  }
2002
2004
 
2003
- // Get the current bundle ID from the cached bundle URL (exclude fallback bundles)
2004
- let currentBundleId = self.getCachedBundleId()
2005
-
2006
- guard let validFileUrl = fileUrl else {
2007
- NSLog("[BundleStorage] fileUrl is nil, resetting bundle URL.")
2008
- // Dispatch the sequence to the file operation queue to ensure completion is called asynchronously
2009
- // and to keep file operations off the calling thread if it's the main thread.
2010
- fileOperationQueue.async {
2011
- let setResult = self.setBundleURL(localPath: nil)
2012
- switch setResult {
2013
- case .success:
2014
- let previousMetadata = self.loadMetadataOrNull()
2015
- var resetMetadata = self.createInitialMetadata()
2016
- resetMetadata.highestSeenCatalogs = previousMetadata?.highestSeenCatalogs ?? [:]
2017
- resetMetadata.currentSelectionContexts = previousMetadata?.currentSelectionContexts ?? [:]
2018
- let _ = self.saveMetadata(resetMetadata)
2019
- self.saveLaunchReport(nil)
2020
- let cleanupResult = self.cleanupOldBundles(currentBundleId: currentBundleId, bundleId: bundleId)
2021
- switch cleanupResult {
2022
- case .success:
2023
- completion(.success(true))
2024
- case .failure(let error):
2025
- NSLog("[BundleStorage] Error during cleanup after reset: \(error)")
2026
- completion(.failure(error))
2027
- }
2028
- case .failure(let error):
2029
- NSLog("[BundleStorage] Error resetting bundle URL: \(error)")
2030
- completion(.failure(error))
2031
- }
2032
- }
2033
- return
2034
- }
2035
-
2036
- // Start the bundle update process on a background queue
2037
- fileOperationQueue.async {
2038
-
2005
+ fileOperationQueue.async(flags: .barrier) {
2039
2006
  let storeDirResult = self.bundleStoreDir()
2040
2007
  guard case .success(let storeDir) = storeDirResult else {
2041
2008
  completion(.failure(storeDirResult.failureError ?? BundleStorageError.unknown(nil)))
2042
2009
  return
2043
2010
  }
2044
-
2045
- let finalBundleDir = (storeDir as NSString).appendingPathComponent(bundleId)
2046
-
2047
- if self.fileSystem.fileExists(atPath: finalBundleDir) {
2048
- let findResult = self.findBundleFile(
2049
- in: finalBundleDir,
2050
- expectedBundleId: bundleId
2051
- )
2052
- switch findResult {
2053
- case .success(let existingBundlePath):
2054
- if let bundlePath = existingBundlePath {
2055
- NSLog("[BundleStorage] Using cached bundle at path: \(bundlePath)")
2056
- let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
2057
- guard let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId) else {
2058
- completion(.failure(BundleStorageError.unknown(
2059
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2060
- NSLocalizedDescriptionKey: "Release catalog selection is stale"
2061
- ])
2062
- )))
2063
- return
2064
- }
2065
- let setResult = self.setBundleURL(localPath: bundlePath)
2066
- switch setResult {
2067
- case .success:
2068
- let _ = self.saveMetadata(updatedMetadata)
2069
- NSLog("[BundleStorage] Set staging bundle (cached): \(bundleId), verificationPending: true")
2070
-
2071
- self.emitArchiveProgress(
2072
- progressHandler: progressHandler,
2073
- progress: UpdateProgress.complete
2074
- )
2075
- self.scheduleCleanupOldBundles(
2076
- bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
2077
- )
2078
- completion(.success(true))
2079
- case .failure(let error):
2080
- completion(.failure(error))
2081
- }
2082
- return
2083
- } else {
2084
- NSLog("[BundleStorage] Cached directory exists but invalid, removing: \(finalBundleDir)")
2085
- do {
2086
- try self.fileSystem.removeItem(atPath: finalBundleDir)
2087
- // Continue with download process on success
2088
- if let manifestUrl,
2089
- let manifestFileHash,
2090
- let changedAssets,
2091
- self.canUseManifestDrivenInstall() {
2092
- self.updateBundleFromManifest(
2093
- bundleId: bundleId,
2094
- fileUrl: validFileUrl,
2095
- fileHash: fileHash,
2096
- manifestUrl: manifestUrl,
2097
- manifestFileHash: manifestFileHash,
2098
- changedAssets: changedAssets,
2099
- storeDir: storeDir,
2100
- progressHandler: progressHandler,
2101
- completion: completion
2102
- )
2103
- } else {
2104
- if manifestUrl != nil && manifestFileHash != nil && changedAssets != nil {
2105
- NSLog("[BundleStorage] Skipping manifest-driven install for \(bundleId) because no active OTA manifest is available. Using archive.")
2106
- }
2107
- self.prepareAndDownloadBundle(bundleId: bundleId, fileUrl: validFileUrl, fileHash: fileHash, storeDir: storeDir, progressHandler: progressHandler, completion: completion)
2108
- }
2109
- } catch let error {
2110
- NSLog("[BundleStorage] Failed to remove invalid bundle dir: \(error.localizedDescription)")
2111
- completion(.failure(BundleStorageError.unknown(error)))
2112
- }
2113
- }
2114
- case .failure(let error):
2115
- completion(.failure(error))
2116
- }
2117
- } else {
2118
- if let manifestUrl,
2119
- let manifestFileHash,
2120
- let changedAssets,
2121
- self.canUseManifestDrivenInstall() {
2122
- self.updateBundleFromManifest(
2123
- bundleId: bundleId,
2124
- fileUrl: validFileUrl,
2125
- fileHash: fileHash,
2126
- manifestUrl: manifestUrl,
2127
- manifestFileHash: manifestFileHash,
2128
- changedAssets: changedAssets,
2129
- storeDir: storeDir,
2130
- progressHandler: progressHandler,
2131
- completion: completion
2132
- )
2133
- } else {
2134
- if manifestUrl != nil && manifestFileHash != nil && changedAssets != nil {
2135
- NSLog("[BundleStorage] Skipping manifest-driven install for \(bundleId) because no active OTA manifest is available. Using archive.")
2136
- }
2137
- self.prepareAndDownloadBundle(bundleId: bundleId, fileUrl: validFileUrl, fileHash: fileHash, storeDir: storeDir, progressHandler: progressHandler, completion: completion)
2138
- }
2139
- }
2011
+ self.updateBundleFromManifest(
2012
+ bundleId: bundleId,
2013
+ manifestUrl: manifestUrl,
2014
+ manifestFileHash: manifestFileHash,
2015
+ archiveUrl: archiveUrl,
2016
+ changedAssets: assets,
2017
+ storeDir: storeDir,
2018
+ progressHandler: progressHandler,
2019
+ completion: completion
2020
+ )
2140
2021
  }
2141
2022
  }
2142
-
2143
- /**
2144
- * Prepares directories and starts the download process.
2145
- * This method is part of the asynchronous `updateBundle` flow.
2146
- * @param bundleId ID of the bundle to update
2147
- * @param fileUrl URL of the bundle file to download
2148
- * @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
2149
- * @param storeDir Path to the bundle-store directory
2150
- * @param progressHandler Callback for download and extraction progress
2151
- * @param completion Callback with result of the operation
2152
- */
2153
- private func updateBundleFromManifest(
2154
- bundleId: String,
2155
- fileUrl: URL,
2156
- fileHash: String?,
2157
- manifestUrl: URL,
2158
- manifestFileHash: String,
2159
- changedAssets: [String: ChangedAssetDescriptor],
2160
- storeDir: String,
2161
- progressHandler: @escaping (UpdateProgressPayload) -> Void,
2162
- completion: @escaping (Result<Bool, Error>) -> Void
2163
- ) {
2164
- let tempDirResult = tempDir()
2165
- guard case .success(let tempDirectory) = tempDirResult else {
2166
- completion(.failure(tempDirResult.failureError ?? BundleStorageError.unknown(nil)))
2167
- return
2168
- }
2169
2023
 
2170
- try? self.fileSystem.removeItem(atPath: tempDirectory)
2171
- guard self.fileSystem.createDirectory(atPath: tempDirectory) else {
2172
- completion(.failure(BundleStorageError.directoryCreationFailed))
2024
+ private func downloadManifestAsset(
2025
+ assetPath: String,
2026
+ expectedAsset: ParsedManifestAsset,
2027
+ changedAsset: ChangedAssetDescriptor,
2028
+ currentBundleId: String?,
2029
+ currentBundleDir: String?,
2030
+ destinationPath: String,
2031
+ tempDirectory: String,
2032
+ progressFile: UpdateProgressPayload.DiffProgressFileSnapshot,
2033
+ progressHandler: @escaping (UpdateProgressPayload) -> Void
2034
+ ) throws {
2035
+ let expectedHash = expectedAsset.fileHash
2036
+ var diffFiles = [progressFile]
2037
+ let patched = applyPatchAssetIfPossible(
2038
+ assetPath: assetPath,
2039
+ changedAsset: changedAsset,
2040
+ currentBundleId: currentBundleId,
2041
+ currentBundleDir: currentBundleDir,
2042
+ destinationPath: destinationPath,
2043
+ expectedHash: expectedHash,
2044
+ tempDirectory: tempDirectory,
2045
+ files: &diffFiles,
2046
+ progressHandler: progressHandler
2047
+ )
2048
+ if patched {
2049
+ try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2050
+ updateDiffProgressFile(
2051
+ files: &diffFiles,
2052
+ assetPath: assetPath,
2053
+ status: "downloaded",
2054
+ progress: 1
2055
+ )
2056
+ self.emitDiffProgress(
2057
+ progressHandler: progressHandler,
2058
+ phase: diffFiles.allSatisfy { $0.status == "downloaded" }
2059
+ ? "finalizing"
2060
+ : "downloading",
2061
+ files: diffFiles
2062
+ )
2173
2063
  return
2174
2064
  }
2175
2065
 
2176
- let currentBundleId = self.getCachedBundleId()
2177
- let currentBundleDir = currentBundleId.map {
2178
- (storeDir as NSString).appendingPathComponent($0)
2179
- }
2180
- let tempManifestPath = (tempDirectory as NSString).appendingPathComponent("manifest.json")
2181
- let tmpDir = (storeDir as NSString).appendingPathComponent("\(bundleId).tmp")
2182
- let realDir = (storeDir as NSString).appendingPathComponent(bundleId)
2183
- var diffFiles = createDiffProgressFiles(changedAssets: changedAssets)
2184
-
2185
- do {
2066
+ guard let changedAssetFileUrl = changedAsset.fileUrl else {
2067
+ updateDiffProgressFile(
2068
+ files: &diffFiles,
2069
+ assetPath: assetPath,
2070
+ status: "failed",
2071
+ progress: 0
2072
+ )
2186
2073
  self.emitDiffProgress(
2187
2074
  progressHandler: progressHandler,
2188
- phase: "manifest",
2189
- files: diffFiles,
2190
- manifestProgress: 0
2075
+ phase: "downloading",
2076
+ files: diffFiles
2191
2077
  )
2192
- switch self.downloadFileSynchronously(
2193
- from: manifestUrl,
2194
- to: tempManifestPath,
2195
- progressHandler: { progress in
2196
- self.emitDiffProgress(
2197
- progressHandler: progressHandler,
2198
- phase: "manifest",
2199
- files: diffFiles,
2200
- manifestProgress: progress.progress
2201
- )
2202
- }
2203
- ) {
2204
- case .success:
2205
- break
2206
- case .failure(let error):
2207
- if let downloadError = error as? DownloadError,
2208
- case .incompleteDownload(let expected, let actual) = downloadError {
2209
- throw BundleStorageError.incompleteDownload(expected: expected, actual: actual)
2210
- }
2211
- throw BundleStorageError.downloadFailed(error)
2212
- }
2078
+ throw BundleStorageError.downloadFailed(
2079
+ NSError(domain: "HotUpdater", code: 0, userInfo: [
2080
+ NSLocalizedDescriptionKey: "Changed asset fileUrl missing and patch could not be applied: \(assetPath)"
2081
+ ])
2082
+ )
2083
+ }
2213
2084
 
2214
- let manifestVerificationResult = SignatureVerifier.verifyBundle(
2215
- fileURL: URL(fileURLWithPath: tempManifestPath),
2216
- fileHash: manifestFileHash
2085
+ let changedAssetDownloadPath = downloadPath(
2086
+ assetPath: assetPath,
2087
+ changedAsset: changedAsset
2088
+ )
2089
+ let downloadDestinationPath: String
2090
+ if changedAsset.fileCompression == "br" {
2091
+ downloadDestinationPath = compressedTempPath(
2092
+ tempDirectory: tempDirectory,
2093
+ assetPath: assetPath,
2094
+ compression: "br"
2217
2095
  )
2218
- guard case .success = manifestVerificationResult else {
2219
- if case .failure(let error) = manifestVerificationResult {
2220
- throw BundleStorageError.signatureVerificationFailed(error)
2221
- }
2222
- throw BundleStorageError.unknown(nil)
2223
- }
2096
+ } else {
2097
+ downloadDestinationPath = destinationPath
2098
+ }
2224
2099
 
2225
- guard let manifestData = try? Data(contentsOf: URL(fileURLWithPath: tempManifestPath)),
2226
- let manifestJson = try JSONSerialization.jsonObject(with: manifestData) as? [String: Any],
2227
- let targetManifest = parseBundleManifest(from: manifestJson),
2228
- targetManifest.bundleId == bundleId
2229
- else {
2230
- throw BundleStorageError.invalidBundle
2100
+ switch self.downloadFileSynchronously(
2101
+ from: changedAssetFileUrl,
2102
+ to: downloadDestinationPath,
2103
+ progressHandler: { progress in
2104
+ self.updateDiffProgressFile(
2105
+ files: &diffFiles,
2106
+ assetPath: assetPath,
2107
+ status: "downloading",
2108
+ progress: progress.progress,
2109
+ downloadPath: changedAssetDownloadPath,
2110
+ downloadedBytes: progress.downloadedBytes,
2111
+ totalBytes: progress.totalBytes
2112
+ )
2113
+ self.emitDiffProgress(
2114
+ progressHandler: progressHandler,
2115
+ phase: "downloading",
2116
+ files: diffFiles
2117
+ )
2118
+ }
2119
+ ) {
2120
+ case .success(let downloadedFileURL):
2121
+ do {
2122
+ try materializeDownloadedAsset(
2123
+ downloadedPath: downloadedFileURL.path,
2124
+ destinationPath: destinationPath,
2125
+ changedAsset: changedAsset
2126
+ )
2127
+ try verifyManifestAssetFile(
2128
+ atPath: destinationPath,
2129
+ asset: expectedAsset
2130
+ )
2131
+ } catch {
2132
+ updateDiffProgressFile(
2133
+ files: &diffFiles,
2134
+ assetPath: assetPath,
2135
+ status: "failed",
2136
+ progress: 1
2137
+ )
2138
+ self.emitDiffProgress(
2139
+ progressHandler: progressHandler,
2140
+ phase: "downloading",
2141
+ files: diffFiles
2142
+ )
2143
+ throw error
2231
2144
  }
2145
+ updateDiffProgressFile(
2146
+ files: &diffFiles,
2147
+ assetPath: assetPath,
2148
+ status: "downloaded",
2149
+ progress: 1
2150
+ )
2232
2151
  self.emitDiffProgress(
2233
2152
  progressHandler: progressHandler,
2234
- phase: diffFiles.isEmpty ? "finalizing" : "downloading",
2153
+ phase: diffFiles.allSatisfy { $0.status == "downloaded" }
2154
+ ? "finalizing"
2155
+ : "downloading",
2235
2156
  files: diffFiles
2236
2157
  )
2237
-
2238
- let currentManifest = getActiveBundleMetadataSnapshot().flatMap { snapshot in
2239
- parseBundleManifest(from: snapshot.manifest)
2240
- }
2241
-
2242
- if self.fileSystem.fileExists(atPath: tmpDir) {
2243
- try self.fileSystem.removeItem(atPath: tmpDir)
2244
- }
2245
- guard self.fileSystem.createDirectory(atPath: tmpDir) else {
2246
- throw BundleStorageError.directoryCreationFailed
2158
+ case .failure(let error):
2159
+ let lastKnownProgress = diffFiles.first(where: { $0.path == assetPath })?.progress ?? 0
2160
+ updateDiffProgressFile(
2161
+ files: &diffFiles,
2162
+ assetPath: assetPath,
2163
+ status: "failed",
2164
+ progress: lastKnownProgress
2165
+ )
2166
+ self.emitDiffProgress(
2167
+ progressHandler: progressHandler,
2168
+ phase: "downloading",
2169
+ files: diffFiles
2170
+ )
2171
+ if let downloadError = error as? DownloadError,
2172
+ case .incompleteDownload(let expected, let actual) = downloadError {
2173
+ throw BundleStorageError.incompleteDownload(expected: expected, actual: actual)
2247
2174
  }
2175
+ throw BundleStorageError.downloadFailed(error)
2176
+ }
2177
+ }
2248
2178
 
2249
- let sortedAssets = targetManifest.assets.sorted { $0.key < $1.key }
2250
-
2251
- for asset in sortedAssets {
2252
- let assetPath = asset.key
2253
- let expectedAsset = asset.value
2254
- let expectedHash = expectedAsset.fileHash
2255
- let destinationPath = try ArchiveExtractionUtilities.extractionURL(
2256
- for: assetPath,
2257
- destinationRoot: tmpDir
2258
- ).path
2259
- let destinationDir = (destinationPath as NSString).deletingLastPathComponent
2260
- guard self.fileSystem.createDirectory(atPath: destinationDir) else {
2261
- throw BundleStorageError.directoryCreationFailed
2262
- }
2179
+ // Retain only target files on retry; partial files are rehashed before reuse.
2180
+ private func pruneStagingDirectory(_ directory: String, assets: Set<String>, prefix: String = "") throws {
2181
+ for name in try fileSystem.contentsOfDirectory(atPath: directory) {
2182
+ let relativePath = prefix + name
2183
+ let path = (directory as NSString).appendingPathComponent(name)
2184
+ let type = try fileSystem.attributesOfItem(atPath: path)[.type] as? FileAttributeType
2185
+ if type == .typeDirectory, assets.contains(where: { $0.hasPrefix(relativePath + "/") }) {
2186
+ try pruneStagingDirectory(path, assets: assets, prefix: relativePath + "/")
2187
+ } else if type != .typeRegular || !assets.contains(relativePath) {
2188
+ try fileSystem.removeItem(atPath: path)
2189
+ }
2190
+ }
2191
+ }
2263
2192
 
2264
- if currentManifest?.assets[assetPath]?.fileHash == expectedHash {
2265
- guard let currentBundleDir,
2266
- self.fileSystem.fileExists(atPath: currentBundleDir)
2267
- else {
2268
- throw BundleStorageError.downloadFailed(
2269
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2270
- NSLocalizedDescriptionKey: "Current bundle directory unavailable for reused asset: \(assetPath)"
2271
- ])
2272
- )
2273
- }
2193
+ private func installArchive(
2194
+ archive: ParsedArchive,
2195
+ archiveUrl: URL,
2196
+ targetManifest: ParsedBundleManifest,
2197
+ preparedDirectory: String,
2198
+ tempDirectory: String,
2199
+ progressHandler: @escaping (UpdateProgressPayload) -> Void
2200
+ ) throws -> [UpdateProgressPayload.DiffProgressFileSnapshot] {
2201
+ let archiveScratch = (tempDirectory as NSString).appendingPathComponent("archive")
2202
+ try? fileSystem.removeItem(atPath: archiveScratch)
2203
+ guard fileSystem.createDirectory(atPath: archiveScratch) else {
2204
+ throw BundleStorageError.directoryCreationFailed
2205
+ }
2206
+ defer { try? fileSystem.removeItem(atPath: archiveScratch) }
2274
2207
 
2275
- let sourcePath = try ArchiveExtractionUtilities.extractionURL(
2276
- for: assetPath,
2277
- destinationRoot: currentBundleDir
2278
- ).path
2279
- guard self.fileSystem.fileExists(atPath: sourcePath),
2280
- HashUtils.verifyHash(fileURL: URL(fileURLWithPath: sourcePath), expectedHash: expectedHash)
2281
- else {
2282
- throw BundleStorageError.signatureVerificationFailed(.fileHashMismatch)
2283
- }
2208
+ let compressedPath = (archiveScratch as NSString).appendingPathComponent("bundle.tar.br")
2209
+ let tarPath = (archiveScratch as NSString).appendingPathComponent("bundle.tar")
2210
+ let extractedDirectory = (archiveScratch as NSString).appendingPathComponent("extracted")
2211
+ var progressFiles = [UpdateProgressPayload.DiffProgressFileSnapshot(
2212
+ path: "bundle.tar.br",
2213
+ downloadPath: "bundle.tar.br",
2214
+ status: "pending",
2215
+ progress: 0,
2216
+ order: 0
2217
+ )]
2218
+ emitDiffProgress(
2219
+ progressHandler: progressHandler,
2220
+ phase: "downloading",
2221
+ files: progressFiles
2222
+ )
2284
2223
 
2285
- try self.fileSystem.copyItem(atPath: sourcePath, toPath: destinationPath)
2286
- try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2287
- continue
2288
- }
2224
+ switch downloadFileSynchronously(
2225
+ from: archiveUrl,
2226
+ to: compressedPath,
2227
+ progressHandler: { progress in
2228
+ self.updateDiffProgressFile(
2229
+ files: &progressFiles,
2230
+ assetPath: "bundle.tar.br",
2231
+ status: "downloading",
2232
+ progress: progress.progress,
2233
+ downloadPath: "bundle.tar.br",
2234
+ downloadedBytes: progress.downloadedBytes,
2235
+ totalBytes: progress.totalBytes
2236
+ )
2237
+ self.emitDiffProgress(
2238
+ progressHandler: progressHandler,
2239
+ phase: "downloading",
2240
+ files: progressFiles
2241
+ )
2242
+ }
2243
+ ) {
2244
+ case .success:
2245
+ break
2246
+ case .failure(let error):
2247
+ throw BundleStorageError.downloadFailed(error)
2248
+ }
2289
2249
 
2290
- guard let changedAsset = changedAssets[assetPath] else {
2291
- updateDiffProgressFile(
2292
- files: &diffFiles,
2293
- assetPath: assetPath,
2294
- status: "failed",
2295
- progress: 0
2296
- )
2297
- self.emitDiffProgress(
2298
- progressHandler: progressHandler,
2299
- phase: "downloading",
2300
- files: diffFiles
2301
- )
2302
- throw BundleStorageError.downloadFailed(
2303
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2304
- NSLocalizedDescriptionKey: "Changed asset missing from update response: \(assetPath)"
2305
- ])
2306
- )
2307
- }
2250
+ let compressedAttributes = try fileSystem.attributesOfItem(atPath: compressedPath)
2251
+ guard let compressedSize = compressedAttributes[.size] as? NSNumber,
2252
+ compressedSize.int64Value == archive.downloadByteSize,
2253
+ HashUtils.verifyHash(
2254
+ fileURL: URL(fileURLWithPath: compressedPath),
2255
+ expectedHash: archive.downloadFileHash
2256
+ ) else {
2257
+ throw BundleStorageError.invalidBundle
2258
+ }
2308
2259
 
2309
- guard changedAsset.fileHash.caseInsensitiveCompare(expectedHash) == .orderedSame else {
2310
- updateDiffProgressFile(
2311
- files: &diffFiles,
2312
- assetPath: assetPath,
2313
- status: "failed",
2314
- progress: 0
2315
- )
2316
- self.emitDiffProgress(
2317
- progressHandler: progressHandler,
2318
- phase: "downloading",
2319
- files: diffFiles
2320
- )
2321
- throw BundleStorageError.signatureVerificationFailed(.fileHashMismatch)
2322
- }
2260
+ try BrotliFileDecompressor.decompress(
2261
+ from: compressedPath,
2262
+ to: tarPath,
2263
+ expectedOutputByteSize: archive.tarByteSize
2264
+ )
2323
2265
 
2324
- let patched = applyPatchAssetIfPossible(
2325
- assetPath: assetPath,
2326
- changedAsset: changedAsset,
2327
- currentBundleId: currentBundleId,
2328
- currentBundleDir: currentBundleDir,
2329
- destinationPath: destinationPath,
2330
- expectedHash: expectedHash,
2331
- tempDirectory: tempDirectory,
2332
- files: &diffFiles,
2333
- progressHandler: progressHandler
2334
- )
2335
- if patched {
2336
- try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2337
- updateDiffProgressFile(
2338
- files: &diffFiles,
2339
- assetPath: assetPath,
2340
- status: "downloaded",
2341
- progress: 1
2342
- )
2343
- self.emitDiffProgress(
2344
- progressHandler: progressHandler,
2345
- phase: diffFiles.allSatisfy { $0.status == "downloaded" }
2346
- ? "finalizing"
2347
- : "downloading",
2348
- files: diffFiles
2349
- )
2350
- continue
2266
+ let expectedFiles = try targetManifest.assets.reduce(
2267
+ into: [String: Int64](),
2268
+ { result, entry in
2269
+ guard let byteSize = entry.value.byteSize else {
2270
+ throw BundleStorageError.invalidBundle
2351
2271
  }
2272
+ result[entry.key] = byteSize
2273
+ }
2274
+ )
2275
+ try TarArchiveExtractor.extract(
2276
+ from: tarPath,
2277
+ to: extractedDirectory,
2278
+ expectedFiles: expectedFiles
2279
+ )
2280
+ for (path, asset) in targetManifest.assets {
2281
+ let extractedPath = try FileUtilities.fileURL(
2282
+ for: path,
2283
+ destinationRoot: extractedDirectory
2284
+ ).path
2285
+ try verifyManifestAssetFile(atPath: extractedPath, asset: asset)
2286
+ }
2352
2287
 
2353
- guard let changedAssetFileUrl = changedAsset.fileUrl else {
2354
- updateDiffProgressFile(
2355
- files: &diffFiles,
2356
- assetPath: assetPath,
2357
- status: "failed",
2358
- progress: 0
2359
- )
2360
- self.emitDiffProgress(
2361
- progressHandler: progressHandler,
2362
- phase: "downloading",
2363
- files: diffFiles
2364
- )
2365
- throw BundleStorageError.downloadFailed(
2366
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2367
- NSLocalizedDescriptionKey: "Changed asset fileUrl missing and patch could not be applied: \(assetPath)"
2368
- ])
2369
- )
2370
- }
2288
+ try promoteVerifiedArchiveDirectory(
2289
+ preparedDirectory,
2290
+ withArchiveDirectory: extractedDirectory
2291
+ )
2371
2292
 
2372
- let changedAssetDownloadPath = downloadPath(
2373
- assetPath: assetPath,
2374
- changedAsset: changedAsset
2375
- )
2376
- let downloadDestinationPath: String
2377
- if changedAsset.fileCompression == "br" {
2378
- downloadDestinationPath = compressedTempPath(
2379
- tempDirectory: tempDirectory,
2380
- assetPath: assetPath,
2381
- compression: "br"
2382
- )
2383
- } else {
2384
- downloadDestinationPath = destinationPath
2385
- }
2293
+ updateDiffProgressFile(
2294
+ files: &progressFiles,
2295
+ assetPath: "bundle.tar.br",
2296
+ status: "downloaded",
2297
+ progress: 1,
2298
+ downloadedBytes: archive.downloadByteSize,
2299
+ totalBytes: archive.downloadByteSize
2300
+ )
2301
+ emitDiffProgress(
2302
+ progressHandler: progressHandler,
2303
+ phase: "finalizing",
2304
+ files: progressFiles
2305
+ )
2306
+ return progressFiles
2307
+ }
2386
2308
 
2387
- switch self.downloadFileSynchronously(
2388
- from: changedAssetFileUrl,
2389
- to: downloadDestinationPath,
2390
- progressHandler: { progress in
2391
- self.updateDiffProgressFile(
2392
- files: &diffFiles,
2393
- assetPath: assetPath,
2394
- status: "downloading",
2395
- progress: progress.progress,
2396
- downloadPath: changedAssetDownloadPath,
2397
- downloadedBytes: progress.downloadedBytes,
2398
- totalBytes: progress.totalBytes
2399
- )
2400
- self.emitDiffProgress(
2401
- progressHandler: progressHandler,
2402
- phase: "downloading",
2403
- files: diffFiles
2404
- )
2405
- }
2406
- ) {
2407
- case .success(let downloadedFileURL):
2408
- do {
2409
- try materializeDownloadedAsset(
2410
- downloadedPath: downloadedFileURL.path,
2411
- destinationPath: destinationPath,
2412
- changedAsset: changedAsset
2413
- )
2414
- try verifyManifestAssetFile(
2415
- atPath: destinationPath,
2416
- asset: expectedAsset
2417
- )
2418
- } catch {
2419
- updateDiffProgressFile(
2420
- files: &diffFiles,
2421
- assetPath: assetPath,
2422
- status: "failed",
2423
- progress: 1
2424
- )
2425
- self.emitDiffProgress(
2426
- progressHandler: progressHandler,
2427
- phase: "downloading",
2428
- files: diffFiles
2429
- )
2430
- throw error
2431
- }
2432
- updateDiffProgressFile(
2433
- files: &diffFiles,
2434
- assetPath: assetPath,
2435
- status: "downloaded",
2436
- progress: 1
2437
- )
2438
- self.emitDiffProgress(
2439
- progressHandler: progressHandler,
2440
- phase: diffFiles.allSatisfy { $0.status == "downloaded" }
2441
- ? "finalizing"
2442
- : "downloading",
2443
- files: diffFiles
2444
- )
2445
- case .failure(let error):
2446
- let lastKnownProgress = diffFiles.first(where: { $0.path == assetPath })?.progress ?? 0
2447
- updateDiffProgressFile(
2448
- files: &diffFiles,
2449
- assetPath: assetPath,
2450
- status: "failed",
2451
- progress: lastKnownProgress
2452
- )
2453
- self.emitDiffProgress(
2454
- progressHandler: progressHandler,
2455
- phase: "downloading",
2456
- files: diffFiles
2457
- )
2458
- if let downloadError = error as? DownloadError,
2459
- case .incompleteDownload(let expected, let actual) = downloadError {
2460
- throw BundleStorageError.incompleteDownload(expected: expected, actual: actual)
2461
- }
2462
- throw BundleStorageError.downloadFailed(error)
2463
- }
2464
- }
2309
+ private func promoteVerifiedArchiveDirectory(
2310
+ _ preparedDirectory: String,
2311
+ withArchiveDirectory archiveDirectory: String
2312
+ ) throws {
2313
+ let backupDirectory = preparedDirectory + ".archive-backup"
2314
+ try? fileSystem.removeItem(atPath: backupDirectory)
2315
+ try fileSystem.moveItem(atPath: preparedDirectory, toPath: backupDirectory)
2465
2316
 
2466
- self.emitDiffProgress(
2467
- progressHandler: progressHandler,
2468
- phase: "finalizing",
2469
- files: diffFiles
2317
+ do {
2318
+ try fileSystem.moveItem(
2319
+ atPath: archiveDirectory,
2320
+ toPath: preparedDirectory
2470
2321
  )
2322
+ } catch {
2323
+ try? fileSystem.removeItem(atPath: preparedDirectory)
2324
+ do {
2325
+ try fileSystem.moveItem(atPath: backupDirectory, toPath: preparedDirectory)
2326
+ } catch let restoreError {
2327
+ throw BundleStorageError.moveOperationFailed(restoreError)
2328
+ }
2329
+ throw error
2330
+ }
2331
+ try? fileSystem.removeItem(atPath: backupDirectory)
2332
+ }
2471
2333
 
2472
- let manifestDestination = (tmpDir as NSString).appendingPathComponent("manifest.json")
2473
- try writeManifestFile(targetManifest, to: manifestDestination)
2474
-
2475
- switch self.findBundleFile(in: tmpDir, expectedBundleId: bundleId) {
2476
- case .success(let maybeBundlePath):
2477
- guard let bundlePathInTmp = maybeBundlePath else {
2478
- throw BundleStorageError.invalidBundle
2479
- }
2334
+ private func recoverInterruptedPromotions() {
2335
+ guard case .success(let storeDir) = bundleStoreDir(),
2336
+ let entries = try? fileSystem.contentsOfDirectory(atPath: storeDir) else { return }
2337
+ for entry in entries where entry.hasSuffix(".install-backup") {
2338
+ let finalDirectory = (storeDir as NSString).appendingPathComponent(String(entry.dropLast(".install-backup".count)))
2339
+ do {
2340
+ try recoverInterruptedPromotion(finalDirectory: finalDirectory)
2341
+ } catch {
2342
+ NSLog("[BundleStorage] Cannot recover interrupted promotion: \(error)")
2343
+ }
2344
+ }
2345
+ }
2480
2346
 
2481
- if self.fileSystem.fileExists(atPath: realDir) {
2482
- try self.fileSystem.removeItem(atPath: realDir)
2483
- }
2347
+ private func recoverInterruptedPromotion(finalDirectory: String) throws {
2348
+ let backup = finalDirectory + ".install-backup"
2349
+ guard fileSystem.fileExists(atPath: backup) else { return }
2350
+ if fileSystem.fileExists(atPath: finalDirectory) {
2351
+ if isCompleteBundleDirectory(finalDirectory) {
2352
+ try fileSystem.removeItem(atPath: backup)
2353
+ return
2354
+ }
2355
+ try fileSystem.removeItem(atPath: finalDirectory)
2356
+ }
2357
+ try fileSystem.moveItem(atPath: backup, toPath: finalDirectory)
2358
+ }
2484
2359
 
2485
- do {
2486
- try self.fileSystem.moveItem(atPath: tmpDir, toPath: realDir)
2487
- } catch {
2488
- throw BundleStorageError.moveOperationFailed(error)
2489
- }
2360
+ private func isCompleteBundleDirectory(_ directory: String) -> Bool {
2361
+ let bundleId = (directory as NSString).lastPathComponent
2362
+ guard let manifest = readManifest(in: directory),
2363
+ let parsed = parseBundleManifest(from: manifest),
2364
+ parsed.bundleId == bundleId,
2365
+ case .success(let bundlePath) = findBundleFile(in: directory, expectedBundleId: bundleId),
2366
+ bundlePath != nil else { return false }
2367
+ return parsed.assets.allSatisfy { path, asset in
2368
+ guard let file = try? FileUtilities.fileURL(for: path, destinationRoot: directory) else { return false }
2369
+ return (try? verifyManifestAssetFile(atPath: file.path, asset: asset)) != nil
2370
+ }
2371
+ }
2490
2372
 
2491
- let finalBundlePath = try resolveBundlePathAfterMove(
2492
- bundlePathInTmp,
2493
- from: tmpDir,
2494
- to: realDir
2495
- )
2496
- let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
2497
- guard let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId) else {
2498
- self.cleanupTemporaryFiles([tempDirectory])
2499
- completion(.failure(BundleStorageError.unknown(
2500
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2501
- NSLocalizedDescriptionKey: "Release catalog selection is stale"
2502
- ])
2503
- )))
2504
- return
2505
- }
2506
- switch self.setBundleURL(localPath: finalBundlePath) {
2507
- case .success:
2508
- let _ = self.saveMetadata(updatedMetadata)
2509
- self.cleanupTemporaryFiles([tempDirectory])
2510
- self.scheduleCleanupOldBundles(
2511
- bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
2512
- )
2513
- progressHandler(
2514
- UpdateProgressPayload(
2515
- progress: UpdateProgress.complete,
2516
- artifactType: "diff",
2517
- details: UpdateProgressPayload.DiffProgressDetails(
2518
- totalFilesCount: diffFiles.count,
2519
- completedFilesCount: diffFiles.filter { $0.status == "downloaded" }.count,
2520
- files: diffFiles
2521
- )
2522
- )
2523
- )
2524
- completion(.success(true))
2525
- case .failure(let error):
2526
- try? self.fileSystem.removeItem(atPath: realDir)
2527
- self.cleanupTemporaryFiles([tempDirectory])
2528
- completion(.failure(error))
2529
- }
2530
- case .failure(let error):
2531
- throw error
2373
+ private func promoteStagedBundle(
2374
+ bundleId: String,
2375
+ stagingDirectory: String,
2376
+ finalDirectory: String,
2377
+ bundlePathInStaging: String
2378
+ ) throws -> BundleMetadata {
2379
+ releaseStateLock.lock()
2380
+ defer { releaseStateLock.unlock() }
2381
+ let currentMetadata = loadMetadataOrNull() ?? createInitialMetadata()
2382
+ guard let updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId) else {
2383
+ throw BundleStorageError.unknown(NSError(domain: "HotUpdater", code: 0,
2384
+ userInfo: [NSLocalizedDescriptionKey: "Release catalog selection is stale"]))
2385
+ }
2386
+ let finalBundlePath = try resolveBundlePathAfterMove(
2387
+ bundlePathInStaging, from: stagingDirectory, to: finalDirectory
2388
+ )
2389
+ let previousPreference = try preferences.getItem(forKey: "HotUpdaterBundleURL")
2390
+ let backup = finalDirectory + ".install-backup"
2391
+ try recoverInterruptedPromotion(finalDirectory: finalDirectory)
2392
+ if fileSystem.fileExists(atPath: finalDirectory) {
2393
+ try fileSystem.moveItem(atPath: finalDirectory, toPath: backup)
2394
+ }
2395
+ var promoted = false
2396
+ do {
2397
+ try fileSystem.moveItem(atPath: stagingDirectory, toPath: finalDirectory)
2398
+ promoted = true
2399
+ try setBundleURL(localPath: finalBundlePath).get()
2400
+ // Metadata is the durable activation point. Launch never uses an
2401
+ // untracked preference if this write fails or the process terminates.
2402
+ guard saveMetadata(updatedMetadata) else {
2403
+ throw BundleStorageError.unknown(NSError(domain: "HotUpdater", code: 0,
2404
+ userInfo: [NSLocalizedDescriptionKey: "Failed to persist bundle activation metadata"]))
2532
2405
  }
2533
2406
  } catch {
2534
- NSLog("[BundleStorage] Manifest-driven install failed: \(error.localizedDescription). Falling back to archive.")
2535
- try? self.fileSystem.removeItem(atPath: tmpDir)
2536
- self.cleanupTemporaryFiles([tempDirectory])
2537
- self.prepareAndDownloadBundle(
2538
- bundleId: bundleId,
2539
- fileUrl: fileUrl,
2540
- fileHash: fileHash,
2541
- storeDir: storeDir,
2542
- progressHandler: progressHandler,
2543
- completion: completion
2544
- )
2407
+ let installError = error
2408
+ let preferenceResult = setBundleURL(localPath: previousPreference)
2409
+ if promoted { try fileSystem.removeItem(atPath: finalDirectory) }
2410
+ if fileSystem.fileExists(atPath: backup) {
2411
+ try fileSystem.moveItem(atPath: backup, toPath: finalDirectory)
2412
+ }
2413
+ try preferenceResult.get()
2414
+ throw installError
2545
2415
  }
2416
+ try? fileSystem.removeItem(atPath: backup)
2417
+ return updatedMetadata
2546
2418
  }
2547
2419
 
2548
- private func prepareAndDownloadBundle(
2420
+ private func updateBundleFromManifest(
2549
2421
  bundleId: String,
2550
- fileUrl: URL,
2551
- fileHash: String?,
2422
+ manifestUrl: URL,
2423
+ manifestFileHash: String,
2424
+ archiveUrl: URL?,
2425
+ changedAssets: [String: ChangedAssetDescriptor],
2552
2426
  storeDir: String,
2553
2427
  progressHandler: @escaping (UpdateProgressPayload) -> Void,
2554
2428
  completion: @escaping (Result<Bool, Error>) -> Void
2555
2429
  ) {
2556
- // 1) Prepare temp directory for download
2557
2430
  let tempDirResult = tempDir()
2558
2431
  guard case .success(let tempDirectory) = tempDirResult else {
2559
2432
  completion(.failure(tempDirResult.failureError ?? BundleStorageError.unknown(nil)))
2560
2433
  return
2561
2434
  }
2562
-
2563
- // 2) Clean up any previous temp dir
2435
+
2564
2436
  try? self.fileSystem.removeItem(atPath: tempDirectory)
2565
-
2566
- // 3) Create temp dir
2567
- if !self.fileSystem.createDirectory(atPath: tempDirectory) {
2437
+ guard self.fileSystem.createDirectory(atPath: tempDirectory) else {
2568
2438
  completion(.failure(BundleStorageError.directoryCreationFailed))
2569
2439
  return
2570
2440
  }
2571
2441
 
2572
- // 4) Determine bundle filename from URL
2573
- let bundleFileName = fileUrl.lastPathComponent.isEmpty ? "bundle.zip" : fileUrl.lastPathComponent
2574
- let tempBundleFile = (tempDirectory as NSString).appendingPathComponent(bundleFileName)
2575
-
2576
- NSLog("[BundleStorage] Starting download from \(fileUrl)")
2577
-
2578
- // Download with integrated disk space check
2579
- var diskSpaceError: BundleStorageError? = nil
2580
-
2581
- _ = self.downloadService.downloadFile(
2582
- from: fileUrl,
2583
- to: tempBundleFile,
2584
- fileSizeHandler: { fileSize in
2585
- // This will be called when Content-Length is received
2586
- NSLog("[BundleStorage] File size received: \(fileSize) bytes")
2587
-
2588
- // Check available disk space
2589
- do {
2590
- let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
2591
- if let freeSize = attributes[.systemFreeSize] as? Int64 {
2592
- let requiredSpace = fileSize * 2 // ZIP + extracted files
2593
-
2594
- NSLog("[BundleStorage] Available: \(freeSize) bytes, Required: \(requiredSpace) bytes")
2442
+ let currentBundleId = self.getCachedBundleId()
2443
+ let currentBundleDir = currentBundleId.map {
2444
+ (storeDir as NSString).appendingPathComponent($0)
2445
+ }
2446
+ let tempManifestPath = (tempDirectory as NSString).appendingPathComponent("manifest.json")
2447
+ let tmpDir = (storeDir as NSString).appendingPathComponent("\(bundleId).tmp")
2448
+ let realDir = (storeDir as NSString).appendingPathComponent(bundleId)
2449
+ var diffFiles: [UpdateProgressPayload.DiffProgressFileSnapshot] = []
2595
2450
 
2596
- if freeSize < requiredSpace {
2597
- NSLog("[BundleStorage] Insufficient disk space detected: need \(requiredSpace) bytes, available \(freeSize) bytes")
2598
- // Store error to be returned in completion handler
2599
- diskSpaceError = .insufficientDiskSpace
2600
- }
2601
- }
2602
- } catch {
2603
- NSLog("[BundleStorage] Failed to check disk space: \(error.localizedDescription)")
2451
+ do {
2452
+ try recoverInterruptedPromotion(finalDirectory: realDir)
2453
+ self.emitDiffProgress(
2454
+ progressHandler: progressHandler,
2455
+ phase: "manifest",
2456
+ files: diffFiles,
2457
+ manifestProgress: 0
2458
+ )
2459
+ switch self.downloadFileSynchronously(
2460
+ from: manifestUrl,
2461
+ to: tempManifestPath,
2462
+ progressHandler: { progress in
2463
+ self.emitDiffProgress(
2464
+ progressHandler: progressHandler,
2465
+ phase: "manifest",
2466
+ files: diffFiles,
2467
+ manifestProgress: progress.progress
2468
+ )
2604
2469
  }
2605
- },
2606
- progressHandler: { downloadProgress in
2607
- self.emitArchiveProgress(
2608
- progressHandler: progressHandler,
2609
- progress: Self.mapProgress(
2610
- downloadProgress.progress,
2611
- start: 0,
2612
- end: UpdateProgress.downloadEnd
2613
- ),
2614
- downloadedBytes: downloadProgress.downloadedBytes,
2615
- totalBytes: downloadProgress.totalBytes
2616
- )
2617
- },
2618
- completion: { [weak self] result in
2619
- guard let self = self else {
2620
- let error = NSError(domain: "HotUpdaterError", code: 998,
2621
- userInfo: [NSLocalizedDescriptionKey: "Self deallocated during download"])
2622
- completion(.failure(error))
2623
- return
2624
- }
2625
-
2626
- // Check for disk space error first before processing download result
2627
- if let diskError = diskSpaceError {
2628
- NSLog("[BundleStorage] Throwing disk space error")
2629
- self.cleanupTemporaryFiles([tempDirectory])
2630
- completion(.failure(diskError))
2631
- return
2470
+ ) {
2471
+ case .success:
2472
+ break
2473
+ case .failure(let error):
2474
+ if let downloadError = error as? DownloadError,
2475
+ case .incompleteDownload(let expected, let actual) = downloadError {
2476
+ throw BundleStorageError.incompleteDownload(expected: expected, actual: actual)
2477
+ }
2478
+ throw BundleStorageError.downloadFailed(error)
2632
2479
  }
2633
2480
 
2634
- // Dispatch the processing of the downloaded file to the file operation queue
2635
- let workItem = DispatchWorkItem {
2636
- switch result {
2637
- case .success(let location):
2638
- self.processDownloadedFileWithTmp(location: location,
2639
- tempBundleFile: tempBundleFile,
2640
- fileHash: fileHash,
2641
- storeDir: storeDir,
2642
- bundleId: bundleId,
2643
- tempDirectory: tempDirectory,
2644
- progressHandler: progressHandler,
2645
- completion: completion)
2646
- case .failure(let error):
2647
- NSLog("[BundleStorage] Download failed: \(error.localizedDescription)")
2648
- self.cleanupTemporaryFiles([tempDirectory]) // Sync cleanup
2649
-
2650
- // Map DownloadError.incompleteDownload to BundleStorageError.incompleteDownload
2651
- if let downloadError = error as? DownloadError,
2652
- case .incompleteDownload(let expected, let actual) = downloadError {
2653
- completion(.failure(BundleStorageError.incompleteDownload(expected: expected, actual: actual)))
2654
- } else {
2655
- completion(.failure(BundleStorageError.downloadFailed(error)))
2656
- }
2481
+ let manifestVerificationResult = SignatureVerifier.verifyBundle(
2482
+ fileURL: URL(fileURLWithPath: tempManifestPath),
2483
+ fileHash: manifestFileHash
2484
+ )
2485
+ guard case .success = manifestVerificationResult else {
2486
+ if case .failure(let error) = manifestVerificationResult {
2487
+ throw BundleStorageError.signatureVerificationFailed(error)
2657
2488
  }
2489
+ throw BundleStorageError.unknown(nil)
2658
2490
  }
2659
- self.fileOperationQueue.async(execute: workItem)
2660
- }
2661
- )
2662
- }
2663
-
2664
- /**
2665
- * Logs detailed diagnostic information about a file system path.
2666
- * @param path The path to diagnose
2667
- * @param context Additional context for logging
2668
- */
2669
- private func logFileSystemDiagnostics(path: String, context: String) {
2670
- let fileManager = FileManager.default
2671
-
2672
- // Check if path exists
2673
- let exists = fileManager.fileExists(atPath: path)
2674
- NSLog("[BundleStorage] [\(context)] Path exists: \(exists) - \(path)")
2675
2491
 
2676
- if exists {
2677
- do {
2678
- let attributes = try fileManager.attributesOfItem(atPath: path)
2679
- let size = attributes[.size] as? Int64 ?? 0
2680
- let permissions = attributes[.posixPermissions] as? Int ?? 0
2681
- NSLog("[BundleStorage] [\(context)] Size: \(size) bytes, Permissions: \(String(permissions, radix: 8))")
2682
- } catch {
2683
- NSLog("[BundleStorage] [\(context)] Failed to get attributes: \(error.localizedDescription)")
2492
+ guard let manifestData = try? Data(contentsOf: URL(fileURLWithPath: tempManifestPath)),
2493
+ let manifestJson = try JSONSerialization.jsonObject(with: manifestData) as? [String: Any],
2494
+ let targetManifest = parseBundleManifest(from: manifestJson),
2495
+ targetManifest.bundleId == bundleId
2496
+ else {
2497
+ throw BundleStorageError.invalidBundle
2684
2498
  }
2685
- }
2686
-
2687
- // Check parent directory
2688
- let parentPath = (path as NSString).deletingLastPathComponent
2689
- let parentExists = fileManager.fileExists(atPath: parentPath)
2690
- NSLog("[BundleStorage] [\(context)] Parent directory exists: \(parentExists) - \(parentPath)")
2691
- }
2692
-
2693
- /**
2694
- * Processes a downloaded bundle file using the "tmp" rename approach.
2695
- * This method is part of the asynchronous `updateBundle` flow and is expected to run on a background thread.
2696
- * @param location URL of the downloaded file
2697
- * @param tempBundleFile Path to store the downloaded bundle file
2698
- * @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
2699
- * @param storeDir Path to the bundle-store directory
2700
- * @param bundleId ID of the bundle being processed
2701
- * @param tempDirectory Temporary directory for processing
2702
- * @param progressHandler Callback for download/apply progress (0.0 to 1.0)
2703
- * @param completion Callback with result of the operation
2704
- */
2705
- private func processDownloadedFileWithTmp(
2706
- location: URL,
2707
- tempBundleFile: String,
2708
- fileHash: String?,
2709
- storeDir: String,
2710
- bundleId: String,
2711
- tempDirectory: String,
2712
- progressHandler: @escaping (UpdateProgressPayload) -> Void,
2713
- completion: @escaping (Result<Bool, Error>) -> Void
2714
- ) {
2715
- let currentBundleId = self.getCachedBundleId()
2716
- NSLog("[BundleStorage] Processing downloaded file atPath: \(location.path)")
2717
-
2718
- // 1) Ensure the bundle file exists
2719
- guard self.fileSystem.fileExists(atPath: location.path) else {
2720
- logFileSystemDiagnostics(path: location.path, context: "Download Location Missing")
2721
- self.cleanupTemporaryFiles([tempDirectory])
2722
- completion(.failure(BundleStorageError.downloadFailed(NSError(
2723
- domain: "HotUpdaterError",
2724
- code: 1,
2725
- userInfo: [NSLocalizedDescriptionKey: "Downloaded file does not exist atPath: \(location.path)"]
2726
- ))))
2727
- return
2728
- }
2729
-
2730
- // 2) Define tmpDir and realDir
2731
- let tmpDir = (storeDir as NSString).appendingPathComponent("\(bundleId).tmp")
2732
- let realDir = (storeDir as NSString).appendingPathComponent(bundleId)
2733
-
2734
- do {
2735
- // 3) Remove any existing tmpDir
2736
- if self.fileSystem.fileExists(atPath: tmpDir) {
2737
- try self.fileSystem.removeItem(atPath: tmpDir)
2738
- NSLog("[BundleStorage] Removed existing tmpDir: \(tmpDir)")
2499
+ // Validate the entire wire contract even when every byte can be reused.
2500
+ guard Set(targetManifest.assets.keys) == Set(changedAssets.keys) else {
2501
+ throw BundleStorageError.invalidBundle
2502
+ }
2503
+ for (path, asset) in targetManifest.assets {
2504
+ guard FileUtilities.normalizedRelativePath(from: path) == path,
2505
+ path != "manifest.json",
2506
+ let descriptor = changedAssets[path],
2507
+ descriptor.fileUrl != nil,
2508
+ descriptor.fileHash.caseInsensitiveCompare(asset.fileHash) == .orderedSame else {
2509
+ throw BundleStorageError.invalidBundle
2510
+ }
2511
+ _ = try FileUtilities.fileURL(for: path, destinationRoot: tmpDir)
2739
2512
  }
2740
2513
 
2741
- // 4) Create tmpDir
2742
2514
  guard self.fileSystem.createDirectory(atPath: tmpDir) else {
2743
2515
  throw BundleStorageError.directoryCreationFailed
2744
2516
  }
2745
- NSLog("[BundleStorage] Created tmpDir: \(tmpDir)")
2746
- logFileSystemDiagnostics(path: tmpDir, context: "TmpDir Created")
2747
-
2748
- // 5) Verify bundle integrity (hash or signature based on fileHash format)
2749
- NSLog("[BundleStorage] Verifying bundle integrity...")
2750
- self.emitArchiveProgress(
2751
- progressHandler: progressHandler,
2752
- progress: UpdateProgress.verificationStart
2753
- )
2754
- let tempBundleURL = URL(fileURLWithPath: tempBundleFile)
2755
- let verificationResult = SignatureVerifier.verifyBundle(fileURL: tempBundleURL, fileHash: fileHash)
2756
- switch verificationResult {
2757
- case .success:
2758
- NSLog("[BundleStorage] Bundle verification completed successfully")
2759
- self.emitArchiveProgress(
2760
- progressHandler: progressHandler,
2761
- progress: UpdateProgress.verificationEnd
2762
- )
2763
- case .failure(let error):
2764
- NSLog("[BundleStorage] Bundle verification failed: \(error)")
2765
- try? self.fileSystem.removeItem(atPath: tmpDir)
2766
- self.cleanupTemporaryFiles([tempDirectory])
2767
- completion(.failure(BundleStorageError.signatureVerificationFailed(error)))
2768
- return
2517
+ try pruneStagingDirectory(tmpDir, assets: Set(targetManifest.assets.keys))
2518
+ let sortedAssets = targetManifest.assets.sorted { $0.key < $1.key }
2519
+ let localDirectories = [currentBundleDir, realDir].compactMap { $0 }.filter { fileSystem.fileExists(atPath: $0) }
2520
+ var downloads: [String: ChangedAssetDescriptor] = [:]
2521
+ for (index, asset) in sortedAssets.enumerated() {
2522
+ // Local preparation has its own progress interval, with no download files.
2523
+ progressHandler(UpdateProgressPayload(
2524
+ progress: 0.15 + 0.05 * Double(index) / Double(max(1, sortedAssets.count)),
2525
+ artifactType: "diff",
2526
+ details: .init(totalFilesCount: 0, completedFilesCount: 0)
2527
+ ))
2528
+ let assetPath = asset.key
2529
+ let expectedAsset = asset.value
2530
+ let destinationPath = try FileUtilities.fileURL(for: assetPath, destinationRoot: tmpDir).path
2531
+ guard self.fileSystem.createDirectory(atPath: (destinationPath as NSString).deletingLastPathComponent) else {
2532
+ throw BundleStorageError.directoryCreationFailed
2533
+ }
2534
+ if (try? fileSystem.attributesOfItem(atPath: destinationPath)[.type] as? FileAttributeType) == .typeRegular,
2535
+ (try? verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)) != nil {
2536
+ continue
2537
+ }
2538
+ if fileSystem.fileExists(atPath: destinationPath) { try self.fileSystem.removeItem(atPath: destinationPath) }
2539
+
2540
+ var reused = false
2541
+ // The cached target is only a candidate, just like the active OTA.
2542
+ for directory in localDirectories {
2543
+ guard let sourcePath = try? FileUtilities.fileURL(for: assetPath, destinationRoot: directory).path,
2544
+ (try? fileSystem.attributesOfItem(atPath: sourcePath)[.type] as? FileAttributeType) == .typeRegular,
2545
+ (try? verifyManifestAssetFile(atPath: sourcePath, asset: expectedAsset)) != nil else { continue }
2546
+ do {
2547
+ try self.fileSystem.copyItem(atPath: sourcePath, toPath: destinationPath)
2548
+ try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2549
+ reused = true
2550
+ break
2551
+ } catch {
2552
+ try? self.fileSystem.removeItem(atPath: destinationPath)
2553
+ }
2554
+ }
2555
+ if reused { continue }
2556
+ if builtInAssetResolver.copyIfMatches(assetPath: assetPath, expectedHash: expectedAsset.fileHash, destination: destinationPath) {
2557
+ try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
2558
+ continue
2559
+ }
2560
+ downloads[assetPath] = changedAssets[assetPath]
2769
2561
  }
2770
2562
 
2771
- // 6) Unzip directly into tmpDir with progress tracking (0.8 - 1.0)
2772
- NSLog("[BundleStorage] Extracting \(tempBundleFile) → \(tmpDir)")
2773
- logFileSystemDiagnostics(path: tempBundleFile, context: "Before Extraction")
2774
- do {
2775
- try self.decompressService.unzip(file: tempBundleFile, to: tmpDir, progressHandler: { unzipProgress in
2776
- self.emitArchiveProgress(
2777
- progressHandler: progressHandler,
2778
- progress: Self.mapProgress(
2779
- unzipProgress,
2780
- start: UpdateProgress.extractionStart,
2781
- end: UpdateProgress.extractionEnd
2782
- )
2563
+ var archiveInstalled = false
2564
+ if shouldUseArchive(
2565
+ archive: targetManifest.archive,
2566
+ archiveUrl: archiveUrl,
2567
+ downloads: downloads,
2568
+ assets: targetManifest.assets
2569
+ ), let archive = targetManifest.archive, let archiveUrl {
2570
+ do {
2571
+ diffFiles = try installArchive(
2572
+ archive: archive,
2573
+ archiveUrl: archiveUrl,
2574
+ targetManifest: targetManifest,
2575
+ preparedDirectory: tmpDir,
2576
+ tempDirectory: tempDirectory,
2577
+ progressHandler: progressHandler
2783
2578
  )
2784
- })
2785
- NSLog("[BundleStorage] Extraction complete at \(tmpDir)")
2786
- logFileSystemDiagnostics(path: tmpDir, context: "After Extraction")
2787
- } catch {
2788
- let nsError = error as NSError
2789
- NSLog("[BundleStorage] Extraction failed - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
2790
- logFileSystemDiagnostics(path: tmpDir, context: "Extraction Failed")
2791
- try? self.fileSystem.removeItem(atPath: tmpDir)
2792
- self.cleanupTemporaryFiles([tempDirectory])
2793
- completion(.failure(BundleStorageError.extractionFormatError(error)))
2794
- return
2579
+ archiveInstalled = true
2580
+ } catch {
2581
+ NSLog("[BundleStorage] tar.br optimization failed; falling back to individual files: \(error.localizedDescription)")
2582
+ guard fileSystem.fileExists(atPath: tmpDir) else {
2583
+ throw error
2584
+ }
2585
+ }
2795
2586
  }
2796
2587
 
2797
- // 7) Remove the downloaded bundle file
2798
- try? self.fileSystem.removeItem(atPath: tempBundleFile)
2588
+ if !archiveInstalled {
2589
+ diffFiles = createDiffProgressFiles(changedAssets: downloads)
2590
+ emitDiffProgress(progressHandler: progressHandler, phase: "downloading", files: diffFiles)
2591
+ let progressLock = NSLock()
2592
+ let workers = OperationQueue()
2593
+ workers.maxConcurrentOperationCount = 4
2594
+ var installError: Error?
2595
+ for progressFile in diffFiles {
2596
+ let assetPath = progressFile.path
2597
+ let expectedAsset = targetManifest.assets[assetPath]!
2598
+ let descriptor = downloads[assetPath]!
2599
+ let destinationPath = try FileUtilities.fileURL(for: assetPath, destinationRoot: tmpDir).path
2600
+ // Separate temp namespaces also avoid collisions between flattened asset paths.
2601
+ let workerTemp = (tempDirectory as NSString).appendingPathComponent(String(progressFile.order))
2602
+ workers.addOperation {
2603
+ progressLock.lock()
2604
+ let shouldSkip = installError != nil
2605
+ progressLock.unlock()
2606
+ if shouldSkip { return }
2607
+ do {
2608
+ guard self.fileSystem.createDirectory(atPath: workerTemp) else { throw BundleStorageError.directoryCreationFailed }
2609
+ try self.downloadManifestAsset(
2610
+ assetPath: assetPath, expectedAsset: expectedAsset, changedAsset: descriptor,
2611
+ currentBundleId: currentBundleId, currentBundleDir: currentBundleDir,
2612
+ destinationPath: destinationPath, tempDirectory: workerTemp, progressFile: progressFile,
2613
+ progressHandler: { payload in
2614
+ progressLock.lock()
2615
+ defer { progressLock.unlock() }
2616
+ if let snapshot = payload.details?.files.first,
2617
+ let index = diffFiles.firstIndex(where: { $0.path == assetPath }) {
2618
+ diffFiles[index] = snapshot
2619
+ }
2620
+ self.emitDiffProgress(progressHandler: progressHandler, phase: "downloading", files: diffFiles)
2621
+ }
2622
+ )
2623
+ } catch {
2624
+ progressLock.lock()
2625
+ if installError == nil { installError = error }
2626
+ progressLock.unlock()
2627
+ }
2628
+ }
2629
+ }
2630
+ // Join all workers before cleaning scratch files, even after a failure.
2631
+ workers.waitUntilAllOperationsAreFinished()
2632
+ if let installError { throw installError }
2633
+ }
2799
2634
 
2800
- // 8) Verify that a valid bundle file exists inside tmpDir
2801
- self.emitArchiveProgress(
2635
+ self.emitDiffProgress(
2802
2636
  progressHandler: progressHandler,
2803
- progress: UpdateProgress.bundleValidation
2637
+ phase: "finalizing",
2638
+ files: diffFiles
2804
2639
  )
2805
- switch self.findBundleFile(in: tmpDir, expectedBundleId: bundleId) {
2806
- case .success(let maybeBundlePath):
2807
- if let bundlePathInTmp = maybeBundlePath {
2808
- NSLog("[BundleStorage] Found valid bundle in tmpDir: \(bundlePathInTmp)")
2809
- logFileSystemDiagnostics(path: bundlePathInTmp, context: "Bundle Found")
2810
-
2811
- // 9) Remove any existing realDir
2812
- if self.fileSystem.fileExists(atPath: realDir) {
2813
- try self.fileSystem.removeItem(atPath: realDir)
2814
- NSLog("[BundleStorage] Removed existing realDir: \(realDir)")
2815
- }
2816
2640
 
2817
- // 10) Rename (move) tmpDir → realDir
2818
- do {
2819
- try self.fileSystem.moveItem(atPath: tmpDir, toPath: realDir)
2820
- NSLog("[BundleStorage] Renamed tmpDir to realDir: \(realDir)")
2821
- logFileSystemDiagnostics(path: realDir, context: "After Move")
2822
- } catch {
2823
- let nsError = error as NSError
2824
- NSLog("[BundleStorage] Move operation failed - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
2825
- logFileSystemDiagnostics(path: tmpDir, context: "Move Failed - Source")
2826
- logFileSystemDiagnostics(path: realDir, context: "Move Failed - Destination")
2827
- throw BundleStorageError.moveOperationFailed(error)
2828
- }
2829
-
2830
- // 11) Construct final bundlePath for preferences
2831
- let finalBundlePath = try resolveBundlePathAfterMove(
2832
- bundlePathInTmp,
2833
- from: tmpDir,
2834
- to: realDir
2835
- )
2641
+ let manifestDestination = (tmpDir as NSString).appendingPathComponent("manifest.json")
2642
+ try copyVerifiedManifest(from: tempManifestPath, to: manifestDestination)
2836
2643
 
2837
- // 12) Set the bundle URL in preferences (for backwards compatibility)
2838
- let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
2839
- guard let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId) else {
2840
- self.cleanupTemporaryFiles([tempDirectory])
2841
- completion(.failure(BundleStorageError.unknown(
2842
- NSError(domain: "HotUpdater", code: 0, userInfo: [
2843
- NSLocalizedDescriptionKey: "Release catalog selection is stale"
2844
- ])
2845
- )))
2846
- return
2847
- }
2848
- let setResult = self.setBundleURL(localPath: finalBundlePath)
2849
- switch setResult {
2850
- case .success:
2851
- NSLog("[BundleStorage] Successfully set bundle URL: \(finalBundlePath)")
2852
-
2853
- // 13) Set staging metadata for rollback support
2854
- let _ = self.saveMetadata(updatedMetadata)
2855
- NSLog("[BundleStorage] Set staging bundle: \(bundleId), verificationPending: true")
2856
-
2857
- // 14) Clean up the temporary directory
2858
- self.cleanupTemporaryFiles([tempDirectory])
2859
-
2860
- self.emitArchiveProgress(
2861
- progressHandler: progressHandler,
2862
- progress: UpdateProgress.activationReady
2863
- )
2864
- self.scheduleCleanupOldBundles(
2865
- bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
2866
- )
2867
-
2868
- // 15) Complete with success
2869
- self.emitArchiveProgress(
2870
- progressHandler: progressHandler,
2871
- progress: UpdateProgress.complete
2872
- )
2873
- completion(.success(true))
2874
- case .failure(let err):
2875
- let nsError = err as NSError
2876
- NSLog("[BundleStorage] Failed to set bundle URL - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
2877
- // Preferences save failed → remove realDir and clean up
2878
- try? self.fileSystem.removeItem(atPath: realDir)
2879
- self.cleanupTemporaryFiles([tempDirectory])
2880
- completion(.failure(err))
2881
- }
2882
- } else {
2883
- // No valid .jsbundle found → delete tmpDir and fail
2884
- NSLog("[BundleStorage] No valid bundle file found in tmpDir")
2885
- logFileSystemDiagnostics(path: tmpDir, context: "Invalid Bundle")
2886
- try? self.fileSystem.removeItem(atPath: tmpDir)
2887
- self.cleanupTemporaryFiles([tempDirectory])
2888
- completion(.failure(BundleStorageError.invalidBundle))
2644
+ switch self.findBundleFile(in: tmpDir, expectedBundleId: bundleId) {
2645
+ case .success(let maybeBundlePath):
2646
+ guard let bundlePathInTmp = maybeBundlePath else {
2647
+ throw BundleStorageError.invalidBundle
2889
2648
  }
2890
- case .failure(let findError):
2891
- let nsError = findError as NSError
2892
- NSLog("[BundleStorage] Error finding bundle file - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
2893
- // Error scanning tmpDir → delete tmpDir and fail
2894
- try? self.fileSystem.removeItem(atPath: tmpDir)
2649
+
2650
+ let updatedMetadata = try promoteStagedBundle(
2651
+ bundleId: bundleId,
2652
+ stagingDirectory: tmpDir,
2653
+ finalDirectory: realDir,
2654
+ bundlePathInStaging: bundlePathInTmp
2655
+ )
2895
2656
  self.cleanupTemporaryFiles([tempDirectory])
2896
- completion(.failure(findError))
2657
+ self.scheduleCleanupOldBundles(
2658
+ bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
2659
+ )
2660
+ progressHandler(UpdateProgressPayload(
2661
+ progress: 1.0,
2662
+ artifactType: "diff",
2663
+ details: .init(
2664
+ totalFilesCount: diffFiles.count,
2665
+ completedFilesCount: diffFiles.filter { $0.status == "downloaded" }.count,
2666
+ files: diffFiles
2667
+ )
2668
+ ))
2669
+ completion(.success(true))
2670
+ case .failure(let error):
2671
+ throw error
2897
2672
  }
2898
- } catch let error {
2899
- // Any failure during unzip or rename → clean tmpDir and fail
2900
- let nsError = error as NSError
2901
- NSLog("[BundleStorage] Error during tmpDir processing - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
2902
- logFileSystemDiagnostics(path: tmpDir, context: "Processing Error")
2903
- try? self.fileSystem.removeItem(atPath: tmpDir)
2673
+ } catch {
2674
+ NSLog("[BundleStorage] Manifest-driven install failed: \(error.localizedDescription).")
2675
+ // Preserve staging for a hash-checked retry. Never trust its presence alone.
2904
2676
  self.cleanupTemporaryFiles([tempDirectory])
2905
-
2906
- // Re-throw specific BundleStorageError if it is one, otherwise wrap as unknown
2907
- if let bundleError = error as? BundleStorageError {
2908
- completion(.failure(bundleError))
2909
- } else {
2910
- completion(.failure(BundleStorageError.unknown(error)))
2911
- }
2677
+ completion(.failure(error))
2912
2678
  }
2913
2679
  }
2914
2680
 
2915
- private static func mapProgress(_ value: Double, start: Double, end: Double) -> Double {
2916
- let clampedValue = min(max(value, 0), 1)
2917
- return start + (clampedValue * (end - start))
2918
- }
2919
-
2920
- // MARK: - Rollback Support
2921
-
2922
- /**
2923
- * Marks the current launch as successful after the first content appeared.
2924
- */
2925
2681
  func markLaunchCompleted(bundleId: String?) {
2926
2682
  guard let bundleId,
2927
2683
  var metadata = loadMetadataOrNull(),
@@ -2935,6 +2691,7 @@ class BundleFileStorageService: BundleStorageService {
2935
2691
  metadata.stableBundleId = nil
2936
2692
  metadata.stableSelection = nil
2937
2693
  metadata.verificationPending = false
2694
+ metadata.launchInProgress = false
2938
2695
  metadata.pendingTransition = nil
2939
2696
  metadata.pendingSelectionTransition = nil
2940
2697
  metadata.updatedAt = Date().timeIntervalSince1970 * 1000