@hot-updater/react-native 0.27.1 → 0.29.0

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 (87) hide show
  1. package/android/build.gradle +12 -0
  2. package/android/src/main/AndroidManifest.xml +3 -0
  3. package/android/src/main/AndroidManifestNew.xml +3 -0
  4. package/android/src/main/cpp/CMakeLists.txt +9 -0
  5. package/android/src/main/cpp/HotUpdaterRecovery.cpp +143 -0
  6. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +325 -210
  7. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +73 -16
  8. package/android/src/main/java/com/hotupdater/CohortService.kt +73 -0
  9. package/android/src/main/java/com/hotupdater/DecompressService.kt +28 -22
  10. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +1 -1
  11. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +51 -13
  12. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +533 -0
  13. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryReceiver.kt +14 -0
  14. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +55 -0
  15. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +19 -7
  16. package/android/src/newarch/HotUpdaterModule.kt +16 -25
  17. package/android/src/oldarch/HotUpdaterModule.kt +20 -26
  18. package/android/src/oldarch/HotUpdaterSpec.kt +12 -2
  19. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +340 -232
  20. package/ios/HotUpdater/Internal/BundleMetadata.swift +61 -8
  21. package/ios/HotUpdater/Internal/CohortService.swift +63 -0
  22. package/ios/HotUpdater/Internal/DecompressService.swift +53 -30
  23. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +9 -1
  24. package/ios/HotUpdater/Internal/HotUpdater.mm +376 -70
  25. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +7 -0
  26. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +4 -0
  27. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +321 -9
  28. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +24 -8
  29. package/lib/commonjs/DefaultResolver.js +3 -5
  30. package/lib/commonjs/DefaultResolver.js.map +1 -1
  31. package/lib/commonjs/checkForUpdate.js +2 -0
  32. package/lib/commonjs/checkForUpdate.js.map +1 -1
  33. package/lib/commonjs/index.js +13 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/native.js +211 -39
  36. package/lib/commonjs/native.js.map +1 -1
  37. package/lib/commonjs/native.spec.js +443 -0
  38. package/lib/commonjs/native.spec.js.map +1 -0
  39. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  40. package/lib/commonjs/types.js.map +1 -1
  41. package/lib/commonjs/wrap.js +4 -5
  42. package/lib/commonjs/wrap.js.map +1 -1
  43. package/lib/module/DefaultResolver.js +3 -5
  44. package/lib/module/DefaultResolver.js.map +1 -1
  45. package/lib/module/checkForUpdate.js +3 -1
  46. package/lib/module/checkForUpdate.js.map +1 -1
  47. package/lib/module/index.js +14 -1
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/native.js +204 -34
  50. package/lib/module/native.js.map +1 -1
  51. package/lib/module/native.spec.js +442 -0
  52. package/lib/module/native.spec.js.map +1 -0
  53. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  54. package/lib/module/types.js.map +1 -1
  55. package/lib/module/wrap.js +5 -6
  56. package/lib/module/wrap.js.map +1 -1
  57. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/index.d.ts +14 -1
  59. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/native.d.ts +43 -23
  61. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +32 -8
  63. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/types.d.ts +6 -3
  65. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/wrap.d.ts +3 -6
  67. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  68. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  69. package/lib/typescript/module/index.d.ts +14 -1
  70. package/lib/typescript/module/index.d.ts.map +1 -1
  71. package/lib/typescript/module/native.d.ts +43 -23
  72. package/lib/typescript/module/native.d.ts.map +1 -1
  73. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +32 -8
  74. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  75. package/lib/typescript/module/types.d.ts +6 -3
  76. package/lib/typescript/module/types.d.ts.map +1 -1
  77. package/lib/typescript/module/wrap.d.ts +3 -6
  78. package/lib/typescript/module/wrap.d.ts.map +1 -1
  79. package/package.json +6 -6
  80. package/src/DefaultResolver.ts +4 -4
  81. package/src/checkForUpdate.ts +4 -0
  82. package/src/index.ts +21 -0
  83. package/src/native.spec.ts +480 -0
  84. package/src/native.ts +285 -39
  85. package/src/specs/NativeHotUpdater.ts +36 -6
  86. package/src/types.ts +7 -3
  87. package/src/wrap.tsx +8 -12
@@ -1,5 +1,7 @@
1
1
  import Foundation
2
2
 
3
+ public typealias ManifestAssets = [String: Any]
4
+
3
5
  public enum BundleStorageError: Error, CustomNSError {
4
6
  case directoryCreationFailed
5
7
  case downloadFailed(Error)
@@ -54,9 +56,9 @@ public enum BundleStorageError: Error, CustomNSError {
54
56
  userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The download was interrupted. Check network connection and try again"
55
57
 
56
58
  case .extractionFormatError(let underlyingError):
57
- userInfo[NSLocalizedDescriptionKey] = "Invalid or corrupted bundle archive format"
59
+ userInfo[NSLocalizedDescriptionKey] = "The downloaded bundle file is not a valid compressed archive"
58
60
  userInfo[NSUnderlyingErrorKey] = underlyingError
59
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The bundle archive may be corrupted or in an unsupported format. Try downloading again"
61
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The downloaded file is not a supported bundle archive. Try downloading again"
60
62
 
61
63
  case .invalidBundle:
62
64
  userInfo[NSLocalizedDescriptionKey] = "Bundle missing required platform files (index.ios.bundle or main.jsbundle)"
@@ -103,13 +105,14 @@ public protocol BundleStorageService {
103
105
  func setBundleURL(localPath: String?) -> Result<Void, Error>
104
106
  func getCachedBundleURL() -> URL?
105
107
  func getFallbackBundleURL(bundle: Bundle) -> URL? // Synchronous as it's lightweight
106
- func getBundleURL(bundle: Bundle) -> URL?
108
+ func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection
107
109
 
108
110
  // Bundle update
109
111
  func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<Bool, Error>) -> Void)
110
112
 
111
113
  // Rollback support
112
- func notifyAppReady(bundleId: String) -> [String: Any]
114
+ func markLaunchCompleted(bundleId: String?)
115
+ func notifyAppReady() -> [String: Any]
113
116
  func getCrashHistory() -> CrashedHistory
114
117
  func clearCrashHistory() -> Bool
115
118
 
@@ -119,6 +122,18 @@ public protocol BundleStorageService {
119
122
  */
120
123
  func getBaseURL() -> String
121
124
 
125
+ /**
126
+ * Gets the current active bundle ID from bundle storage.
127
+ * Reads manifest.json first and falls back to older metadata when needed.
128
+ */
129
+ func getBundleId() -> String?
130
+
131
+ /**
132
+ * Gets the current manifest from bundle storage.
133
+ * Returns an empty object when manifest.json is missing or invalid.
134
+ */
135
+ func getManifest() -> ManifestAssets
136
+
122
137
  /**
123
138
  * Restores the original bundle and clears downloaded bundle state.
124
139
  */
@@ -126,6 +141,12 @@ public protocol BundleStorageService {
126
141
  }
127
142
 
128
143
  class BundleFileStorageService: BundleStorageService {
144
+ private struct ActiveBundleMetadataSnapshot {
145
+ let activeBundleId: String
146
+ let bundleId: String?
147
+ let manifest: ManifestAssets
148
+ }
149
+
129
150
  private let fileSystem: FileSystemService
130
151
  private let downloadService: DownloadService
131
152
  private let decompressService: DecompressService
@@ -139,8 +160,9 @@ class BundleFileStorageService: BundleStorageService {
139
160
 
140
161
  private var activeTasks: [URLSessionTask] = []
141
162
 
142
- // Session-only rollback tracking (in-memory)
143
- private var sessionRollbackBundleId: String?
163
+ private var currentLaunchReport: LaunchReport?
164
+ private let activeBundleMetadataLock = NSLock()
165
+ private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot?
144
166
 
145
167
  public init(fileSystem: FileSystemService,
146
168
  downloadService: DownloadService,
@@ -182,6 +204,13 @@ class BundleFileStorageService: BundleStorageService {
182
204
  return URL(fileURLWithPath: storeDir).appendingPathComponent(CrashedHistory.crashedHistoryFilename)
183
205
  }
184
206
 
207
+ private func launchReportFileURL() -> URL? {
208
+ guard case .success(let storeDir) = bundleStoreDir() else {
209
+ return nil
210
+ }
211
+ return URL(fileURLWithPath: storeDir).appendingPathComponent(LaunchReport.launchReportFilename)
212
+ }
213
+
185
214
  // MARK: - Metadata Operations
186
215
 
187
216
  private func loadMetadataOrNull() -> BundleMetadata? {
@@ -200,6 +229,170 @@ class BundleFileStorageService: BundleStorageService {
200
229
  return updatedMetadata.save(to: file)
201
230
  }
202
231
 
232
+ private func loadLaunchReport() -> LaunchReport? {
233
+ if let currentLaunchReport {
234
+ return currentLaunchReport
235
+ }
236
+ guard let file = launchReportFileURL(),
237
+ let report = LaunchReport.load(from: file) else {
238
+ return nil
239
+ }
240
+ currentLaunchReport = report
241
+ return report
242
+ }
243
+
244
+ private func saveLaunchReport(_ report: LaunchReport?) {
245
+ currentLaunchReport = report
246
+ guard let file = launchReportFileURL() else {
247
+ return
248
+ }
249
+
250
+ guard let report else {
251
+ if FileManager.default.fileExists(atPath: file.path) {
252
+ try? FileManager.default.removeItem(at: file)
253
+ }
254
+ return
255
+ }
256
+
257
+ _ = report.save(to: file)
258
+ }
259
+
260
+ private func createInitialMetadata() -> BundleMetadata {
261
+ let currentBundleId = getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent
262
+ return BundleMetadata(
263
+ isolationKey: isolationKey,
264
+ stableBundleId: nil,
265
+ stagingBundleId: currentBundleId,
266
+ verificationPending: false
267
+ )
268
+ }
269
+
270
+ private func getCurrentVerifiedBundleId(_ metadata: BundleMetadata) -> String? {
271
+ if let stagingBundleId = metadata.stagingBundleId, !metadata.verificationPending {
272
+ return stagingBundleId
273
+ }
274
+ return metadata.stableBundleId
275
+ }
276
+
277
+ private func getActiveBundleId() -> String? {
278
+ let metadata = loadMetadataOrNull()
279
+
280
+ if let stagingBundleId = metadata?.stagingBundleId {
281
+ return stagingBundleId
282
+ }
283
+
284
+ if let stableBundleId = metadata?.stableBundleId {
285
+ return stableBundleId
286
+ }
287
+
288
+ return getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent
289
+ }
290
+
291
+ private func withActiveBundleMetadataLock<T>(_ body: () -> T) -> T {
292
+ activeBundleMetadataLock.lock()
293
+ defer { activeBundleMetadataLock.unlock() }
294
+ return body()
295
+ }
296
+
297
+ private func clearActiveBundleMetadataSnapshot() {
298
+ withActiveBundleMetadataLock {
299
+ activeBundleMetadataSnapshot = nil
300
+ }
301
+ }
302
+
303
+ private func getActiveBundleMetadataSnapshot() -> ActiveBundleMetadataSnapshot? {
304
+ guard let activeBundleId = getActiveBundleId(),
305
+ case .success(let storeDir) = bundleStoreDir() else {
306
+ clearActiveBundleMetadataSnapshot()
307
+ return nil
308
+ }
309
+
310
+ if let snapshot = withActiveBundleMetadataLock({
311
+ activeBundleMetadataSnapshot?.activeBundleId == activeBundleId
312
+ ? activeBundleMetadataSnapshot
313
+ : nil
314
+ }) {
315
+ return snapshot
316
+ }
317
+
318
+ let bundleDir = (storeDir as NSString).appendingPathComponent(activeBundleId)
319
+ guard fileSystem.fileExists(atPath: bundleDir) else {
320
+ clearActiveBundleMetadataSnapshot()
321
+ return nil
322
+ }
323
+
324
+ let snapshot = resolveActiveBundleMetadataSnapshot(
325
+ activeBundleId: activeBundleId,
326
+ bundleDirectory: bundleDir
327
+ )
328
+ return withActiveBundleMetadataLock {
329
+ activeBundleMetadataSnapshot = snapshot
330
+ return snapshot
331
+ }
332
+ }
333
+
334
+ private func resolveActiveBundleMetadataSnapshot(
335
+ activeBundleId: String,
336
+ bundleDirectory: String
337
+ ) -> ActiveBundleMetadataSnapshot {
338
+ let manifest = readManifest(in: bundleDirectory) ?? [:]
339
+ let manifestBundleId =
340
+ (manifest["bundleId"] as? String)?
341
+ .trimmingCharacters(in: .whitespacesAndNewlines)
342
+ let resolvedBundleId =
343
+ (manifestBundleId?.isEmpty == false ? manifestBundleId : nil) ??
344
+ readCompatibilityBundleId(in: bundleDirectory)
345
+
346
+ return ActiveBundleMetadataSnapshot(
347
+ activeBundleId: activeBundleId,
348
+ bundleId: resolvedBundleId,
349
+ manifest: manifest
350
+ )
351
+ }
352
+
353
+ private func readCompatibilityBundleId(in bundleDirectory: String) -> String? {
354
+ let compatibilityBundleIdPath = (bundleDirectory as NSString)
355
+ .appendingPathComponent(compatibilityBundleIdFilename())
356
+ if fileSystem.fileExists(atPath: compatibilityBundleIdPath) {
357
+ do {
358
+ let compatibilityBundleId = try String(
359
+ contentsOfFile: compatibilityBundleIdPath,
360
+ encoding: .utf8
361
+ )
362
+ .trimmingCharacters(in: .whitespacesAndNewlines)
363
+
364
+ if !compatibilityBundleId.isEmpty {
365
+ return compatibilityBundleId
366
+ }
367
+ } catch {
368
+ NSLog(
369
+ "[BundleStorage] Failed to read compatibility bundle metadata at \(compatibilityBundleIdPath): \(error.localizedDescription)"
370
+ )
371
+ }
372
+ }
373
+
374
+ return nil
375
+ }
376
+
377
+ private func compatibilityBundleIdFilename() -> String {
378
+ "BUNDLE_ID"
379
+ }
380
+
381
+ private func readManifest(in bundleDirectory: String) -> [String: Any]? {
382
+ let manifestPath = (bundleDirectory as NSString).appendingPathComponent("manifest.json")
383
+ guard fileSystem.fileExists(atPath: manifestPath) else {
384
+ return nil
385
+ }
386
+
387
+ do {
388
+ let manifestData = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
389
+ return try JSONSerialization.jsonObject(with: manifestData) as? [String: Any]
390
+ } catch {
391
+ NSLog("[BundleStorage] Failed to read manifest at \(manifestPath): \(error.localizedDescription)")
392
+ return nil
393
+ }
394
+ }
395
+
203
396
  /**
204
397
  * Checks if isolationKey has changed and cleans up old bundles if needed.
205
398
  * This handles migration when isolationKey format changes.
@@ -289,100 +482,78 @@ class BundleFileStorageService: BundleStorageService {
289
482
  return metadata.verificationPending && metadata.stagingBundleId != nil
290
483
  }
291
484
 
292
- private func wasVerificationAttempted(_ metadata: BundleMetadata) -> Bool {
293
- return metadata.verificationAttemptedAt != nil
485
+ private func prepareMetadataForNewStagingBundle(_ metadata: BundleMetadata, bundleId: String) -> BundleMetadata {
486
+ let currentVerifiedBundleId = getCurrentVerifiedBundleId(metadata).flatMap { $0 == bundleId ? nil : $0 }
487
+ return BundleMetadata(
488
+ isolationKey: isolationKey,
489
+ stableBundleId: currentVerifiedBundleId,
490
+ stagingBundleId: bundleId,
491
+ verificationPending: true,
492
+ updatedAt: Date().timeIntervalSince1970 * 1000
493
+ )
294
494
  }
295
495
 
296
- private func markVerificationAttempted() {
297
- guard var metadata = loadMetadataOrNull() else {
298
- return
496
+ @discardableResult
497
+ private func rollbackPendingBundle(_ stagingId: String) -> Bool {
498
+ guard var metadata = loadMetadataOrNull(), metadata.stagingBundleId == stagingId else {
499
+ return false
299
500
  }
300
- metadata.verificationAttemptedAt = Date().timeIntervalSince1970 * 1000
301
- let _ = saveMetadata(metadata)
302
- NSLog("[BundleStorage] Marked verification attempted for staging bundle: \(metadata.stagingBundleId ?? "nil")")
303
- }
304
501
 
305
- private func incrementStagingExecutionCount() {
306
- guard var metadata = loadMetadataOrNull() else {
307
- return
308
- }
309
- metadata.stagingExecutionCount = (metadata.stagingExecutionCount ?? 0) + 1
310
- metadata.updatedAt = Date().timeIntervalSince1970 * 1000
311
- let _ = saveMetadata(metadata)
312
- NSLog("[BundleStorage] Incremented staging execution count to: \(metadata.stagingExecutionCount ?? 0)")
313
- }
502
+ var crashedHistory = loadCrashedHistory()
503
+ crashedHistory.addEntry(stagingId)
504
+ let _ = saveCrashedHistory(crashedHistory)
314
505
 
315
- private func promoteStagingToStable() {
316
- guard var metadata = loadMetadataOrNull() else {
317
- return
318
- }
319
- guard let stagingId = metadata.stagingBundleId else {
320
- NSLog("[BundleStorage] No staging bundle to promote")
321
- return
506
+ let fallbackBundleId = metadata.stableBundleId.flatMap { candidate in
507
+ if case .success(let storeDir) = bundleStoreDir() {
508
+ let stableBundleDir = (storeDir as NSString).appendingPathComponent(candidate)
509
+ if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), bundlePath != nil {
510
+ return candidate
511
+ }
512
+ }
513
+ return nil
322
514
  }
323
515
 
324
- let oldStableId = metadata.stableBundleId
325
- metadata.stableBundleId = stagingId
326
- metadata.stagingBundleId = nil
327
- metadata.verificationPending = false
328
- metadata.verificationAttemptedAt = nil
329
- metadata.stagingExecutionCount = nil
330
- metadata.updatedAt = Date().timeIntervalSince1970 * 1000
516
+ metadata = BundleMetadata(
517
+ isolationKey: isolationKey,
518
+ stableBundleId: nil,
519
+ stagingBundleId: fallbackBundleId,
520
+ verificationPending: false,
521
+ updatedAt: Date().timeIntervalSince1970 * 1000
522
+ )
331
523
 
332
- if saveMetadata(metadata) {
333
- NSLog("[BundleStorage] Promoted staging '\(stagingId)' to stable (old stable: \(oldStableId ?? "nil"))")
524
+ guard saveMetadata(metadata) else {
525
+ return false
526
+ }
334
527
 
335
- // Clean up old stable bundle
336
- if let oldId = oldStableId, oldId != stagingId {
337
- let _ = cleanupOldBundles(currentBundleId: stagingId, bundleId: nil)
528
+ if let fallbackBundleId,
529
+ case .success(let storeDir) = bundleStoreDir() {
530
+ let fallbackBundleDir = (storeDir as NSString).appendingPathComponent(fallbackBundleId)
531
+ if case .success(let bundlePath) = findBundleFile(in: fallbackBundleDir), let bundlePath {
532
+ let _ = setBundleURL(localPath: bundlePath)
338
533
  }
534
+ } else {
535
+ let _ = setBundleURL(localPath: nil)
339
536
  }
340
- }
341
537
 
342
- private func rollbackToStable() {
343
- guard var metadata = loadMetadataOrNull() else {
344
- return
538
+ if case .success(let storeDir) = bundleStoreDir() {
539
+ let stagingDir = (storeDir as NSString).appendingPathComponent(stagingId)
540
+ try? fileSystem.removeItem(atPath: stagingDir)
345
541
  }
346
- guard let stagingId = metadata.stagingBundleId else {
347
- NSLog("[BundleStorage] No staging bundle to rollback from")
348
- return
349
- }
350
-
351
- // Add crashed bundle to history
352
- var crashedHistory = loadCrashedHistory()
353
- crashedHistory.addEntry(stagingId)
354
- let _ = saveCrashedHistory(crashedHistory)
355
- NSLog("[BundleStorage('\(id)')] Added bundle '\(stagingId)' to crashed history")
356
542
 
357
- // Save rollback info to session variable (memory only)
358
- self.sessionRollbackBundleId = stagingId
359
-
360
- // Clear staging
361
- metadata.stagingBundleId = nil
362
- metadata.verificationPending = false
363
- metadata.verificationAttemptedAt = nil
364
- metadata.stagingExecutionCount = nil
365
- metadata.updatedAt = Date().timeIntervalSince1970 * 1000
366
-
367
- if saveMetadata(metadata) {
368
- NSLog("[BundleStorage] Rolled back to stable bundle: \(metadata.stableBundleId ?? "fallback")")
369
-
370
- // Update HotUpdaterBundleURL to point to stable bundle
371
- if let stableId = metadata.stableBundleId {
372
- if case .success(let storeDir) = bundleStoreDir() {
373
- let stableBundleDir = (storeDir as NSString).appendingPathComponent(stableId)
374
- if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), let path = bundlePath {
375
- let _ = setBundleURL(localPath: path)
376
- }
377
- }
378
- } else {
379
- // Reset to fallback
380
- let _ = setBundleURL(localPath: nil)
381
- }
543
+ saveLaunchReport(LaunchReport(status: "RECOVERED", crashedBundleId: stagingId))
544
+ return true
545
+ }
382
546
 
383
- // Clean up failed staging bundle
384
- let _ = cleanupOldBundles(currentBundleId: metadata.stableBundleId, bundleId: nil)
547
+ private func applyPendingRecoveryIfNeeded(_ pendingRecovery: PendingCrashRecovery?) {
548
+ guard let metadata = loadMetadataOrNull(),
549
+ let stagingBundleId = metadata.stagingBundleId,
550
+ pendingRecovery?.shouldRollback == true,
551
+ pendingRecovery?.launchedBundleId == stagingBundleId,
552
+ isVerificationPending(metadata) else {
553
+ return
385
554
  }
555
+
556
+ _ = rollbackPendingBundle(stagingBundleId)
386
557
  }
387
558
 
388
559
  // MARK: - Directory Management
@@ -568,6 +739,7 @@ class BundleFileStorageService: BundleStorageService {
568
739
  do {
569
740
  NSLog("[BundleStorage] Setting bundle URL to: \(localPath ?? "nil")")
570
741
  try self.preferences.setItem(localPath, forKey: "HotUpdaterBundleURL")
742
+ clearActiveBundleMetadataSnapshot()
571
743
  return .success(())
572
744
  } catch let error {
573
745
  return .failure(error)
@@ -599,66 +771,55 @@ class BundleFileStorageService: BundleStorageService {
599
771
  func getFallbackBundleURL(bundle: Bundle) -> URL? {
600
772
  return bundle.url(forResource: "main", withExtension: "jsbundle")
601
773
  }
602
-
603
- public func getBundleURL(bundle: Bundle) -> URL? {
604
- // Try to load metadata
605
- let metadata = loadMetadataOrNull()
606
774
 
607
- // If no metadata exists, use legacy behavior (backwards compatible)
608
- guard let metadata = metadata else {
609
- let cached = getCachedBundleURL()
610
- return cached ?? getFallbackBundleURL(bundle: bundle)
611
- }
612
-
613
- // Check if we need to handle crash recovery
614
- if isVerificationPending(metadata) {
615
- let executionCount = metadata.stagingExecutionCount ?? 0
616
-
617
- if executionCount == 0 {
618
- // First execution - give staging bundle a chance
619
- NSLog("[BundleStorage] First execution of staging bundle, incrementing counter")
620
- incrementStagingExecutionCount()
621
- // Don't mark verificationAttempted yet!
622
- } else if wasVerificationAttempted(metadata) {
623
- // Already executed once and verificationAttempted is set → crash!
624
- NSLog("[BundleStorage] Crash detected: staging bundle executed but didn't call notifyAppReady")
625
- rollbackToStable()
626
- } else {
627
- // Second execution - now mark verification attempted
628
- NSLog("[BundleStorage] Second execution of staging bundle, marking verification attempted")
629
- markVerificationAttempted()
630
- }
775
+ private func selectLaunch(bundle: Bundle) -> LaunchSelection {
776
+ guard let metadata = loadMetadataOrNull() else {
777
+ return LaunchSelection(
778
+ bundleURL: getCachedBundleURL() ?? getFallbackBundleURL(bundle: bundle),
779
+ launchedBundleId: getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent,
780
+ shouldRollbackOnCrash: false
781
+ )
631
782
  }
632
783
 
633
- // Reload metadata after potential rollback
634
- guard let currentMetadata = loadMetadataOrNull() else {
635
- return getCachedBundleURL() ?? getFallbackBundleURL(bundle: bundle)
636
- }
784
+ if let stagingId = metadata.stagingBundleId,
785
+ case .success(let storeDir) = bundleStoreDir() {
786
+ let stagingBundleDir = (storeDir as NSString).appendingPathComponent(stagingId)
787
+ if case .success(let bundlePath) = findBundleFile(in: stagingBundleDir), let bundlePath {
788
+ return LaunchSelection(
789
+ bundleURL: URL(fileURLWithPath: bundlePath),
790
+ launchedBundleId: stagingId,
791
+ shouldRollbackOnCrash: metadata.verificationPending
792
+ )
793
+ }
637
794
 
638
- // If verification is pending, return staging bundle URL
639
- if isVerificationPending(currentMetadata), let stagingId = currentMetadata.stagingBundleId {
640
- if case .success(let storeDir) = bundleStoreDir() {
641
- let stagingBundleDir = (storeDir as NSString).appendingPathComponent(stagingId)
642
- if case .success(let bundlePath) = findBundleFile(in: stagingBundleDir), let path = bundlePath {
643
- NSLog("[BundleStorage] Returning staging bundle URL: \(path)")
644
- return URL(fileURLWithPath: path)
645
- }
795
+ if metadata.verificationPending, rollbackPendingBundle(stagingId) {
796
+ return selectLaunch(bundle: bundle)
646
797
  }
647
798
  }
648
799
 
649
- // Return stable bundle URL
650
- if let stableId = currentMetadata.stableBundleId {
651
- if case .success(let storeDir) = bundleStoreDir() {
652
- let stableBundleDir = (storeDir as NSString).appendingPathComponent(stableId)
653
- if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), let path = bundlePath {
654
- NSLog("[BundleStorage] Returning stable bundle URL: \(path)")
655
- return URL(fileURLWithPath: path)
656
- }
800
+ if let stableId = metadata.stableBundleId,
801
+ case .success(let storeDir) = bundleStoreDir() {
802
+ let stableBundleDir = (storeDir as NSString).appendingPathComponent(stableId)
803
+ if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), let bundlePath {
804
+ return LaunchSelection(
805
+ bundleURL: URL(fileURLWithPath: bundlePath),
806
+ launchedBundleId: stableId,
807
+ shouldRollbackOnCrash: false
808
+ )
657
809
  }
658
810
  }
659
811
 
660
- // Fallback to app bundle
661
- return getFallbackBundleURL(bundle: bundle)
812
+ return LaunchSelection(
813
+ bundleURL: getFallbackBundleURL(bundle: bundle),
814
+ launchedBundleId: getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent,
815
+ shouldRollbackOnCrash: false
816
+ )
817
+ }
818
+
819
+ func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection {
820
+ saveLaunchReport(nil)
821
+ applyPendingRecoveryIfNeeded(pendingRecovery)
822
+ return selectLaunch(bundle: bundle)
662
823
  }
663
824
 
664
825
  // MARK: - Bundle Update
@@ -691,6 +852,8 @@ class BundleFileStorageService: BundleStorageService {
691
852
  let setResult = self.setBundleURL(localPath: nil)
692
853
  switch setResult {
693
854
  case .success:
855
+ let _ = self.saveMetadata(self.createInitialMetadata())
856
+ self.saveLaunchReport(nil)
694
857
  let cleanupResult = self.cleanupOldBundles(currentBundleId: currentBundleId, bundleId: bundleId)
695
858
  switch cleanupResult {
696
859
  case .success:
@@ -727,19 +890,13 @@ class BundleFileStorageService: BundleStorageService {
727
890
  let setResult = self.setBundleURL(localPath: bundlePath)
728
891
  switch setResult {
729
892
  case .success:
730
- // Set staging metadata for rollback support
731
- var metadata = self.loadMetadataOrNull() ?? BundleMetadata()
732
- metadata.stagingBundleId = bundleId
733
- metadata.verificationPending = true
734
- metadata.verificationAttemptedAt = nil
735
- metadata.stagingExecutionCount = 0
736
- metadata.updatedAt = Date().timeIntervalSince1970 * 1000
737
- let _ = self.saveMetadata(metadata)
893
+ let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
894
+ let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
895
+ let _ = self.saveMetadata(updatedMetadata)
738
896
  NSLog("[BundleStorage] Set staging bundle (cached): \(bundleId), verificationPending: true")
739
897
 
740
- // Clean up old bundles, preserving stable and new staging
741
- let stableId = metadata.stableBundleId
742
- let bundleIdsToKeep = [stableId, bundleId].compactMap { $0 }
898
+ // Clean up old bundles, preserving the fallback and new staging bundle.
899
+ let bundleIdsToKeep = [updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
743
900
  if bundleIdsToKeep.count > 0 {
744
901
  let _ = self.cleanupOldBundles(currentBundleId: bundleIdsToKeep.first, bundleId: bundleIdsToKeep.count > 1 ? bundleIdsToKeep[1] : nil)
745
902
  }
@@ -1045,21 +1202,16 @@ class BundleFileStorageService: BundleStorageService {
1045
1202
  NSLog("[BundleStorage] Successfully set bundle URL: \(finalBundlePath)")
1046
1203
 
1047
1204
  // 13) Set staging metadata for rollback support
1048
- var metadata = self.loadMetadataOrNull() ?? BundleMetadata()
1049
- metadata.stagingBundleId = bundleId
1050
- metadata.verificationPending = true
1051
- metadata.verificationAttemptedAt = nil
1052
- metadata.stagingExecutionCount = 0
1053
- metadata.updatedAt = Date().timeIntervalSince1970 * 1000
1054
- let _ = self.saveMetadata(metadata)
1205
+ let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
1206
+ let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
1207
+ let _ = self.saveMetadata(updatedMetadata)
1055
1208
  NSLog("[BundleStorage] Set staging bundle: \(bundleId), verificationPending: true")
1056
1209
 
1057
1210
  // 14) Clean up the temporary directory
1058
1211
  self.cleanupTemporaryFiles([tempDirectory])
1059
1212
 
1060
- // 15) Clean up old bundles, preserving current, stable, and new staging
1061
- let stableId = metadata.stableBundleId
1062
- let bundleIdsToKeep = [stableId, bundleId].compactMap { $0 }
1213
+ // 15) Clean up old bundles, preserving the fallback and new staging bundle.
1214
+ let bundleIdsToKeep = [updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
1063
1215
  if bundleIdsToKeep.count > 0 {
1064
1216
  let _ = self.cleanupOldBundles(currentBundleId: bundleIdsToKeep.first, bundleId: bundleIdsToKeep.count > 1 ? bundleIdsToKeep[1] : nil)
1065
1217
  }
@@ -1110,57 +1262,31 @@ class BundleFileStorageService: BundleStorageService {
1110
1262
  // MARK: - Rollback Support
1111
1263
 
1112
1264
  /**
1113
- * Notifies the system that the app has successfully started with the given bundle.
1114
- * If the bundle matches the staging bundle, promotes it to stable.
1115
- * @param bundleId The ID of the currently running bundle
1116
- * @return true if promotion was successful or no action was needed
1265
+ * Marks the current launch as successful after the first content appeared.
1117
1266
  */
1118
- func notifyAppReady(bundleId: String) -> [String: Any] {
1119
- NSLog("[BundleStorage('\(id)'] notifyAppReady: Called with bundleId '\(bundleId)'")
1120
- guard var metadata = loadMetadataOrNull() else {
1121
- // No metadata exists - legacy mode, nothing to do
1122
- NSLog("[BundleStorage] notifyAppReady: No metadata exists (legacy mode)")
1123
- return ["status": "STABLE"]
1124
- }
1125
-
1126
- // Check if there was a recent rollback (session variable)
1127
- if let crashedBundleId = self.sessionRollbackBundleId {
1128
- NSLog("[BundleStorage] notifyAppReady: Detected rollback recovery from '\(crashedBundleId)'")
1129
-
1130
- // Clear rollback info (one-time read)
1131
- self.sessionRollbackBundleId = nil
1132
-
1133
- return [
1134
- "status": "RECOVERED",
1135
- "crashedBundleId": crashedBundleId
1136
- ]
1267
+ func markLaunchCompleted(bundleId: String?) {
1268
+ guard let bundleId,
1269
+ var metadata = loadMetadataOrNull(),
1270
+ metadata.verificationPending,
1271
+ metadata.stagingBundleId == bundleId else {
1272
+ return
1137
1273
  }
1138
1274
 
1139
- // Check if the bundle matches the staging bundle (promotion case)
1140
- if let stagingId = metadata.stagingBundleId, stagingId == bundleId, metadata.verificationPending {
1141
- NSLog("[BundleStorage] notifyAppReady: Bundle '\(bundleId)' matches staging, promoting to stable")
1142
- promoteStagingToStable()
1143
- return ["status": "PROMOTED"]
1144
- }
1275
+ metadata.verificationPending = false
1276
+ metadata.updatedAt = Date().timeIntervalSince1970 * 1000
1277
+ let _ = saveMetadata(metadata)
1278
+ }
1145
1279
 
1146
- // Check if the bundle matches the stable bundle
1147
- if let stableId = metadata.stableBundleId, stableId == bundleId {
1148
- // Already stable, clear any pending verification state
1149
- if metadata.verificationPending {
1150
- metadata.verificationPending = false
1151
- metadata.verificationAttemptedAt = nil
1152
- metadata.updatedAt = Date().timeIntervalSince1970 * 1000
1153
- let _ = saveMetadata(metadata)
1154
- NSLog("[BundleStorage] notifyAppReady: Bundle '\(bundleId)' is stable, cleared pending verification")
1155
- } else {
1156
- NSLog("[BundleStorage] notifyAppReady: Bundle '\(bundleId)' is already stable")
1157
- }
1280
+ func notifyAppReady() -> [String: Any] {
1281
+ guard let report = loadLaunchReport() else {
1158
1282
  return ["status": "STABLE"]
1159
1283
  }
1160
1284
 
1161
- // Bundle doesn't match staging or stable - might be fallback or unknown
1162
- NSLog("[BundleStorage] notifyAppReady: Bundle '\(bundleId)' doesn't match staging or stable")
1163
- return ["status": "STABLE"]
1285
+ var result: [String: Any] = ["status": report.status]
1286
+ if let crashedBundleId = report.crashedBundleId {
1287
+ result["crashedBundleId"] = crashedBundleId
1288
+ }
1289
+ return result
1164
1290
  }
1165
1291
 
1166
1292
  /**
@@ -1187,35 +1313,7 @@ class BundleFileStorageService: BundleStorageService {
1187
1313
  */
1188
1314
  func getBaseURL() -> String {
1189
1315
  do {
1190
- let metadata = loadMetadataOrNull()
1191
- let activeBundleId: String?
1192
-
1193
- // Prefer staging bundle if verification is pending
1194
- if let meta = metadata, meta.verificationPending, let staging = meta.stagingBundleId {
1195
- activeBundleId = staging
1196
- } else if let stable = metadata?.stableBundleId {
1197
- activeBundleId = stable
1198
- } else {
1199
- // Fall back to current bundle ID from preferences
1200
- if let savedURL = try preferences.getItem(forKey: "HotUpdaterBundleURL") {
1201
- // Extract bundle ID from path like "bundle-store/abc123/index.ios.bundle"
1202
- if let range = savedURL.range(of: "bundle-store/([^/]+)/", options: .regularExpression) {
1203
- let match = savedURL[range]
1204
- let components = match.split(separator: "/")
1205
- if components.count >= 2 {
1206
- activeBundleId = String(components[1])
1207
- } else {
1208
- activeBundleId = nil
1209
- }
1210
- } else {
1211
- activeBundleId = nil
1212
- }
1213
- } else {
1214
- activeBundleId = nil
1215
- }
1216
- }
1217
-
1218
- if let bundleId = activeBundleId {
1316
+ if let bundleId = getActiveBundleId() {
1219
1317
  if case .success(let storeDir) = bundleStoreDir() {
1220
1318
  let bundleDir = (storeDir as NSString).appendingPathComponent(bundleId)
1221
1319
  if fileSystem.fileExists(atPath: bundleDir) {
@@ -1231,6 +1329,14 @@ class BundleFileStorageService: BundleStorageService {
1231
1329
  }
1232
1330
  }
1233
1331
 
1332
+ func getBundleId() -> String? {
1333
+ return getActiveBundleMetadataSnapshot()?.bundleId
1334
+ }
1335
+
1336
+ func getManifest() -> ManifestAssets {
1337
+ return getActiveBundleMetadataSnapshot()?.manifest ?? [:]
1338
+ }
1339
+
1234
1340
  func resetChannel() -> Result<Bool, Error> {
1235
1341
  guard case .success = setBundleURL(localPath: nil) else {
1236
1342
  return .failure(BundleStorageError.unknown(nil))
@@ -1240,22 +1346,24 @@ class BundleFileStorageService: BundleStorageService {
1240
1346
  isolationKey: isolationKey,
1241
1347
  stableBundleId: nil,
1242
1348
  stagingBundleId: nil,
1243
- verificationPending: false,
1244
- verificationAttemptedAt: nil,
1245
- stagingExecutionCount: nil
1349
+ verificationPending: false
1246
1350
  )
1247
1351
 
1248
1352
  guard saveMetadata(clearedMetadata) else {
1249
1353
  return .failure(BundleStorageError.unknown(nil))
1250
1354
  }
1251
1355
 
1356
+ saveLaunchReport(nil)
1357
+
1252
1358
  guard case .success(let storeDir) = bundleStoreDir() else {
1253
1359
  return .failure(BundleStorageError.unknown(nil))
1254
1360
  }
1255
1361
 
1256
1362
  do {
1257
1363
  for item in try fileSystem.contentsOfDirectory(atPath: storeDir) {
1258
- if item == BundleMetadata.metadataFilename || item == CrashedHistory.crashedHistoryFilename {
1364
+ if item == BundleMetadata.metadataFilename ||
1365
+ item == CrashedHistory.crashedHistoryFilename ||
1366
+ item == LaunchReport.launchReportFilename {
1259
1367
  continue
1260
1368
  }
1261
1369