@hot-updater/react-native 0.23.1 → 0.24.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 (109) hide show
  1. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +393 -49
  2. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +204 -0
  3. package/android/src/main/java/com/hotupdater/HotUpdater.kt +48 -36
  4. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +134 -0
  5. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +168 -95
  6. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +15 -3
  7. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +17 -12
  8. package/android/src/newarch/HotUpdaterModule.kt +88 -23
  9. package/android/src/oldarch/HotUpdaterModule.kt +89 -22
  10. package/android/src/oldarch/HotUpdaterSpec.kt +6 -0
  11. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +401 -77
  12. package/ios/HotUpdater/Internal/BundleMetadata.swift +177 -0
  13. package/ios/HotUpdater/Internal/HotUpdater.mm +213 -47
  14. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +96 -25
  15. package/ios/HotUpdater/Internal/SignatureVerifier.swift +35 -29
  16. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +2 -2
  17. package/ios/HotUpdater/Public/HotUpdater.h +8 -2
  18. package/lib/commonjs/DefaultResolver.js +38 -0
  19. package/lib/commonjs/DefaultResolver.js.map +1 -0
  20. package/lib/commonjs/checkForUpdate.js +33 -45
  21. package/lib/commonjs/checkForUpdate.js.map +1 -1
  22. package/lib/commonjs/error.js +45 -1
  23. package/lib/commonjs/error.js.map +1 -1
  24. package/lib/commonjs/fetchUpdateInfo.js +7 -45
  25. package/lib/commonjs/fetchUpdateInfo.js.map +1 -1
  26. package/lib/commonjs/index.js +249 -208
  27. package/lib/commonjs/index.js.map +1 -1
  28. package/lib/commonjs/native.js +103 -3
  29. package/lib/commonjs/native.js.map +1 -1
  30. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  31. package/lib/commonjs/types.js +12 -0
  32. package/lib/commonjs/types.js.map +1 -1
  33. package/lib/commonjs/wrap.js +70 -1
  34. package/lib/commonjs/wrap.js.map +1 -1
  35. package/lib/module/DefaultResolver.js +34 -0
  36. package/lib/module/DefaultResolver.js.map +1 -0
  37. package/lib/module/checkForUpdate.js +34 -43
  38. package/lib/module/checkForUpdate.js.map +1 -1
  39. package/lib/module/error.js +45 -0
  40. package/lib/module/error.js.map +1 -1
  41. package/lib/module/fetchUpdateInfo.js +7 -45
  42. package/lib/module/fetchUpdateInfo.js.map +1 -1
  43. package/lib/module/index.js +250 -203
  44. package/lib/module/index.js.map +1 -1
  45. package/lib/module/native.js +87 -2
  46. package/lib/module/native.js.map +1 -1
  47. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  48. package/lib/module/types.js +12 -0
  49. package/lib/module/types.js.map +1 -1
  50. package/lib/module/wrap.js +71 -2
  51. package/lib/module/wrap.js.map +1 -1
  52. package/lib/typescript/commonjs/DefaultResolver.d.ts +10 -0
  53. package/lib/typescript/commonjs/DefaultResolver.d.ts.map +1 -0
  54. package/lib/typescript/commonjs/checkForUpdate.d.ts +12 -13
  55. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/error.d.ts +120 -0
  57. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/fetchUpdateInfo.d.ts +3 -5
  59. package/lib/typescript/commonjs/fetchUpdateInfo.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/index.d.ts +38 -44
  61. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/native.d.ts +58 -2
  63. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +62 -0
  65. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/types.d.ts +115 -0
  67. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/wrap.d.ts +132 -7
  69. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  70. package/lib/typescript/module/DefaultResolver.d.ts +10 -0
  71. package/lib/typescript/module/DefaultResolver.d.ts.map +1 -0
  72. package/lib/typescript/module/checkForUpdate.d.ts +12 -13
  73. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  74. package/lib/typescript/module/error.d.ts +120 -0
  75. package/lib/typescript/module/error.d.ts.map +1 -1
  76. package/lib/typescript/module/fetchUpdateInfo.d.ts +3 -5
  77. package/lib/typescript/module/fetchUpdateInfo.d.ts.map +1 -1
  78. package/lib/typescript/module/index.d.ts +38 -44
  79. package/lib/typescript/module/index.d.ts.map +1 -1
  80. package/lib/typescript/module/native.d.ts +58 -2
  81. package/lib/typescript/module/native.d.ts.map +1 -1
  82. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +62 -0
  83. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  84. package/lib/typescript/module/types.d.ts +115 -0
  85. package/lib/typescript/module/types.d.ts.map +1 -1
  86. package/lib/typescript/module/wrap.d.ts +132 -7
  87. package/lib/typescript/module/wrap.d.ts.map +1 -1
  88. package/package.json +6 -6
  89. package/plugin/build/withHotUpdater.js +3 -3
  90. package/src/DefaultResolver.ts +36 -0
  91. package/src/checkForUpdate.ts +51 -56
  92. package/src/error.ts +153 -0
  93. package/src/fetchUpdateInfo.ts +10 -58
  94. package/src/index.ts +315 -206
  95. package/src/native.ts +88 -2
  96. package/src/specs/NativeHotUpdater.ts +63 -0
  97. package/src/types.ts +135 -0
  98. package/src/wrap.tsx +245 -34
  99. package/android/src/main/java/com/hotupdater/HotUpdaterFactory.kt +0 -52
  100. package/ios/HotUpdater/Internal/HotUpdaterFactory.swift +0 -24
  101. package/lib/commonjs/runUpdateProcess.js +0 -69
  102. package/lib/commonjs/runUpdateProcess.js.map +0 -1
  103. package/lib/module/runUpdateProcess.js +0 -64
  104. package/lib/module/runUpdateProcess.js.map +0 -1
  105. package/lib/typescript/commonjs/runUpdateProcess.d.ts +0 -49
  106. package/lib/typescript/commonjs/runUpdateProcess.d.ts.map +0 -1
  107. package/lib/typescript/module/runUpdateProcess.d.ts +0 -49
  108. package/lib/typescript/module/runUpdateProcess.d.ts.map +0 -1
  109. package/src/runUpdateProcess.ts +0 -80
@@ -1,40 +1,38 @@
1
1
  import Foundation
2
2
 
3
3
  public enum BundleStorageError: Error, CustomNSError {
4
- case bundleNotFound
5
4
  case directoryCreationFailed
6
5
  case downloadFailed(Error)
7
- case extractionFailed(Error)
6
+ case incompleteDownload(expected: Int64, actual: Int64)
7
+ case extractionFormatError(Error)
8
8
  case invalidBundle
9
- case invalidZipFile
10
9
  case insufficientDiskSpace
11
- case hashMismatch
12
10
  case signatureVerificationFailed(SignatureVerificationError)
13
11
  case moveOperationFailed(Error)
14
- case copyOperationFailed(Error)
15
- case fileSystemError(Error)
12
+ case bundleInCrashedHistory(String)
16
13
  case unknown(Error?)
17
14
 
18
15
  // CustomNSError protocol implementation
19
16
  public static var errorDomain: String {
20
- return "com.hotupdater.BundleStorageError"
17
+ return "HotUpdater"
21
18
  }
22
19
 
23
20
  public var errorCode: Int {
21
+ return 0
22
+ }
23
+
24
+ public var errorCodeString: String {
24
25
  switch self {
25
- case .bundleNotFound: return 1001
26
- case .directoryCreationFailed: return 1002
27
- case .downloadFailed: return 1003
28
- case .extractionFailed: return 1004
29
- case .invalidBundle: return 1005
30
- case .invalidZipFile: return 1006
31
- case .insufficientDiskSpace: return 1007
32
- case .hashMismatch: return 1008
33
- case .signatureVerificationFailed: return 1009
34
- case .moveOperationFailed: return 1010
35
- case .copyOperationFailed: return 1011
36
- case .fileSystemError: return 1012
37
- case .unknown: return 1099
26
+ case .directoryCreationFailed: return "DIRECTORY_CREATION_FAILED"
27
+ case .downloadFailed: return "DOWNLOAD_FAILED"
28
+ case .incompleteDownload: return "INCOMPLETE_DOWNLOAD"
29
+ case .extractionFormatError: return "EXTRACTION_FORMAT_ERROR"
30
+ case .invalidBundle: return "INVALID_BUNDLE"
31
+ case .insufficientDiskSpace: return "INSUFFICIENT_DISK_SPACE"
32
+ case .signatureVerificationFailed: return "SIGNATURE_VERIFICATION_FAILED"
33
+ case .moveOperationFailed: return "MOVE_OPERATION_FAILED"
34
+ case .bundleInCrashedHistory: return "BUNDLE_IN_CRASHED_HISTORY"
35
+ case .unknown: return "UNKNOWN_ERROR"
38
36
  }
39
37
  }
40
38
 
@@ -42,10 +40,6 @@ public enum BundleStorageError: Error, CustomNSError {
42
40
  var userInfo: [String: Any] = [:]
43
41
 
44
42
  switch self {
45
- case .bundleNotFound:
46
- userInfo[NSLocalizedDescriptionKey] = "Bundle file not found in the downloaded archive"
47
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Ensure the bundle archive contains index.ios.bundle or main.jsbundle"
48
-
49
43
  case .directoryCreationFailed:
50
44
  userInfo[NSLocalizedDescriptionKey] = "Failed to create required directory for bundle storage"
51
45
  userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Check app permissions and available disk space"
@@ -55,27 +49,23 @@ public enum BundleStorageError: Error, CustomNSError {
55
49
  userInfo[NSUnderlyingErrorKey] = underlyingError
56
50
  userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Check network connection and try again"
57
51
 
58
- case .extractionFailed(let underlyingError):
59
- userInfo[NSLocalizedDescriptionKey] = "Failed to extract bundle archive"
52
+ case .incompleteDownload(let expected, let actual):
53
+ userInfo[NSLocalizedDescriptionKey] = "Download incomplete: received \(actual) bytes, expected \(expected) bytes"
54
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The download was interrupted. Check network connection and try again"
55
+
56
+ case .extractionFormatError(let underlyingError):
57
+ userInfo[NSLocalizedDescriptionKey] = "Invalid or corrupted bundle archive format"
60
58
  userInfo[NSUnderlyingErrorKey] = underlyingError
61
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The downloaded file may be corrupted. Try downloading again"
59
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The bundle archive may be corrupted or in an unsupported format. Try downloading again"
62
60
 
63
61
  case .invalidBundle:
64
- userInfo[NSLocalizedDescriptionKey] = "Downloaded archive does not contain a valid React Native bundle"
62
+ userInfo[NSLocalizedDescriptionKey] = "Bundle missing required platform files (index.ios.bundle or main.jsbundle)"
65
63
  userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Verify the bundle was built correctly with metro bundler"
66
64
 
67
- case .invalidZipFile:
68
- userInfo[NSLocalizedDescriptionKey] = "Downloaded file is not a valid ZIP archive"
69
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The file may be corrupted during download"
70
-
71
65
  case .insufficientDiskSpace:
72
66
  userInfo[NSLocalizedDescriptionKey] = "Insufficient disk space to download and extract bundle"
73
67
  userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Free up device storage and try again"
74
68
 
75
- case .hashMismatch:
76
- userInfo[NSLocalizedDescriptionKey] = "Downloaded bundle hash does not match expected hash"
77
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The file may have been corrupted or tampered with. Try downloading again"
78
-
79
69
  case .signatureVerificationFailed(let underlyingError):
80
70
  userInfo[NSLocalizedDescriptionKey] = "Bundle signature verification failed"
81
71
  userInfo[NSUnderlyingErrorKey] = underlyingError
@@ -86,15 +76,9 @@ public enum BundleStorageError: Error, CustomNSError {
86
76
  userInfo[NSUnderlyingErrorKey] = underlyingError
87
77
  userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Check file system permissions"
88
78
 
89
- case .copyOperationFailed(let underlyingError):
90
- userInfo[NSLocalizedDescriptionKey] = "Failed to copy bundle files"
91
- userInfo[NSUnderlyingErrorKey] = underlyingError
92
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Check available disk space and permissions"
93
-
94
- case .fileSystemError(let underlyingError):
95
- userInfo[NSLocalizedDescriptionKey] = "File system operation failed"
96
- userInfo[NSUnderlyingErrorKey] = underlyingError
97
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Check app permissions and disk space"
79
+ case .bundleInCrashedHistory(let bundleId):
80
+ userInfo[NSLocalizedDescriptionKey] = "Bundle '\(bundleId)' is in crashed history and cannot be applied"
81
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "This bundle previously caused a crash and was blocked for safety"
98
82
 
99
83
  case .unknown(let underlyingError):
100
84
  userInfo[NSLocalizedDescriptionKey] = "An unknown error occurred during bundle update"
@@ -114,15 +98,20 @@ public enum BundleStorageError: Error, CustomNSError {
114
98
  * Other operations are synchronous.
115
99
  */
116
100
  public protocol BundleStorageService {
117
-
101
+
118
102
  // Bundle URL operations
119
103
  func setBundleURL(localPath: String?) -> Result<Void, Error>
120
104
  func getCachedBundleURL() -> URL?
121
105
  func getFallbackBundleURL() -> URL? // Synchronous as it's lightweight
122
106
  func getBundleURL() -> URL?
123
-
107
+
124
108
  // Bundle update
125
109
  func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<Bool, Error>) -> Void)
110
+
111
+ // Rollback support
112
+ func notifyAppReady(bundleId: String) -> [String: Any]
113
+ func getCrashHistory() -> CrashedHistory
114
+ func clearCrashHistory() -> Bool
126
115
  }
127
116
 
128
117
  class BundleFileStorageService: BundleStorageService {
@@ -131,11 +120,16 @@ class BundleFileStorageService: BundleStorageService {
131
120
  private let decompressService: DecompressService
132
121
  private let preferences: PreferencesService
133
122
 
123
+ private let id = Int.random(in: 1..<100)
124
+
134
125
  // Queue for potentially long-running sequences within updateBundle or for explicit background tasks.
135
126
  private let fileOperationQueue: DispatchQueue
136
127
 
137
128
  private var activeTasks: [URLSessionTask] = []
138
129
 
130
+ // Session-only rollback tracking (in-memory)
131
+ private var sessionRollbackBundleId: String?
132
+
139
133
  public init(fileSystem: FileSystemService,
140
134
  downloadService: DownloadService,
141
135
  decompressService: DecompressService,
@@ -146,10 +140,161 @@ class BundleFileStorageService: BundleStorageService {
146
140
  self.decompressService = decompressService
147
141
  self.preferences = preferences
148
142
 
143
+ // Create queue for file operations
149
144
  self.fileOperationQueue = DispatchQueue(label: "com.hotupdater.fileoperations",
150
145
  qos: .utility,
151
146
  attributes: .concurrent)
152
147
  }
148
+
149
+ // MARK: - Metadata File Paths
150
+
151
+ private func metadataFileURL() -> URL? {
152
+ guard case .success(let storeDir) = bundleStoreDir() else {
153
+ return nil
154
+ }
155
+ return URL(fileURLWithPath: storeDir).appendingPathComponent(BundleMetadata.metadataFilename)
156
+ }
157
+
158
+ private func crashedHistoryFileURL() -> URL? {
159
+ guard case .success(let storeDir) = bundleStoreDir() else {
160
+ return nil
161
+ }
162
+ return URL(fileURLWithPath: storeDir).appendingPathComponent(CrashedHistory.crashedHistoryFilename)
163
+ }
164
+
165
+ // MARK: - Metadata Operations
166
+
167
+ private func loadMetadataOrNull() -> BundleMetadata? {
168
+ guard let file = metadataFileURL() else {
169
+ return nil
170
+ }
171
+ return BundleMetadata.load(from: file)
172
+ }
173
+
174
+ private func saveMetadata(_ metadata: BundleMetadata) -> Bool {
175
+ guard let file = metadataFileURL() else {
176
+ return false
177
+ }
178
+ return metadata.save(to: file)
179
+ }
180
+
181
+ // MARK: - Crashed History Operations
182
+
183
+ private func loadCrashedHistory() -> CrashedHistory {
184
+ guard let file = crashedHistoryFileURL() else {
185
+ return CrashedHistory()
186
+ }
187
+ return CrashedHistory.load(from: file)
188
+ }
189
+
190
+ private func saveCrashedHistory(_ history: CrashedHistory) -> Bool {
191
+ guard let file = crashedHistoryFileURL() else {
192
+ return false
193
+ }
194
+ return history.save(to: file)
195
+ }
196
+
197
+ // MARK: - State Machine Methods
198
+
199
+ private func isVerificationPending(_ metadata: BundleMetadata) -> Bool {
200
+ return metadata.verificationPending && metadata.stagingBundleId != nil
201
+ }
202
+
203
+ private func wasVerificationAttempted(_ metadata: BundleMetadata) -> Bool {
204
+ return metadata.verificationAttemptedAt != nil
205
+ }
206
+
207
+ private func markVerificationAttempted() {
208
+ guard var metadata = loadMetadataOrNull() else {
209
+ return
210
+ }
211
+ metadata.verificationAttemptedAt = Date().timeIntervalSince1970 * 1000
212
+ let _ = saveMetadata(metadata)
213
+ NSLog("[BundleStorage] Marked verification attempted for staging bundle: \(metadata.stagingBundleId ?? "nil")")
214
+ }
215
+
216
+ private func incrementStagingExecutionCount() {
217
+ guard var metadata = loadMetadataOrNull() else {
218
+ return
219
+ }
220
+ metadata.stagingExecutionCount = (metadata.stagingExecutionCount ?? 0) + 1
221
+ metadata.updatedAt = Date().timeIntervalSince1970 * 1000
222
+ let _ = saveMetadata(metadata)
223
+ NSLog("[BundleStorage] Incremented staging execution count to: \(metadata.stagingExecutionCount ?? 0)")
224
+ }
225
+
226
+ private func promoteStagingToStable() {
227
+ guard var metadata = loadMetadataOrNull() else {
228
+ return
229
+ }
230
+ guard let stagingId = metadata.stagingBundleId else {
231
+ NSLog("[BundleStorage] No staging bundle to promote")
232
+ return
233
+ }
234
+
235
+ let oldStableId = metadata.stableBundleId
236
+ metadata.stableBundleId = stagingId
237
+ metadata.stagingBundleId = nil
238
+ metadata.verificationPending = false
239
+ metadata.verificationAttemptedAt = nil
240
+ metadata.stagingExecutionCount = nil
241
+ metadata.updatedAt = Date().timeIntervalSince1970 * 1000
242
+
243
+ if saveMetadata(metadata) {
244
+ NSLog("[BundleStorage] Promoted staging '\(stagingId)' to stable (old stable: \(oldStableId ?? "nil"))")
245
+
246
+ // Clean up old stable bundle
247
+ if let oldId = oldStableId, oldId != stagingId {
248
+ let _ = cleanupOldBundles(currentBundleId: stagingId, bundleId: nil)
249
+ }
250
+ }
251
+ }
252
+
253
+ private func rollbackToStable() {
254
+ guard var metadata = loadMetadataOrNull() else {
255
+ return
256
+ }
257
+ guard let stagingId = metadata.stagingBundleId else {
258
+ NSLog("[BundleStorage] No staging bundle to rollback from")
259
+ return
260
+ }
261
+
262
+ // Add crashed bundle to history
263
+ var crashedHistory = loadCrashedHistory()
264
+ crashedHistory.addEntry(stagingId)
265
+ let _ = saveCrashedHistory(crashedHistory)
266
+ NSLog("[BundleStorage('\(id)')] Added bundle '\(stagingId)' to crashed history")
267
+
268
+ // Save rollback info to session variable (memory only)
269
+ self.sessionRollbackBundleId = stagingId
270
+
271
+ // Clear staging
272
+ metadata.stagingBundleId = nil
273
+ metadata.verificationPending = false
274
+ metadata.verificationAttemptedAt = nil
275
+ metadata.stagingExecutionCount = nil
276
+ metadata.updatedAt = Date().timeIntervalSince1970 * 1000
277
+
278
+ if saveMetadata(metadata) {
279
+ NSLog("[BundleStorage] Rolled back to stable bundle: \(metadata.stableBundleId ?? "fallback")")
280
+
281
+ // Update HotUpdaterBundleURL to point to stable bundle
282
+ if let stableId = metadata.stableBundleId {
283
+ if case .success(let storeDir) = bundleStoreDir() {
284
+ let stableBundleDir = (storeDir as NSString).appendingPathComponent(stableId)
285
+ if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), let path = bundlePath {
286
+ let _ = setBundleURL(localPath: path)
287
+ }
288
+ }
289
+ } else {
290
+ // Reset to fallback
291
+ let _ = setBundleURL(localPath: nil)
292
+ }
293
+
294
+ // Clean up failed staging bundle
295
+ let _ = cleanupOldBundles(currentBundleId: metadata.stableBundleId, bundleId: nil)
296
+ }
297
+ }
153
298
 
154
299
  // MARK: - Directory Management
155
300
 
@@ -276,11 +421,17 @@ class BundleFileStorageService: BundleStorageService {
276
421
  contents = try self.fileSystem.contentsOfDirectory(atPath: storeDir)
277
422
  } catch let error {
278
423
  NSLog("[BundleStorage] Failed to list contents of bundle store directory: \(storeDir)")
279
- return .failure(BundleStorageError.fileSystemError(error))
424
+ return .failure(BundleStorageError.unknown(error))
280
425
  }
281
426
 
282
427
  let bundles = contents.compactMap { item -> String? in
283
428
  let fullPath = (storeDir as NSString).appendingPathComponent(item)
429
+
430
+ // Skip metadata files - DO NOT delete
431
+ if item == "metadata.json" || item == "crashed-history.json" {
432
+ return nil
433
+ }
434
+
284
435
  return (!item.hasSuffix(".tmp") && self.fileSystem.fileExists(atPath: fullPath)) ? fullPath : nil
285
436
  }
286
437
 
@@ -360,7 +511,64 @@ class BundleFileStorageService: BundleStorageService {
360
511
  }
361
512
 
362
513
  public func getBundleURL() -> URL? {
363
- return getCachedBundleURL() ?? getFallbackBundleURL()
514
+ // Try to load metadata
515
+ let metadata = loadMetadataOrNull()
516
+
517
+ // If no metadata exists, use legacy behavior (backwards compatible)
518
+ guard let metadata = metadata else {
519
+ let cached = getCachedBundleURL()
520
+ return cached ?? getFallbackBundleURL()
521
+ }
522
+
523
+ // Check if we need to handle crash recovery
524
+ if isVerificationPending(metadata) {
525
+ let executionCount = metadata.stagingExecutionCount ?? 0
526
+
527
+ if executionCount == 0 {
528
+ // First execution - give staging bundle a chance
529
+ NSLog("[BundleStorage] First execution of staging bundle, incrementing counter")
530
+ incrementStagingExecutionCount()
531
+ // Don't mark verificationAttempted yet!
532
+ } else if wasVerificationAttempted(metadata) {
533
+ // Already executed once and verificationAttempted is set → crash!
534
+ NSLog("[BundleStorage] Crash detected: staging bundle executed but didn't call notifyAppReady")
535
+ rollbackToStable()
536
+ } else {
537
+ // Second execution - now mark verification attempted
538
+ NSLog("[BundleStorage] Second execution of staging bundle, marking verification attempted")
539
+ markVerificationAttempted()
540
+ }
541
+ }
542
+
543
+ // Reload metadata after potential rollback
544
+ guard let currentMetadata = loadMetadataOrNull() else {
545
+ return getCachedBundleURL() ?? getFallbackBundleURL()
546
+ }
547
+
548
+ // If verification is pending, return staging bundle URL
549
+ if isVerificationPending(currentMetadata), let stagingId = currentMetadata.stagingBundleId {
550
+ if case .success(let storeDir) = bundleStoreDir() {
551
+ let stagingBundleDir = (storeDir as NSString).appendingPathComponent(stagingId)
552
+ if case .success(let bundlePath) = findBundleFile(in: stagingBundleDir), let path = bundlePath {
553
+ NSLog("[BundleStorage] Returning staging bundle URL: \(path)")
554
+ return URL(fileURLWithPath: path)
555
+ }
556
+ }
557
+ }
558
+
559
+ // Return stable bundle URL
560
+ if let stableId = currentMetadata.stableBundleId {
561
+ if case .success(let storeDir) = bundleStoreDir() {
562
+ let stableBundleDir = (storeDir as NSString).appendingPathComponent(stableId)
563
+ if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), let path = bundlePath {
564
+ NSLog("[BundleStorage] Returning stable bundle URL: \(path)")
565
+ return URL(fileURLWithPath: path)
566
+ }
567
+ }
568
+ }
569
+
570
+ // Fallback to app bundle
571
+ return getFallbackBundleURL()
364
572
  }
365
573
 
366
574
  // MARK: - Bundle Update
@@ -374,9 +582,17 @@ class BundleFileStorageService: BundleStorageService {
374
582
  * @param completion Callback with result of the operation
375
583
  */
376
584
  func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<Bool, Error>) -> Void) {
585
+ // Check if bundle is in crashed history
586
+ let crashedHistory = loadCrashedHistory()
587
+ if crashedHistory.contains(bundleId) {
588
+ NSLog("[BundleStorage] Bundle '\(bundleId)' is in crashed history, rejecting update")
589
+ completion(.failure(BundleStorageError.bundleInCrashedHistory(bundleId)))
590
+ return
591
+ }
592
+
377
593
  // Get the current bundle ID from the cached bundle URL (exclude fallback bundles)
378
594
  let currentBundleId = self.getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent
379
-
595
+
380
596
  guard let validFileUrl = fileUrl else {
381
597
  NSLog("[BundleStorage] fileUrl is nil, resetting bundle URL.")
382
598
  // Dispatch the sequence to the file operation queue to ensure completion is called asynchronously
@@ -403,7 +619,7 @@ class BundleFileStorageService: BundleStorageService {
403
619
 
404
620
  // Start the bundle update process on a background queue
405
621
  fileOperationQueue.async {
406
-
622
+
407
623
  let storeDirResult = self.bundleStoreDir()
408
624
  guard case .success(let storeDir) = storeDirResult else {
409
625
  completion(.failure(storeDirResult.failureError ?? BundleStorageError.unknown(nil)))
@@ -418,22 +634,28 @@ class BundleFileStorageService: BundleStorageService {
418
634
  case .success(let existingBundlePath):
419
635
  if let bundlePath = existingBundlePath {
420
636
  NSLog("[BundleStorage] Using cached bundle at path: \(bundlePath)")
421
- do {
422
- let setResult = self.setBundleURL(localPath: bundlePath)
423
- switch setResult {
424
- case .success:
425
- let cleanupResult = self.cleanupOldBundles(currentBundleId: currentBundleId, bundleId: bundleId)
426
- switch cleanupResult {
427
- case .success:
428
- completion(.success(true))
429
- case .failure(let error):
430
- NSLog("[BundleStorage] Warning: Cleanup failed but bundle is set: \(error)")
431
- completion(.failure(error))
432
- }
433
- case .failure(let error):
434
- completion(.failure(error))
637
+ let setResult = self.setBundleURL(localPath: bundlePath)
638
+ switch setResult {
639
+ case .success:
640
+ // Set staging metadata for rollback support
641
+ var metadata = self.loadMetadataOrNull() ?? BundleMetadata()
642
+ metadata.stagingBundleId = bundleId
643
+ metadata.verificationPending = true
644
+ metadata.verificationAttemptedAt = nil
645
+ metadata.stagingExecutionCount = 0
646
+ metadata.updatedAt = Date().timeIntervalSince1970 * 1000
647
+ let _ = self.saveMetadata(metadata)
648
+ NSLog("[BundleStorage] Set staging bundle (cached): \(bundleId), verificationPending: true")
649
+
650
+ // Clean up old bundles, preserving stable and new staging
651
+ let stableId = metadata.stableBundleId
652
+ let bundleIdsToKeep = [stableId, bundleId].compactMap { $0 }
653
+ if bundleIdsToKeep.count > 0 {
654
+ let _ = self.cleanupOldBundles(currentBundleId: bundleIdsToKeep.first, bundleId: bundleIdsToKeep.count > 1 ? bundleIdsToKeep[1] : nil)
435
655
  }
436
- } catch let error {
656
+
657
+ completion(.success(true))
658
+ case .failure(let error):
437
659
  completion(.failure(error))
438
660
  }
439
661
  return
@@ -445,7 +667,7 @@ class BundleFileStorageService: BundleStorageService {
445
667
  self.prepareAndDownloadBundle(bundleId: bundleId, fileUrl: validFileUrl, fileHash: fileHash, storeDir: storeDir, progressHandler: progressHandler, completion: completion)
446
668
  } catch let error {
447
669
  NSLog("[BundleStorage] Failed to remove invalid bundle dir: \(error.localizedDescription)")
448
- completion(.failure(BundleStorageError.fileSystemError(error)))
670
+ completion(.failure(BundleStorageError.unknown(error)))
449
671
  }
450
672
  }
451
673
  case .failure(let error):
@@ -558,7 +780,14 @@ class BundleFileStorageService: BundleStorageService {
558
780
  case .failure(let error):
559
781
  NSLog("[BundleStorage] Download failed: \(error.localizedDescription)")
560
782
  self.cleanupTemporaryFiles([tempDirectory]) // Sync cleanup
561
- completion(.failure(BundleStorageError.downloadFailed(error)))
783
+
784
+ // Map DownloadError.incompleteDownload to BundleStorageError.incompleteDownload
785
+ if let downloadError = error as? DownloadError,
786
+ case .incompleteDownload(let expected, let actual) = downloadError {
787
+ completion(.failure(BundleStorageError.incompleteDownload(expected: expected, actual: actual)))
788
+ } else {
789
+ completion(.failure(BundleStorageError.downloadFailed(error)))
790
+ }
562
791
  }
563
792
  }
564
793
  self.fileOperationQueue.async(execute: workItem)
@@ -628,10 +857,10 @@ class BundleFileStorageService: BundleStorageService {
628
857
  guard self.fileSystem.fileExists(atPath: location.path) else {
629
858
  logFileSystemDiagnostics(path: location.path, context: "Download Location Missing")
630
859
  self.cleanupTemporaryFiles([tempDirectory])
631
- completion(.failure(BundleStorageError.fileSystemError(NSError(
860
+ completion(.failure(BundleStorageError.downloadFailed(NSError(
632
861
  domain: "HotUpdaterError",
633
862
  code: 1,
634
- userInfo: [NSLocalizedDescriptionKey: "Source file does not exist atPath: \(location.path)"]
863
+ userInfo: [NSLocalizedDescriptionKey: "Downloaded file does not exist atPath: \(location.path)"]
635
864
  ))))
636
865
  return
637
866
  }
@@ -683,7 +912,7 @@ class BundleFileStorageService: BundleStorageService {
683
912
  logFileSystemDiagnostics(path: tmpDir, context: "Extraction Failed")
684
913
  try? self.fileSystem.removeItem(atPath: tmpDir)
685
914
  self.cleanupTemporaryFiles([tempDirectory])
686
- completion(.failure(BundleStorageError.extractionFailed(error)))
915
+ completion(.failure(BundleStorageError.extractionFormatError(error)))
687
916
  return
688
917
  }
689
918
 
@@ -719,18 +948,33 @@ class BundleFileStorageService: BundleStorageService {
719
948
  // 11) Construct final bundlePath for preferences
720
949
  let finalBundlePath = (realDir as NSString).appendingPathComponent((bundlePathInTmp as NSString).lastPathComponent)
721
950
 
722
- // 12) Set the bundle URL in preferences
951
+ // 12) Set the bundle URL in preferences (for backwards compatibility)
723
952
  let setResult = self.setBundleURL(localPath: finalBundlePath)
724
953
  switch setResult {
725
954
  case .success:
726
955
  NSLog("[BundleStorage] Successfully set bundle URL: \(finalBundlePath)")
727
- // 13) Clean up the temporary directory
956
+
957
+ // 13) Set staging metadata for rollback support
958
+ var metadata = self.loadMetadataOrNull() ?? BundleMetadata()
959
+ metadata.stagingBundleId = bundleId
960
+ metadata.verificationPending = true
961
+ metadata.verificationAttemptedAt = nil
962
+ metadata.stagingExecutionCount = 0
963
+ metadata.updatedAt = Date().timeIntervalSince1970 * 1000
964
+ let _ = self.saveMetadata(metadata)
965
+ NSLog("[BundleStorage] Set staging bundle: \(bundleId), verificationPending: true")
966
+
967
+ // 14) Clean up the temporary directory
728
968
  self.cleanupTemporaryFiles([tempDirectory])
729
969
 
730
- // 14) Clean up old bundles, preserving current and latest
731
- let _ = self.cleanupOldBundles(currentBundleId: currentBundleId, bundleId: bundleId)
970
+ // 15) Clean up old bundles, preserving current, stable, and new staging
971
+ let stableId = metadata.stableBundleId
972
+ let bundleIdsToKeep = [stableId, bundleId].compactMap { $0 }
973
+ if bundleIdsToKeep.count > 0 {
974
+ let _ = self.cleanupOldBundles(currentBundleId: bundleIdsToKeep.first, bundleId: bundleIdsToKeep.count > 1 ? bundleIdsToKeep[1] : nil)
975
+ }
732
976
 
733
- // 15) Complete with success
977
+ // 16) Complete with success
734
978
  completion(.success(true))
735
979
  case .failure(let err):
736
980
  let nsError = err as NSError
@@ -763,9 +1007,89 @@ class BundleFileStorageService: BundleStorageService {
763
1007
  logFileSystemDiagnostics(path: tmpDir, context: "Processing Error")
764
1008
  try? self.fileSystem.removeItem(atPath: tmpDir)
765
1009
  self.cleanupTemporaryFiles([tempDirectory])
766
- completion(.failure(BundleStorageError.fileSystemError(error)))
1010
+
1011
+ // Re-throw specific BundleStorageError if it is one, otherwise wrap as unknown
1012
+ if let bundleError = error as? BundleStorageError {
1013
+ completion(.failure(bundleError))
1014
+ } else {
1015
+ completion(.failure(BundleStorageError.unknown(error)))
1016
+ }
767
1017
  }
768
1018
  }
1019
+
1020
+ // MARK: - Rollback Support
1021
+
1022
+ /**
1023
+ * Notifies the system that the app has successfully started with the given bundle.
1024
+ * If the bundle matches the staging bundle, promotes it to stable.
1025
+ * @param bundleId The ID of the currently running bundle
1026
+ * @return true if promotion was successful or no action was needed
1027
+ */
1028
+ func notifyAppReady(bundleId: String) -> [String: Any] {
1029
+ NSLog("[BundleStorage('\(id)'] notifyAppReady: Called with bundleId '\(bundleId)'")
1030
+ guard var metadata = loadMetadataOrNull() else {
1031
+ // No metadata exists - legacy mode, nothing to do
1032
+ NSLog("[BundleStorage] notifyAppReady: No metadata exists (legacy mode)")
1033
+ return ["status": "STABLE"]
1034
+ }
1035
+
1036
+ // Check if there was a recent rollback (session variable)
1037
+ if let crashedBundleId = self.sessionRollbackBundleId {
1038
+ NSLog("[BundleStorage] notifyAppReady: Detected rollback recovery from '\(crashedBundleId)'")
1039
+
1040
+ // Clear rollback info (one-time read)
1041
+ self.sessionRollbackBundleId = nil
1042
+
1043
+ return [
1044
+ "status": "RECOVERED",
1045
+ "crashedBundleId": crashedBundleId
1046
+ ]
1047
+ }
1048
+
1049
+ // Check if the bundle matches the staging bundle (promotion case)
1050
+ if let stagingId = metadata.stagingBundleId, stagingId == bundleId, metadata.verificationPending {
1051
+ NSLog("[BundleStorage] notifyAppReady: Bundle '\(bundleId)' matches staging, promoting to stable")
1052
+ promoteStagingToStable()
1053
+ return ["status": "PROMOTED"]
1054
+ }
1055
+
1056
+ // Check if the bundle matches the stable bundle
1057
+ if let stableId = metadata.stableBundleId, stableId == bundleId {
1058
+ // Already stable, clear any pending verification state
1059
+ if metadata.verificationPending {
1060
+ metadata.verificationPending = false
1061
+ metadata.verificationAttemptedAt = nil
1062
+ metadata.updatedAt = Date().timeIntervalSince1970 * 1000
1063
+ let _ = saveMetadata(metadata)
1064
+ NSLog("[BundleStorage] notifyAppReady: Bundle '\(bundleId)' is stable, cleared pending verification")
1065
+ } else {
1066
+ NSLog("[BundleStorage] notifyAppReady: Bundle '\(bundleId)' is already stable")
1067
+ }
1068
+ return ["status": "STABLE"]
1069
+ }
1070
+
1071
+ // Bundle doesn't match staging or stable - might be fallback or unknown
1072
+ NSLog("[BundleStorage] notifyAppReady: Bundle '\(bundleId)' doesn't match staging or stable")
1073
+ return ["status": "STABLE"]
1074
+ }
1075
+
1076
+ /**
1077
+ * Returns the crashed bundle history.
1078
+ * @return The crashed history object
1079
+ */
1080
+ func getCrashHistory() -> CrashedHistory {
1081
+ return loadCrashedHistory()
1082
+ }
1083
+
1084
+ /**
1085
+ * Clears the crashed bundle history.
1086
+ * @return true if clearing was successful
1087
+ */
1088
+ func clearCrashHistory() -> Bool {
1089
+ var history = loadCrashedHistory()
1090
+ history.clear()
1091
+ return saveCrashedHistory(history)
1092
+ }
769
1093
  }
770
1094
 
771
1095
  // Helper to get the associated error from a Result, if it's a failure