@hot-updater/react-native 0.30.12 → 0.31.1

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 (119) hide show
  1. package/HotUpdater.podspec +1 -0
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/hotupdater/BsdiffPatch.kt +284 -0
  4. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +885 -5
  5. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +13 -2
  6. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +37 -8
  7. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +48 -12
  8. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +60 -22
  9. package/android/src/newarch/HotUpdater.kt +14 -2
  10. package/android/src/newarch/HotUpdaterModule.kt +112 -1
  11. package/android/src/oldarch/HotUpdater.kt +14 -2
  12. package/android/src/oldarch/HotUpdaterModule.kt +107 -3
  13. package/android/src/test/java/com/hotupdater/BsdiffPatchTest.kt +46 -0
  14. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +29 -1
  15. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +4 -1
  16. package/ios/HotUpdater/Internal/BsdiffPatchBridge.h +16 -0
  17. package/ios/HotUpdater/Internal/BsdiffPatchBridge.mm +343 -0
  18. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +1093 -47
  19. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +2 -0
  20. package/ios/HotUpdater/Internal/HotUpdater.mm +12 -2
  21. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +64 -5
  22. package/ios/HotUpdater/Internal/NotificationExtension.swift +2 -1
  23. package/ios/HotUpdater/Internal/SignatureVerifier.swift +28 -5
  24. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +11 -0
  25. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +11 -5
  26. package/lib/commonjs/DefaultResolver.js +6 -2
  27. package/lib/commonjs/DefaultResolver.js.map +1 -1
  28. package/lib/commonjs/DefaultResolver.spec.js +79 -3
  29. package/lib/commonjs/DefaultResolver.spec.js.map +1 -1
  30. package/lib/commonjs/checkForUpdate.js +6 -0
  31. package/lib/commonjs/checkForUpdate.js.map +1 -1
  32. package/lib/commonjs/index.js +53 -18
  33. package/lib/commonjs/index.js.map +1 -1
  34. package/lib/commonjs/index.spec.js +117 -1
  35. package/lib/commonjs/index.spec.js.map +1 -1
  36. package/lib/commonjs/native.js +40 -16
  37. package/lib/commonjs/native.js.map +1 -1
  38. package/lib/commonjs/native.spec.js +119 -3
  39. package/lib/commonjs/native.spec.js.map +1 -1
  40. package/lib/commonjs/sdkVersion.js +8 -0
  41. package/lib/commonjs/sdkVersion.js.map +1 -0
  42. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  43. package/lib/commonjs/store.js +72 -5
  44. package/lib/commonjs/store.js.map +1 -1
  45. package/lib/commonjs/store.spec.js +265 -8
  46. package/lib/commonjs/store.spec.js.map +1 -1
  47. package/lib/commonjs/wrap.js +19 -5
  48. package/lib/commonjs/wrap.js.map +1 -1
  49. package/lib/commonjs/wrap.spec.js +74 -0
  50. package/lib/commonjs/wrap.spec.js.map +1 -1
  51. package/lib/module/DefaultResolver.js +6 -2
  52. package/lib/module/DefaultResolver.js.map +1 -1
  53. package/lib/module/DefaultResolver.spec.js +78 -3
  54. package/lib/module/DefaultResolver.spec.js.map +1 -1
  55. package/lib/module/checkForUpdate.js +6 -0
  56. package/lib/module/checkForUpdate.js.map +1 -1
  57. package/lib/module/index.js +53 -18
  58. package/lib/module/index.js.map +1 -1
  59. package/lib/module/index.spec.js +117 -1
  60. package/lib/module/index.spec.js.map +1 -1
  61. package/lib/module/native.js +40 -16
  62. package/lib/module/native.js.map +1 -1
  63. package/lib/module/native.spec.js +119 -3
  64. package/lib/module/native.spec.js.map +1 -1
  65. package/lib/module/sdkVersion.js +4 -0
  66. package/lib/module/sdkVersion.js.map +1 -0
  67. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  68. package/lib/module/store.js +72 -5
  69. package/lib/module/store.js.map +1 -1
  70. package/lib/module/store.spec.js +265 -8
  71. package/lib/module/store.spec.js.map +1 -1
  72. package/lib/module/wrap.js +19 -5
  73. package/lib/module/wrap.js.map +1 -1
  74. package/lib/module/wrap.spec.js +74 -0
  75. package/lib/module/wrap.spec.js.map +1 -1
  76. package/lib/typescript/commonjs/DefaultResolver.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/checkForUpdate.d.ts +2 -2
  78. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/index.d.ts +36 -8
  80. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/native.d.ts +56 -3
  82. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/sdkVersion.d.ts +2 -0
  84. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +27 -0
  86. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/store.d.ts +5 -0
  88. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/wrap.d.ts +43 -29
  90. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  91. package/lib/typescript/module/DefaultResolver.d.ts.map +1 -1
  92. package/lib/typescript/module/checkForUpdate.d.ts +2 -2
  93. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  94. package/lib/typescript/module/index.d.ts +36 -8
  95. package/lib/typescript/module/index.d.ts.map +1 -1
  96. package/lib/typescript/module/native.d.ts +56 -3
  97. package/lib/typescript/module/native.d.ts.map +1 -1
  98. package/lib/typescript/module/sdkVersion.d.ts +2 -0
  99. package/lib/typescript/module/sdkVersion.d.ts.map +1 -0
  100. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +27 -0
  101. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  102. package/lib/typescript/module/store.d.ts +5 -0
  103. package/lib/typescript/module/store.d.ts.map +1 -1
  104. package/lib/typescript/module/wrap.d.ts +43 -29
  105. package/lib/typescript/module/wrap.d.ts.map +1 -1
  106. package/package.json +20 -10
  107. package/src/DefaultResolver.spec.ts +103 -3
  108. package/src/DefaultResolver.ts +9 -2
  109. package/src/checkForUpdate.ts +9 -2
  110. package/src/index.spec.ts +149 -1
  111. package/src/index.ts +99 -40
  112. package/src/native.spec.ts +139 -1
  113. package/src/native.ts +145 -31
  114. package/src/sdkVersion.ts +1 -0
  115. package/src/specs/NativeHotUpdater.ts +28 -0
  116. package/src/store.spec.ts +324 -9
  117. package/src/store.ts +133 -7
  118. package/src/wrap.spec.ts +91 -0
  119. package/src/wrap.tsx +79 -42
@@ -1,7 +1,173 @@
1
1
  import Foundation
2
2
 
3
+ @_silgen_name("HotUpdaterApplyBsdiffPatch")
4
+ private func hotUpdaterApplyBsdiffPatchSymbol(
5
+ _ patchPath: NSString,
6
+ _ basePath: NSString,
7
+ _ outputPath: NSString
8
+ ) -> ObjCBool
9
+
10
+ private func hotUpdaterApplyBsdiffPatch(
11
+ patchPath: String,
12
+ basePath: String,
13
+ outputPath: String
14
+ ) -> Bool {
15
+ return hotUpdaterApplyBsdiffPatchSymbol(
16
+ patchPath as NSString,
17
+ basePath as NSString,
18
+ outputPath as NSString
19
+ ).boolValue
20
+ }
21
+
3
22
  public typealias ManifestAssets = [String: Any]
4
23
 
24
+ public struct ChangedAssetDescriptor {
25
+ public let fileUrl: URL?
26
+ public let fileHash: String
27
+ public let fileCompression: String?
28
+ public let patch: BsdiffPatchDescriptor?
29
+
30
+ public init(
31
+ fileUrl: URL?,
32
+ fileHash: String,
33
+ fileCompression: String? = nil,
34
+ patch: BsdiffPatchDescriptor? = nil
35
+ ) {
36
+ self.fileUrl = fileUrl
37
+ self.fileHash = fileHash
38
+ self.fileCompression = fileCompression
39
+ self.patch = patch
40
+ }
41
+ }
42
+
43
+ public struct BsdiffPatchDescriptor {
44
+ public let algorithm: String
45
+ public let baseBundleId: String
46
+ public let baseFileHash: String
47
+ public let patchFileHash: String
48
+ public let patchUrl: URL
49
+
50
+ public init(
51
+ algorithm: String,
52
+ baseBundleId: String,
53
+ baseFileHash: String,
54
+ patchFileHash: String,
55
+ patchUrl: URL
56
+ ) {
57
+ self.algorithm = algorithm
58
+ self.baseBundleId = baseBundleId
59
+ self.baseFileHash = baseFileHash
60
+ self.patchFileHash = patchFileHash
61
+ self.patchUrl = patchUrl
62
+ }
63
+ }
64
+
65
+ public struct UpdateProgressPayload {
66
+ public let progress: Double
67
+ public let artifactType: String
68
+ public let downloadedBytes: Int64?
69
+ public let totalBytes: Int64?
70
+ public let details: DiffProgressDetails?
71
+
72
+ public struct DiffProgressFileSnapshot {
73
+ public let path: String
74
+ public let downloadPath: String
75
+ public let status: String
76
+ public let progress: Double
77
+ public let order: Int
78
+ public let downloadedBytes: Int64?
79
+ public let totalBytes: Int64?
80
+
81
+ public init(
82
+ path: String,
83
+ downloadPath: String? = nil,
84
+ status: String,
85
+ progress: Double,
86
+ order: Int,
87
+ downloadedBytes: Int64? = nil,
88
+ totalBytes: Int64? = nil
89
+ ) {
90
+ self.path = path
91
+ self.downloadPath = downloadPath ?? path
92
+ self.status = status
93
+ self.progress = progress
94
+ self.order = order
95
+ self.downloadedBytes = downloadedBytes
96
+ self.totalBytes = totalBytes
97
+ }
98
+
99
+ public var userInfo: [String: Any] {
100
+ var info: [String: Any] = [
101
+ "path": path,
102
+ "downloadPath": downloadPath,
103
+ "status": status,
104
+ "progress": progress,
105
+ "order": order
106
+ ]
107
+ if let downloadedBytes {
108
+ info["downloadedBytes"] = downloadedBytes
109
+ }
110
+ if let totalBytes {
111
+ info["totalBytes"] = totalBytes
112
+ }
113
+ return info
114
+ }
115
+ }
116
+
117
+ public struct DiffProgressDetails {
118
+ public let totalFilesCount: Int
119
+ public let completedFilesCount: Int
120
+ public let files: [DiffProgressFileSnapshot]
121
+
122
+ public init(
123
+ totalFilesCount: Int,
124
+ completedFilesCount: Int,
125
+ files: [DiffProgressFileSnapshot] = []
126
+ ) {
127
+ self.totalFilesCount = totalFilesCount
128
+ self.completedFilesCount = completedFilesCount
129
+ self.files = files
130
+ }
131
+
132
+ public var userInfo: [String: Any] {
133
+ return [
134
+ "totalFilesCount": totalFilesCount,
135
+ "completedFilesCount": completedFilesCount,
136
+ "files": files.map(\.userInfo)
137
+ ]
138
+ }
139
+ }
140
+
141
+ public init(
142
+ progress: Double,
143
+ artifactType: String,
144
+ downloadedBytes: Int64? = nil,
145
+ totalBytes: Int64? = nil,
146
+ details: DiffProgressDetails? = nil
147
+ ) {
148
+ self.progress = progress
149
+ self.artifactType = artifactType
150
+ self.downloadedBytes = downloadedBytes
151
+ self.totalBytes = totalBytes
152
+ self.details = details
153
+ }
154
+
155
+ public var userInfo: [String: Any] {
156
+ var info: [String: Any] = [
157
+ "artifactType": artifactType,
158
+ "progress": progress,
159
+ "details": details?.userInfo ?? NSNull()
160
+ ]
161
+ if let downloadedBytes {
162
+ info["downloadedBytes"] = downloadedBytes
163
+ }
164
+ if let totalBytes {
165
+ info["totalBytes"] = totalBytes
166
+ }
167
+ return info
168
+ }
169
+ }
170
+
5
171
  public enum BundleStorageError: Error, CustomNSError {
6
172
  case directoryCreationFailed
7
173
  case downloadFailed(Error)
@@ -108,7 +274,7 @@ public protocol BundleStorageService {
108
274
  func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection
109
275
 
110
276
  // Bundle update
111
- func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<Bool, Error>) -> Void)
277
+ 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)
112
278
 
113
279
  // Rollback support
114
280
  func markLaunchCompleted(bundleId: String?)
@@ -149,6 +315,16 @@ class BundleFileStorageService: BundleStorageService {
149
315
  let manifest: ManifestAssets
150
316
  }
151
317
 
318
+ private struct ParsedBundleManifest {
319
+ let bundleId: String
320
+ let assets: [String: ParsedManifestAsset]
321
+ }
322
+
323
+ private struct ParsedManifestAsset {
324
+ let fileHash: String
325
+ let signature: String?
326
+ }
327
+
152
328
  private enum UpdateProgress {
153
329
  static let downloadEnd = 0.7
154
330
  static let verificationStart = 0.72
@@ -177,6 +353,342 @@ class BundleFileStorageService: BundleStorageService {
177
353
  private let activeBundleMetadataLock = NSLock()
178
354
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot?
179
355
 
356
+ private func emitArchiveProgress(
357
+ progressHandler: @escaping (UpdateProgressPayload) -> Void,
358
+ progress: Double,
359
+ downloadedBytes: Int64? = nil,
360
+ totalBytes: Int64? = nil
361
+ ) {
362
+ progressHandler(
363
+ UpdateProgressPayload(
364
+ progress: max(0, min(progress, 1)),
365
+ artifactType: "archive",
366
+ downloadedBytes: downloadedBytes,
367
+ totalBytes: totalBytes
368
+ )
369
+ )
370
+ }
371
+
372
+ private func createDiffProgressFiles(
373
+ changedAssets: [String: ChangedAssetDescriptor]
374
+ ) -> [UpdateProgressPayload.DiffProgressFileSnapshot] {
375
+ return changedAssets.keys.sorted().enumerated().map { index, path in
376
+ UpdateProgressPayload.DiffProgressFileSnapshot(
377
+ path: path,
378
+ downloadPath: path,
379
+ status: "pending",
380
+ progress: 0,
381
+ order: index
382
+ )
383
+ }
384
+ }
385
+
386
+ private func updateDiffProgressFile(
387
+ files: inout [UpdateProgressPayload.DiffProgressFileSnapshot],
388
+ assetPath: String,
389
+ status: String,
390
+ progress: Double,
391
+ downloadPath: String? = nil,
392
+ downloadedBytes: Int64? = nil,
393
+ totalBytes: Int64? = nil
394
+ ) {
395
+ guard let fileIndex = files.firstIndex(where: { $0.path == assetPath }) else {
396
+ return
397
+ }
398
+
399
+ files[fileIndex] = UpdateProgressPayload.DiffProgressFileSnapshot(
400
+ path: files[fileIndex].path,
401
+ downloadPath: downloadPath ?? files[fileIndex].downloadPath,
402
+ status: status,
403
+ progress: max(0, min(progress, 1)),
404
+ order: files[fileIndex].order,
405
+ downloadedBytes: downloadedBytes ?? files[fileIndex].downloadedBytes,
406
+ totalBytes: totalBytes ?? files[fileIndex].totalBytes
407
+ )
408
+ }
409
+
410
+ private func calculateDiffOverallProgress(
411
+ phase: String,
412
+ files: [UpdateProgressPayload.DiffProgressFileSnapshot],
413
+ manifestProgress: Double = 0
414
+ ) -> Double {
415
+ let normalizedManifestProgress = max(0, min(manifestProgress, 1))
416
+ switch phase {
417
+ case "manifest":
418
+ return normalizedManifestProgress * 0.15
419
+ case "downloading":
420
+ guard !files.isEmpty else {
421
+ return 0.92
422
+ }
423
+
424
+ let completedFilesCount = files.filter { $0.status == "downloaded" }.count
425
+ let activeProgressUnits = files
426
+ .filter { $0.status == "downloading" }
427
+ .reduce(0.0) { partialResult, file in
428
+ partialResult + max(0, min(file.progress, 1))
429
+ }
430
+ let normalizedProgress =
431
+ (Double(completedFilesCount) + activeProgressUnits) / Double(files.count)
432
+ return max(0.2, min(0.2 + normalizedProgress * 0.72, 0.92))
433
+ case "finalizing":
434
+ return 0.97
435
+ case "completed":
436
+ return 1.0
437
+ default:
438
+ return 0
439
+ }
440
+ }
441
+
442
+ private func emitDiffProgress(
443
+ progressHandler: @escaping (UpdateProgressPayload) -> Void,
444
+ phase: String,
445
+ files: [UpdateProgressPayload.DiffProgressFileSnapshot],
446
+ manifestProgress: Double = 0
447
+ ) {
448
+ let completedFilesCount = files.filter { $0.status == "downloaded" }.count
449
+ progressHandler(
450
+ UpdateProgressPayload(
451
+ progress: calculateDiffOverallProgress(
452
+ phase: phase,
453
+ files: files,
454
+ manifestProgress: manifestProgress
455
+ ),
456
+ artifactType: "diff",
457
+ details: UpdateProgressPayload.DiffProgressDetails(
458
+ totalFilesCount: files.count,
459
+ completedFilesCount: completedFilesCount,
460
+ files: files
461
+ )
462
+ )
463
+ )
464
+ }
465
+
466
+ private func resetDiffProgressFile(
467
+ files: inout [UpdateProgressPayload.DiffProgressFileSnapshot],
468
+ assetPath: String,
469
+ progressHandler: @escaping (UpdateProgressPayload) -> Void
470
+ ) {
471
+ updateDiffProgressFile(
472
+ files: &files,
473
+ assetPath: assetPath,
474
+ status: "pending",
475
+ progress: 0,
476
+ downloadPath: assetPath
477
+ )
478
+ emitDiffProgress(
479
+ progressHandler: progressHandler,
480
+ phase: "downloading",
481
+ files: files
482
+ )
483
+ }
484
+
485
+ private func patchTempPath(
486
+ tempDirectory: String,
487
+ assetPath: String
488
+ ) -> String {
489
+ let safeName = assetPath
490
+ .replacingOccurrences(of: "/", with: "__")
491
+ .replacingOccurrences(of: "\\", with: "__")
492
+ let patchDirectory = (tempDirectory as NSString).appendingPathComponent("patches")
493
+ _ = fileSystem.createDirectory(atPath: patchDirectory)
494
+ return (patchDirectory as NSString).appendingPathComponent("\(safeName).bsdiff")
495
+ }
496
+
497
+ private func patchDownloadPath(assetPath: String) -> String {
498
+ return "\(assetPath).bsdiff"
499
+ }
500
+
501
+ private func downloadPath(
502
+ assetPath: String,
503
+ changedAsset: ChangedAssetDescriptor
504
+ ) -> String {
505
+ if changedAsset.fileCompression == "br" {
506
+ return "\(assetPath).br"
507
+ }
508
+ return assetPath
509
+ }
510
+
511
+ private func compressedTempPath(
512
+ tempDirectory: String,
513
+ assetPath: String,
514
+ compression: String
515
+ ) -> String {
516
+ let safeName = assetPath
517
+ .replacingOccurrences(of: "/", with: "__")
518
+ .replacingOccurrences(of: "\\", with: "__")
519
+ let compressedDirectory = (tempDirectory as NSString).appendingPathComponent("compressed")
520
+ _ = fileSystem.createDirectory(atPath: compressedDirectory)
521
+ return (compressedDirectory as NSString).appendingPathComponent("\(safeName).\(compression)")
522
+ }
523
+
524
+ private func materializeDownloadedAsset(
525
+ downloadedPath: String,
526
+ destinationPath: String,
527
+ changedAsset: ChangedAssetDescriptor
528
+ ) throws {
529
+ guard changedAsset.fileCompression == "br" else {
530
+ return
531
+ }
532
+
533
+ try StreamingTarArchiveExtractor.decompressBrotliFile(
534
+ from: downloadedPath,
535
+ to: destinationPath
536
+ )
537
+ try? fileSystem.removeItem(atPath: downloadedPath)
538
+ }
539
+
540
+ private func applyPatchAssetIfPossible(
541
+ assetPath: String,
542
+ changedAsset: ChangedAssetDescriptor,
543
+ currentBundleId: String?,
544
+ currentBundleDir: String?,
545
+ destinationPath: String,
546
+ expectedHash: String,
547
+ tempDirectory: String,
548
+ files: inout [UpdateProgressPayload.DiffProgressFileSnapshot],
549
+ progressHandler: @escaping (UpdateProgressPayload) -> Void
550
+ ) -> Bool {
551
+ guard let patch = changedAsset.patch,
552
+ patch.algorithm == "bsdiff",
553
+ currentBundleId == patch.baseBundleId,
554
+ let currentBundleDir
555
+ else {
556
+ return false
557
+ }
558
+
559
+ let sourcePath = (currentBundleDir as NSString).appendingPathComponent(assetPath)
560
+ guard self.fileSystem.fileExists(atPath: sourcePath),
561
+ HashUtils.verifyHash(
562
+ fileURL: URL(fileURLWithPath: sourcePath),
563
+ expectedHash: patch.baseFileHash
564
+ )
565
+ else {
566
+ return false
567
+ }
568
+
569
+ let patchPath = patchTempPath(
570
+ tempDirectory: tempDirectory,
571
+ assetPath: assetPath
572
+ )
573
+
574
+ defer {
575
+ try? self.fileSystem.removeItem(atPath: patchPath)
576
+ if self.fileSystem.fileExists(atPath: destinationPath),
577
+ !HashUtils.verifyHash(
578
+ fileURL: URL(fileURLWithPath: destinationPath),
579
+ expectedHash: expectedHash
580
+ ) {
581
+ try? self.fileSystem.removeItem(atPath: destinationPath)
582
+ }
583
+ }
584
+
585
+ var updatedFiles = files
586
+ defer {
587
+ files = updatedFiles
588
+ }
589
+
590
+ switch self.downloadFileSynchronously(
591
+ from: patch.patchUrl,
592
+ to: patchPath,
593
+ progressHandler: { progress in
594
+ self.updateDiffProgressFile(
595
+ files: &updatedFiles,
596
+ assetPath: assetPath,
597
+ status: "downloading",
598
+ progress: progress.progress,
599
+ downloadPath: self.patchDownloadPath(assetPath: assetPath),
600
+ downloadedBytes: progress.downloadedBytes,
601
+ totalBytes: progress.totalBytes
602
+ )
603
+ self.emitDiffProgress(
604
+ progressHandler: progressHandler,
605
+ phase: "downloading",
606
+ files: updatedFiles
607
+ )
608
+ }
609
+ ) {
610
+ case .success(let patchFileURL):
611
+ guard HashUtils.verifyHash(
612
+ fileURL: patchFileURL,
613
+ expectedHash: patch.patchFileHash
614
+ ) else {
615
+ resetDiffProgressFile(
616
+ files: &updatedFiles,
617
+ assetPath: assetPath,
618
+ progressHandler: progressHandler
619
+ )
620
+ return false
621
+ }
622
+
623
+ let applied = hotUpdaterApplyBsdiffPatch(
624
+ patchPath: patchPath,
625
+ basePath: sourcePath,
626
+ outputPath: destinationPath
627
+ )
628
+ guard applied else {
629
+ resetDiffProgressFile(
630
+ files: &updatedFiles,
631
+ assetPath: assetPath,
632
+ progressHandler: progressHandler
633
+ )
634
+ return false
635
+ }
636
+
637
+ guard HashUtils.verifyHash(
638
+ fileURL: URL(fileURLWithPath: destinationPath),
639
+ expectedHash: expectedHash
640
+ ) else {
641
+ resetDiffProgressFile(
642
+ files: &updatedFiles,
643
+ assetPath: assetPath,
644
+ progressHandler: progressHandler
645
+ )
646
+ return false
647
+ }
648
+
649
+ NSLog("[BundleStorage] HotUpdaterBsdiffPatchApplied asset=\(assetPath) baseBundleId=\(patch.baseBundleId)")
650
+ return true
651
+ case .failure:
652
+ resetDiffProgressFile(
653
+ files: &updatedFiles,
654
+ assetPath: assetPath,
655
+ progressHandler: progressHandler
656
+ )
657
+ return false
658
+ }
659
+ }
660
+
661
+ private func verifyManifestAssetFile(
662
+ atPath path: String,
663
+ asset: ParsedManifestAsset
664
+ ) throws {
665
+ guard let actualHash = HashUtils.calculateSHA256(fileURL: URL(fileURLWithPath: path)) else {
666
+ throw BundleStorageError.signatureVerificationFailed(.fileReadFailed)
667
+ }
668
+
669
+ guard actualHash.caseInsensitiveCompare(asset.fileHash) == .orderedSame else {
670
+ throw BundleStorageError.signatureVerificationFailed(.fileHashMismatch)
671
+ }
672
+
673
+ guard SignatureVerifier.isSigningEnabled() else {
674
+ return
675
+ }
676
+
677
+ guard let signature = asset.signature, !signature.isEmpty else {
678
+ throw BundleStorageError.signatureVerificationFailed(.invalidSignatureFormat)
679
+ }
680
+
681
+ switch SignatureVerifier.verifyHashSignature(
682
+ fileHash: asset.fileHash,
683
+ signatureBase64: signature
684
+ ) {
685
+ case .success:
686
+ return
687
+ case .failure(let error):
688
+ throw BundleStorageError.signatureVerificationFailed(error)
689
+ }
690
+ }
691
+
180
692
  public init(fileSystem: FileSystemService,
181
693
  downloadService: DownloadService,
182
694
  decompressService: DecompressService,
@@ -349,6 +861,23 @@ class BundleFileStorageService: BundleStorageService {
349
861
  }
350
862
  }
351
863
 
864
+ func canUseManifestDrivenInstall() -> Bool {
865
+ guard let currentBundleURL = getCachedBundleURL(),
866
+ case .success(let storeDir) = bundleStoreDir() else {
867
+ return false
868
+ }
869
+
870
+ let currentBundleId = currentBundleURL.deletingLastPathComponent().lastPathComponent
871
+ let currentBundleDir = (storeDir as NSString).appendingPathComponent(currentBundleId)
872
+ guard fileSystem.fileExists(atPath: currentBundleDir),
873
+ let snapshot = getActiveBundleMetadataSnapshot(),
874
+ let currentManifest = parseBundleManifest(from: snapshot.manifest) else {
875
+ return false
876
+ }
877
+
878
+ return currentManifest.assets.isEmpty == false
879
+ }
880
+
352
881
  private func resolveActiveBundleMetadataSnapshot(
353
882
  activeBundleId: String,
354
883
  bundleDirectory: String
@@ -429,6 +958,65 @@ class BundleFileStorageService: BundleStorageService {
429
958
  }
430
959
  }
431
960
 
961
+ private func parseBundleManifest(from manifest: [String: Any]) -> ParsedBundleManifest? {
962
+ guard let manifestBundleId = (manifest["bundleId"] as? String)?
963
+ .trimmingCharacters(in: .whitespacesAndNewlines),
964
+ !manifestBundleId.isEmpty,
965
+ let rawAssets = manifest["assets"] as? [String: Any]
966
+ else {
967
+ return nil
968
+ }
969
+
970
+ var assets: [String: ParsedManifestAsset] = [:]
971
+ for (assetPath, assetValue) in rawAssets {
972
+ guard let asset = assetValue as? [String: Any],
973
+ let fileHash = asset["fileHash"] as? String,
974
+ !fileHash.isEmpty
975
+ else {
976
+ return nil
977
+ }
978
+ let signature = (asset["signature"] as? String)?
979
+ .trimmingCharacters(in: .whitespacesAndNewlines)
980
+ assets[assetPath] = ParsedManifestAsset(
981
+ fileHash: fileHash,
982
+ signature: signature?.isEmpty == false ? signature : nil
983
+ )
984
+ }
985
+
986
+ return ParsedBundleManifest(bundleId: manifestBundleId, assets: assets)
987
+ }
988
+
989
+ private func parseBundleManifest(fromFile manifestPath: String) -> ParsedBundleManifest? {
990
+ guard let manifest = readManifest(in: (manifestPath as NSString).deletingLastPathComponent) else {
991
+ return nil
992
+ }
993
+
994
+ return parseBundleManifest(from: manifest)
995
+ }
996
+
997
+ private func writeManifestFile(_ manifest: ParsedBundleManifest, to destination: String) throws {
998
+ let manifestDirectory = (destination as NSString).deletingLastPathComponent
999
+ guard fileSystem.createDirectory(atPath: manifestDirectory) else {
1000
+ throw BundleStorageError.directoryCreationFailed
1001
+ }
1002
+
1003
+ let assets = manifest.assets
1004
+ .sorted { $0.key < $1.key }
1005
+ .reduce(into: [String: [String: String]]()) { partialResult, entry in
1006
+ var assetPayload = ["fileHash": entry.value.fileHash]
1007
+ if let signature = entry.value.signature, !signature.isEmpty {
1008
+ assetPayload["signature"] = signature
1009
+ }
1010
+ partialResult[entry.key] = assetPayload
1011
+ }
1012
+ let payload: [String: Any] = [
1013
+ "bundleId": manifest.bundleId,
1014
+ "assets": assets,
1015
+ ]
1016
+ let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])
1017
+ try data.write(to: URL(fileURLWithPath: destination))
1018
+ }
1019
+
432
1020
  /**
433
1021
  * Checks if isolationKey has changed and cleans up old bundles if needed.
434
1022
  * This handles migration when isolationKey format changes.
@@ -648,6 +1236,28 @@ class BundleFileStorageService: BundleStorageService {
648
1236
  }
649
1237
  DispatchQueue.global(qos: .background).async(execute: workItem)
650
1238
  }
1239
+
1240
+ private func downloadFileSynchronously(
1241
+ from url: URL,
1242
+ to destination: String,
1243
+ progressHandler: @escaping (DownloadProgress) -> Void
1244
+ ) -> Result<URL, Error> {
1245
+ let semaphore = DispatchSemaphore(value: 0)
1246
+ var finalResult: Result<URL, Error> = .failure(BundleStorageError.unknown(nil))
1247
+
1248
+ _ = downloadService.downloadFile(
1249
+ from: url,
1250
+ to: destination,
1251
+ fileSizeHandler: nil,
1252
+ progressHandler: progressHandler
1253
+ ) { result in
1254
+ finalResult = result
1255
+ semaphore.signal()
1256
+ }
1257
+
1258
+ semaphore.wait()
1259
+ return finalResult
1260
+ }
651
1261
 
652
1262
  // MARK: - Bundle File Operations
653
1263
 
@@ -881,7 +1491,7 @@ class BundleFileStorageService: BundleStorageService {
881
1491
  * @param progressHandler Callback for download and extraction progress (0.0 to 1.0)
882
1492
  * @param completion Callback with result of the operation
883
1493
  */
884
- func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<Bool, Error>) -> Void) {
1494
+ 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) {
885
1495
  // Check if bundle is in crashed history
886
1496
  let crashedHistory = loadCrashedHistory()
887
1497
  if crashedHistory.contains(bundleId) {
@@ -944,7 +1554,10 @@ class BundleFileStorageService: BundleStorageService {
944
1554
  let _ = self.saveMetadata(updatedMetadata)
945
1555
  NSLog("[BundleStorage] Set staging bundle (cached): \(bundleId), verificationPending: true")
946
1556
 
947
- progressHandler(UpdateProgress.complete)
1557
+ self.emitArchiveProgress(
1558
+ progressHandler: progressHandler,
1559
+ progress: UpdateProgress.complete
1560
+ )
948
1561
  self.scheduleCleanupOldBundles(
949
1562
  bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
950
1563
  )
@@ -958,7 +1571,27 @@ class BundleFileStorageService: BundleStorageService {
958
1571
  do {
959
1572
  try self.fileSystem.removeItem(atPath: finalBundleDir)
960
1573
  // Continue with download process on success
961
- self.prepareAndDownloadBundle(bundleId: bundleId, fileUrl: validFileUrl, fileHash: fileHash, storeDir: storeDir, progressHandler: progressHandler, completion: completion)
1574
+ if let manifestUrl,
1575
+ let manifestFileHash,
1576
+ let changedAssets,
1577
+ self.canUseManifestDrivenInstall() {
1578
+ self.updateBundleFromManifest(
1579
+ bundleId: bundleId,
1580
+ fileUrl: validFileUrl,
1581
+ fileHash: fileHash,
1582
+ manifestUrl: manifestUrl,
1583
+ manifestFileHash: manifestFileHash,
1584
+ changedAssets: changedAssets,
1585
+ storeDir: storeDir,
1586
+ progressHandler: progressHandler,
1587
+ completion: completion
1588
+ )
1589
+ } else {
1590
+ if manifestUrl != nil && manifestFileHash != nil && changedAssets != nil {
1591
+ NSLog("[BundleStorage] Skipping manifest-driven install for \(bundleId) because no active OTA manifest is available. Using archive.")
1592
+ }
1593
+ self.prepareAndDownloadBundle(bundleId: bundleId, fileUrl: validFileUrl, fileHash: fileHash, storeDir: storeDir, progressHandler: progressHandler, completion: completion)
1594
+ }
962
1595
  } catch let error {
963
1596
  NSLog("[BundleStorage] Failed to remove invalid bundle dir: \(error.localizedDescription)")
964
1597
  completion(.failure(BundleStorageError.unknown(error)))
@@ -968,7 +1601,27 @@ class BundleFileStorageService: BundleStorageService {
968
1601
  completion(.failure(error))
969
1602
  }
970
1603
  } else {
971
- self.prepareAndDownloadBundle(bundleId: bundleId, fileUrl: validFileUrl, fileHash: fileHash, storeDir: storeDir, progressHandler: progressHandler, completion: completion)
1604
+ if let manifestUrl,
1605
+ let manifestFileHash,
1606
+ let changedAssets,
1607
+ self.canUseManifestDrivenInstall() {
1608
+ self.updateBundleFromManifest(
1609
+ bundleId: bundleId,
1610
+ fileUrl: validFileUrl,
1611
+ fileHash: fileHash,
1612
+ manifestUrl: manifestUrl,
1613
+ manifestFileHash: manifestFileHash,
1614
+ changedAssets: changedAssets,
1615
+ storeDir: storeDir,
1616
+ progressHandler: progressHandler,
1617
+ completion: completion
1618
+ )
1619
+ } else {
1620
+ if manifestUrl != nil && manifestFileHash != nil && changedAssets != nil {
1621
+ NSLog("[BundleStorage] Skipping manifest-driven install for \(bundleId) because no active OTA manifest is available. Using archive.")
1622
+ }
1623
+ self.prepareAndDownloadBundle(bundleId: bundleId, fileUrl: validFileUrl, fileHash: fileHash, storeDir: storeDir, progressHandler: progressHandler, completion: completion)
1624
+ }
972
1625
  }
973
1626
  }
974
1627
  }
@@ -983,12 +1636,391 @@ class BundleFileStorageService: BundleStorageService {
983
1636
  * @param progressHandler Callback for download and extraction progress
984
1637
  * @param completion Callback with result of the operation
985
1638
  */
1639
+ private func updateBundleFromManifest(
1640
+ bundleId: String,
1641
+ fileUrl: URL,
1642
+ fileHash: String?,
1643
+ manifestUrl: URL,
1644
+ manifestFileHash: String,
1645
+ changedAssets: [String: ChangedAssetDescriptor],
1646
+ storeDir: String,
1647
+ progressHandler: @escaping (UpdateProgressPayload) -> Void,
1648
+ completion: @escaping (Result<Bool, Error>) -> Void
1649
+ ) {
1650
+ let tempDirResult = tempDir()
1651
+ guard case .success(let tempDirectory) = tempDirResult else {
1652
+ completion(.failure(tempDirResult.failureError ?? BundleStorageError.unknown(nil)))
1653
+ return
1654
+ }
1655
+
1656
+ try? self.fileSystem.removeItem(atPath: tempDirectory)
1657
+ guard self.fileSystem.createDirectory(atPath: tempDirectory) else {
1658
+ completion(.failure(BundleStorageError.directoryCreationFailed))
1659
+ return
1660
+ }
1661
+
1662
+ let currentBundleId = self.getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent
1663
+ let currentBundleDir = currentBundleId.map {
1664
+ (storeDir as NSString).appendingPathComponent($0)
1665
+ }
1666
+ let tempManifestPath = (tempDirectory as NSString).appendingPathComponent("manifest.json")
1667
+ let tmpDir = (storeDir as NSString).appendingPathComponent("\(bundleId).tmp")
1668
+ let realDir = (storeDir as NSString).appendingPathComponent(bundleId)
1669
+ var diffFiles = createDiffProgressFiles(changedAssets: changedAssets)
1670
+
1671
+ do {
1672
+ self.emitDiffProgress(
1673
+ progressHandler: progressHandler,
1674
+ phase: "manifest",
1675
+ files: diffFiles,
1676
+ manifestProgress: 0
1677
+ )
1678
+ switch self.downloadFileSynchronously(
1679
+ from: manifestUrl,
1680
+ to: tempManifestPath,
1681
+ progressHandler: { progress in
1682
+ self.emitDiffProgress(
1683
+ progressHandler: progressHandler,
1684
+ phase: "manifest",
1685
+ files: diffFiles,
1686
+ manifestProgress: progress.progress
1687
+ )
1688
+ }
1689
+ ) {
1690
+ case .success:
1691
+ break
1692
+ case .failure(let error):
1693
+ if let downloadError = error as? DownloadError,
1694
+ case .incompleteDownload(let expected, let actual) = downloadError {
1695
+ throw BundleStorageError.incompleteDownload(expected: expected, actual: actual)
1696
+ }
1697
+ throw BundleStorageError.downloadFailed(error)
1698
+ }
1699
+
1700
+ let manifestVerificationResult = SignatureVerifier.verifyBundle(
1701
+ fileURL: URL(fileURLWithPath: tempManifestPath),
1702
+ fileHash: manifestFileHash
1703
+ )
1704
+ guard case .success = manifestVerificationResult else {
1705
+ if case .failure(let error) = manifestVerificationResult {
1706
+ throw BundleStorageError.signatureVerificationFailed(error)
1707
+ }
1708
+ throw BundleStorageError.unknown(nil)
1709
+ }
1710
+
1711
+ guard let manifestData = try? Data(contentsOf: URL(fileURLWithPath: tempManifestPath)),
1712
+ let manifestJson = try JSONSerialization.jsonObject(with: manifestData) as? [String: Any],
1713
+ let targetManifest = parseBundleManifest(from: manifestJson),
1714
+ targetManifest.bundleId == bundleId
1715
+ else {
1716
+ throw BundleStorageError.invalidBundle
1717
+ }
1718
+ self.emitDiffProgress(
1719
+ progressHandler: progressHandler,
1720
+ phase: diffFiles.isEmpty ? "finalizing" : "downloading",
1721
+ files: diffFiles
1722
+ )
1723
+
1724
+ let currentManifest = getActiveBundleMetadataSnapshot().flatMap { snapshot in
1725
+ parseBundleManifest(from: snapshot.manifest)
1726
+ }
1727
+
1728
+ if self.fileSystem.fileExists(atPath: tmpDir) {
1729
+ try self.fileSystem.removeItem(atPath: tmpDir)
1730
+ }
1731
+ guard self.fileSystem.createDirectory(atPath: tmpDir) else {
1732
+ throw BundleStorageError.directoryCreationFailed
1733
+ }
1734
+
1735
+ let sortedAssets = targetManifest.assets.sorted { $0.key < $1.key }
1736
+
1737
+ for asset in sortedAssets {
1738
+ let assetPath = asset.key
1739
+ let expectedAsset = asset.value
1740
+ let expectedHash = expectedAsset.fileHash
1741
+ let destinationPath = (tmpDir as NSString).appendingPathComponent(assetPath)
1742
+ let destinationDir = (destinationPath as NSString).deletingLastPathComponent
1743
+ guard self.fileSystem.createDirectory(atPath: destinationDir) else {
1744
+ throw BundleStorageError.directoryCreationFailed
1745
+ }
1746
+
1747
+ if currentManifest?.assets[assetPath]?.fileHash == expectedHash {
1748
+ guard let currentBundleDir,
1749
+ self.fileSystem.fileExists(atPath: currentBundleDir)
1750
+ else {
1751
+ throw BundleStorageError.downloadFailed(
1752
+ NSError(domain: "HotUpdater", code: 0, userInfo: [
1753
+ NSLocalizedDescriptionKey: "Current bundle directory unavailable for reused asset: \(assetPath)"
1754
+ ])
1755
+ )
1756
+ }
1757
+
1758
+ let sourcePath = (currentBundleDir as NSString).appendingPathComponent(assetPath)
1759
+ guard self.fileSystem.fileExists(atPath: sourcePath),
1760
+ HashUtils.verifyHash(fileURL: URL(fileURLWithPath: sourcePath), expectedHash: expectedHash)
1761
+ else {
1762
+ throw BundleStorageError.signatureVerificationFailed(.fileHashMismatch)
1763
+ }
1764
+
1765
+ try self.fileSystem.copyItem(atPath: sourcePath, toPath: destinationPath)
1766
+ try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
1767
+ continue
1768
+ }
1769
+
1770
+ guard let changedAsset = changedAssets[assetPath] else {
1771
+ updateDiffProgressFile(
1772
+ files: &diffFiles,
1773
+ assetPath: assetPath,
1774
+ status: "failed",
1775
+ progress: 0
1776
+ )
1777
+ self.emitDiffProgress(
1778
+ progressHandler: progressHandler,
1779
+ phase: "downloading",
1780
+ files: diffFiles
1781
+ )
1782
+ throw BundleStorageError.downloadFailed(
1783
+ NSError(domain: "HotUpdater", code: 0, userInfo: [
1784
+ NSLocalizedDescriptionKey: "Changed asset missing from update response: \(assetPath)"
1785
+ ])
1786
+ )
1787
+ }
1788
+
1789
+ guard changedAsset.fileHash.caseInsensitiveCompare(expectedHash) == .orderedSame else {
1790
+ updateDiffProgressFile(
1791
+ files: &diffFiles,
1792
+ assetPath: assetPath,
1793
+ status: "failed",
1794
+ progress: 0
1795
+ )
1796
+ self.emitDiffProgress(
1797
+ progressHandler: progressHandler,
1798
+ phase: "downloading",
1799
+ files: diffFiles
1800
+ )
1801
+ throw BundleStorageError.signatureVerificationFailed(.fileHashMismatch)
1802
+ }
1803
+
1804
+ let patched = applyPatchAssetIfPossible(
1805
+ assetPath: assetPath,
1806
+ changedAsset: changedAsset,
1807
+ currentBundleId: currentBundleId,
1808
+ currentBundleDir: currentBundleDir,
1809
+ destinationPath: destinationPath,
1810
+ expectedHash: expectedHash,
1811
+ tempDirectory: tempDirectory,
1812
+ files: &diffFiles,
1813
+ progressHandler: progressHandler
1814
+ )
1815
+ if patched {
1816
+ try verifyManifestAssetFile(atPath: destinationPath, asset: expectedAsset)
1817
+ updateDiffProgressFile(
1818
+ files: &diffFiles,
1819
+ assetPath: assetPath,
1820
+ status: "downloaded",
1821
+ progress: 1
1822
+ )
1823
+ self.emitDiffProgress(
1824
+ progressHandler: progressHandler,
1825
+ phase: diffFiles.allSatisfy { $0.status == "downloaded" }
1826
+ ? "finalizing"
1827
+ : "downloading",
1828
+ files: diffFiles
1829
+ )
1830
+ continue
1831
+ }
1832
+
1833
+ guard let changedAssetFileUrl = changedAsset.fileUrl else {
1834
+ updateDiffProgressFile(
1835
+ files: &diffFiles,
1836
+ assetPath: assetPath,
1837
+ status: "failed",
1838
+ progress: 0
1839
+ )
1840
+ self.emitDiffProgress(
1841
+ progressHandler: progressHandler,
1842
+ phase: "downloading",
1843
+ files: diffFiles
1844
+ )
1845
+ throw BundleStorageError.downloadFailed(
1846
+ NSError(domain: "HotUpdater", code: 0, userInfo: [
1847
+ NSLocalizedDescriptionKey: "Changed asset fileUrl missing and patch could not be applied: \(assetPath)"
1848
+ ])
1849
+ )
1850
+ }
1851
+
1852
+ let changedAssetDownloadPath = downloadPath(
1853
+ assetPath: assetPath,
1854
+ changedAsset: changedAsset
1855
+ )
1856
+ let downloadDestinationPath: String
1857
+ if changedAsset.fileCompression == "br" {
1858
+ downloadDestinationPath = compressedTempPath(
1859
+ tempDirectory: tempDirectory,
1860
+ assetPath: assetPath,
1861
+ compression: "br"
1862
+ )
1863
+ } else {
1864
+ downloadDestinationPath = destinationPath
1865
+ }
1866
+
1867
+ switch self.downloadFileSynchronously(
1868
+ from: changedAssetFileUrl,
1869
+ to: downloadDestinationPath,
1870
+ progressHandler: { progress in
1871
+ self.updateDiffProgressFile(
1872
+ files: &diffFiles,
1873
+ assetPath: assetPath,
1874
+ status: "downloading",
1875
+ progress: progress.progress,
1876
+ downloadPath: changedAssetDownloadPath,
1877
+ downloadedBytes: progress.downloadedBytes,
1878
+ totalBytes: progress.totalBytes
1879
+ )
1880
+ self.emitDiffProgress(
1881
+ progressHandler: progressHandler,
1882
+ phase: "downloading",
1883
+ files: diffFiles
1884
+ )
1885
+ }
1886
+ ) {
1887
+ case .success(let downloadedFileURL):
1888
+ do {
1889
+ try materializeDownloadedAsset(
1890
+ downloadedPath: downloadedFileURL.path,
1891
+ destinationPath: destinationPath,
1892
+ changedAsset: changedAsset
1893
+ )
1894
+ try verifyManifestAssetFile(
1895
+ atPath: destinationPath,
1896
+ asset: expectedAsset
1897
+ )
1898
+ } catch {
1899
+ updateDiffProgressFile(
1900
+ files: &diffFiles,
1901
+ assetPath: assetPath,
1902
+ status: "failed",
1903
+ progress: 1
1904
+ )
1905
+ self.emitDiffProgress(
1906
+ progressHandler: progressHandler,
1907
+ phase: "downloading",
1908
+ files: diffFiles
1909
+ )
1910
+ throw error
1911
+ }
1912
+ updateDiffProgressFile(
1913
+ files: &diffFiles,
1914
+ assetPath: assetPath,
1915
+ status: "downloaded",
1916
+ progress: 1
1917
+ )
1918
+ self.emitDiffProgress(
1919
+ progressHandler: progressHandler,
1920
+ phase: diffFiles.allSatisfy { $0.status == "downloaded" }
1921
+ ? "finalizing"
1922
+ : "downloading",
1923
+ files: diffFiles
1924
+ )
1925
+ case .failure(let error):
1926
+ let lastKnownProgress = diffFiles.first(where: { $0.path == assetPath })?.progress ?? 0
1927
+ updateDiffProgressFile(
1928
+ files: &diffFiles,
1929
+ assetPath: assetPath,
1930
+ status: "failed",
1931
+ progress: lastKnownProgress
1932
+ )
1933
+ self.emitDiffProgress(
1934
+ progressHandler: progressHandler,
1935
+ phase: "downloading",
1936
+ files: diffFiles
1937
+ )
1938
+ if let downloadError = error as? DownloadError,
1939
+ case .incompleteDownload(let expected, let actual) = downloadError {
1940
+ throw BundleStorageError.incompleteDownload(expected: expected, actual: actual)
1941
+ }
1942
+ throw BundleStorageError.downloadFailed(error)
1943
+ }
1944
+ }
1945
+
1946
+ self.emitDiffProgress(
1947
+ progressHandler: progressHandler,
1948
+ phase: "finalizing",
1949
+ files: diffFiles
1950
+ )
1951
+
1952
+ let manifestDestination = (tmpDir as NSString).appendingPathComponent("manifest.json")
1953
+ try writeManifestFile(targetManifest, to: manifestDestination)
1954
+
1955
+ switch self.findBundleFile(in: tmpDir) {
1956
+ case .success(let maybeBundlePath):
1957
+ guard let bundlePathInTmp = maybeBundlePath else {
1958
+ throw BundleStorageError.invalidBundle
1959
+ }
1960
+
1961
+ if self.fileSystem.fileExists(atPath: realDir) {
1962
+ try self.fileSystem.removeItem(atPath: realDir)
1963
+ }
1964
+
1965
+ do {
1966
+ try self.fileSystem.moveItem(atPath: tmpDir, toPath: realDir)
1967
+ } catch {
1968
+ throw BundleStorageError.moveOperationFailed(error)
1969
+ }
1970
+
1971
+ let finalBundlePath = (realDir as NSString).appendingPathComponent(
1972
+ (bundlePathInTmp as NSString).lastPathComponent
1973
+ )
1974
+ switch self.setBundleURL(localPath: finalBundlePath) {
1975
+ case .success:
1976
+ let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
1977
+ let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
1978
+ let _ = self.saveMetadata(updatedMetadata)
1979
+ self.cleanupTemporaryFiles([tempDirectory])
1980
+ self.scheduleCleanupOldBundles(
1981
+ bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
1982
+ )
1983
+ progressHandler(
1984
+ UpdateProgressPayload(
1985
+ progress: UpdateProgress.complete,
1986
+ artifactType: "diff",
1987
+ details: UpdateProgressPayload.DiffProgressDetails(
1988
+ totalFilesCount: diffFiles.count,
1989
+ completedFilesCount: diffFiles.filter { $0.status == "downloaded" }.count,
1990
+ files: diffFiles
1991
+ )
1992
+ )
1993
+ )
1994
+ completion(.success(true))
1995
+ case .failure(let error):
1996
+ try? self.fileSystem.removeItem(atPath: realDir)
1997
+ self.cleanupTemporaryFiles([tempDirectory])
1998
+ completion(.failure(error))
1999
+ }
2000
+ case .failure(let error):
2001
+ throw error
2002
+ }
2003
+ } catch {
2004
+ NSLog("[BundleStorage] Manifest-driven install failed: \(error.localizedDescription). Falling back to archive.")
2005
+ try? self.fileSystem.removeItem(atPath: tmpDir)
2006
+ self.cleanupTemporaryFiles([tempDirectory])
2007
+ self.prepareAndDownloadBundle(
2008
+ bundleId: bundleId,
2009
+ fileUrl: fileUrl,
2010
+ fileHash: fileHash,
2011
+ storeDir: storeDir,
2012
+ progressHandler: progressHandler,
2013
+ completion: completion
2014
+ )
2015
+ }
2016
+ }
2017
+
986
2018
  private func prepareAndDownloadBundle(
987
2019
  bundleId: String,
988
2020
  fileUrl: URL,
989
2021
  fileHash: String?,
990
2022
  storeDir: String,
991
- progressHandler: @escaping (Double) -> Void,
2023
+ progressHandler: @escaping (UpdateProgressPayload) -> Void,
992
2024
  completion: @escaping (Result<Bool, Error>) -> Void
993
2025
  ) {
994
2026
  // 1) Prepare temp directory for download
@@ -1019,10 +2051,8 @@ class BundleFileStorageService: BundleStorageService {
1019
2051
  _ = self.downloadService.downloadFile(
1020
2052
  from: fileUrl,
1021
2053
  to: tempBundleFile,
1022
- fileSizeHandler: { [weak self] fileSize in
2054
+ fileSizeHandler: { fileSize in
1023
2055
  // This will be called when Content-Length is received
1024
- guard let self = self else { return }
1025
-
1026
2056
  NSLog("[BundleStorage] File size received: \(fileSize) bytes")
1027
2057
 
1028
2058
  // Check available disk space
@@ -1044,7 +2074,16 @@ class BundleFileStorageService: BundleStorageService {
1044
2074
  }
1045
2075
  },
1046
2076
  progressHandler: { downloadProgress in
1047
- progressHandler(Self.mapProgress(downloadProgress, start: 0, end: UpdateProgress.downloadEnd))
2077
+ self.emitArchiveProgress(
2078
+ progressHandler: progressHandler,
2079
+ progress: Self.mapProgress(
2080
+ downloadProgress.progress,
2081
+ start: 0,
2082
+ end: UpdateProgress.downloadEnd
2083
+ ),
2084
+ downloadedBytes: downloadProgress.downloadedBytes,
2085
+ totalBytes: downloadProgress.totalBytes
2086
+ )
1048
2087
  },
1049
2088
  completion: { [weak self] result in
1050
2089
  guard let self = self else {
@@ -1140,7 +2179,7 @@ class BundleFileStorageService: BundleStorageService {
1140
2179
  storeDir: String,
1141
2180
  bundleId: String,
1142
2181
  tempDirectory: String,
1143
- progressHandler: @escaping (Double) -> Void,
2182
+ progressHandler: @escaping (UpdateProgressPayload) -> Void,
1144
2183
  completion: @escaping (Result<Bool, Error>) -> Void
1145
2184
  ) {
1146
2185
  let currentBundleId = self.getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent
@@ -1178,13 +2217,19 @@ class BundleFileStorageService: BundleStorageService {
1178
2217
 
1179
2218
  // 5) Verify bundle integrity (hash or signature based on fileHash format)
1180
2219
  NSLog("[BundleStorage] Verifying bundle integrity...")
1181
- progressHandler(UpdateProgress.verificationStart)
2220
+ self.emitArchiveProgress(
2221
+ progressHandler: progressHandler,
2222
+ progress: UpdateProgress.verificationStart
2223
+ )
1182
2224
  let tempBundleURL = URL(fileURLWithPath: tempBundleFile)
1183
2225
  let verificationResult = SignatureVerifier.verifyBundle(fileURL: tempBundleURL, fileHash: fileHash)
1184
2226
  switch verificationResult {
1185
2227
  case .success:
1186
2228
  NSLog("[BundleStorage] Bundle verification completed successfully")
1187
- progressHandler(UpdateProgress.verificationEnd)
2229
+ self.emitArchiveProgress(
2230
+ progressHandler: progressHandler,
2231
+ progress: UpdateProgress.verificationEnd
2232
+ )
1188
2233
  case .failure(let error):
1189
2234
  NSLog("[BundleStorage] Bundle verification failed: \(error)")
1190
2235
  try? self.fileSystem.removeItem(atPath: tmpDir)
@@ -1198,12 +2243,14 @@ class BundleFileStorageService: BundleStorageService {
1198
2243
  logFileSystemDiagnostics(path: tempBundleFile, context: "Before Extraction")
1199
2244
  do {
1200
2245
  try self.decompressService.unzip(file: tempBundleFile, to: tmpDir, progressHandler: { unzipProgress in
1201
- let progress = Self.mapProgress(
1202
- unzipProgress,
1203
- start: UpdateProgress.extractionStart,
1204
- end: UpdateProgress.extractionEnd
2246
+ self.emitArchiveProgress(
2247
+ progressHandler: progressHandler,
2248
+ progress: Self.mapProgress(
2249
+ unzipProgress,
2250
+ start: UpdateProgress.extractionStart,
2251
+ end: UpdateProgress.extractionEnd
2252
+ )
1205
2253
  )
1206
- progressHandler(progress)
1207
2254
  })
1208
2255
  NSLog("[BundleStorage] Extraction complete at \(tmpDir)")
1209
2256
  logFileSystemDiagnostics(path: tmpDir, context: "After Extraction")
@@ -1221,7 +2268,10 @@ class BundleFileStorageService: BundleStorageService {
1221
2268
  try? self.fileSystem.removeItem(atPath: tempBundleFile)
1222
2269
 
1223
2270
  // 8) Verify that a valid bundle file exists inside tmpDir
1224
- progressHandler(UpdateProgress.bundleValidation)
2271
+ self.emitArchiveProgress(
2272
+ progressHandler: progressHandler,
2273
+ progress: UpdateProgress.bundleValidation
2274
+ )
1225
2275
  switch self.findBundleFile(in: tmpDir) {
1226
2276
  case .success(let maybeBundlePath):
1227
2277
  if let bundlePathInTmp = maybeBundlePath {
@@ -1265,13 +2315,19 @@ class BundleFileStorageService: BundleStorageService {
1265
2315
  // 14) Clean up the temporary directory
1266
2316
  self.cleanupTemporaryFiles([tempDirectory])
1267
2317
 
1268
- progressHandler(UpdateProgress.activationReady)
2318
+ self.emitArchiveProgress(
2319
+ progressHandler: progressHandler,
2320
+ progress: UpdateProgress.activationReady
2321
+ )
1269
2322
  self.scheduleCleanupOldBundles(
1270
2323
  bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
1271
2324
  )
1272
2325
 
1273
2326
  // 15) Complete with success
1274
- progressHandler(UpdateProgress.complete)
2327
+ self.emitArchiveProgress(
2328
+ progressHandler: progressHandler,
2329
+ progress: UpdateProgress.complete
2330
+ )
1275
2331
  completion(.success(true))
1276
2332
  case .failure(let err):
1277
2333
  let nsError = err as NSError
@@ -1372,41 +2428,31 @@ class BundleFileStorageService: BundleStorageService {
1372
2428
  * Returns the file:// URL to the bundle directory without trailing slash
1373
2429
  */
1374
2430
  func getBaseURL() -> String {
1375
- do {
1376
- if let bundleId = getActiveBundleId() {
1377
- if case .success(let storeDir) = bundleStoreDir() {
1378
- let bundleDir = (storeDir as NSString).appendingPathComponent(bundleId)
1379
- if fileSystem.fileExists(atPath: bundleDir) {
1380
- return "file://\(bundleDir)"
1381
- }
2431
+ if let bundleId = getActiveBundleId() {
2432
+ if case .success(let storeDir) = bundleStoreDir() {
2433
+ let bundleDir = (storeDir as NSString).appendingPathComponent(bundleId)
2434
+ if fileSystem.fileExists(atPath: bundleDir) {
2435
+ return "file://\(bundleDir)"
1382
2436
  }
1383
2437
  }
1384
-
1385
- return ""
1386
- } catch {
1387
- NSLog("[BundleStorage] Error getting base URL: \(error)")
1388
- return ""
1389
2438
  }
2439
+
2440
+ return ""
1390
2441
  }
1391
2442
 
1392
2443
  func getBaseURL(forBundleId bundleId: String?) -> String {
1393
- do {
1394
- guard let bundleId = bundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
1395
- !bundleId.isEmpty,
1396
- case .success(let storeDir) = bundleStoreDir() else {
1397
- return ""
1398
- }
1399
-
1400
- let bundleDir = (storeDir as NSString).appendingPathComponent(bundleId)
1401
- guard fileSystem.fileExists(atPath: bundleDir) else {
1402
- return ""
1403
- }
2444
+ guard let bundleId = bundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
2445
+ !bundleId.isEmpty,
2446
+ case .success(let storeDir) = bundleStoreDir() else {
2447
+ return ""
2448
+ }
1404
2449
 
1405
- return "file://\(bundleDir)"
1406
- } catch {
1407
- NSLog("[BundleStorage] Error getting base URL for bundle \(bundleId ?? "nil"): \(error)")
2450
+ let bundleDir = (storeDir as NSString).appendingPathComponent(bundleId)
2451
+ guard fileSystem.fileExists(atPath: bundleDir) else {
1408
2452
  return ""
1409
2453
  }
2454
+
2455
+ return "file://\(bundleDir)"
1410
2456
  }
1411
2457
 
1412
2458
  func getBundleId() -> String? {