@hot-updater/react-native 0.27.0 → 0.28.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 (62) 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 +170 -204
  7. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +73 -16
  8. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +39 -13
  9. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +533 -0
  10. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryReceiver.kt +14 -0
  11. package/android/src/newarch/HotUpdaterModule.kt +2 -8
  12. package/android/src/oldarch/HotUpdaterModule.kt +2 -8
  13. package/android/src/oldarch/HotUpdaterSpec.kt +1 -1
  14. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +189 -203
  15. package/ios/HotUpdater/Internal/BundleMetadata.swift +61 -8
  16. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +9 -1
  17. package/ios/HotUpdater/Internal/HotUpdater.mm +265 -11
  18. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +7 -0
  19. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +4 -0
  20. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +293 -9
  21. package/lib/commonjs/native.js +18 -21
  22. package/lib/commonjs/native.js.map +1 -1
  23. package/lib/commonjs/native.spec.js +86 -0
  24. package/lib/commonjs/native.spec.js.map +1 -0
  25. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  26. package/lib/commonjs/types.js.map +1 -1
  27. package/lib/commonjs/wrap.js +4 -5
  28. package/lib/commonjs/wrap.js.map +1 -1
  29. package/lib/module/native.js +17 -20
  30. package/lib/module/native.js.map +1 -1
  31. package/lib/module/native.spec.js +85 -0
  32. package/lib/module/native.spec.js.map +1 -0
  33. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  34. package/lib/module/types.js.map +1 -1
  35. package/lib/module/wrap.js +5 -6
  36. package/lib/module/wrap.js.map +1 -1
  37. package/lib/typescript/commonjs/native.d.ts +4 -15
  38. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/native.spec.d.ts +2 -0
  40. package/lib/typescript/commonjs/native.spec.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +4 -8
  42. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/types.d.ts +2 -3
  44. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/wrap.d.ts +2 -5
  46. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  47. package/lib/typescript/module/native.d.ts +4 -15
  48. package/lib/typescript/module/native.d.ts.map +1 -1
  49. package/lib/typescript/module/native.spec.d.ts +2 -0
  50. package/lib/typescript/module/native.spec.d.ts.map +1 -0
  51. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +4 -8
  52. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  53. package/lib/typescript/module/types.d.ts +2 -3
  54. package/lib/typescript/module/types.d.ts.map +1 -1
  55. package/lib/typescript/module/wrap.d.ts +2 -5
  56. package/lib/typescript/module/wrap.d.ts.map +1 -1
  57. package/package.json +6 -6
  58. package/src/native.spec.ts +84 -0
  59. package/src/native.ts +20 -19
  60. package/src/specs/NativeHotUpdater.ts +4 -6
  61. package/src/types.ts +2 -3
  62. package/src/wrap.tsx +7 -11
@@ -153,17 +153,11 @@ class HotUpdaterModule internal constructor(
153
153
  }
154
154
 
155
155
  @ReactMethod(isBlockingSynchronousMethod = true)
156
- override fun notifyAppReady(params: ReadableMap): String {
157
- val bundleId = params.getString("bundleId")
156
+ override fun notifyAppReady(): String {
158
157
  val result = JSONObject()
159
158
 
160
- if (bundleId == null) {
161
- result.put("status", "STABLE")
162
- return result.toString()
163
- }
164
-
165
159
  val impl = getInstance()
166
- val statusMap = impl.notifyAppReady(bundleId)
160
+ val statusMap = impl.notifyAppReady()
167
161
 
168
162
  result.put("status", statusMap["status"] as? String ?: "STABLE")
169
163
  statusMap["crashedBundleId"]?.let {
@@ -17,7 +17,7 @@ abstract class HotUpdaterSpec internal constructor(
17
17
 
18
18
  abstract fun reloadProcess(promise: Promise)
19
19
 
20
- abstract fun notifyAppReady(params: ReadableMap): String
20
+ abstract fun notifyAppReady(): String
21
21
 
22
22
  abstract fun getCrashHistory(): String
23
23
 
@@ -103,13 +103,14 @@ public protocol BundleStorageService {
103
103
  func setBundleURL(localPath: String?) -> Result<Void, Error>
104
104
  func getCachedBundleURL() -> URL?
105
105
  func getFallbackBundleURL(bundle: Bundle) -> URL? // Synchronous as it's lightweight
106
- func getBundleURL(bundle: Bundle) -> URL?
106
+ func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection
107
107
 
108
108
  // Bundle update
109
109
  func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<Bool, Error>) -> Void)
110
110
 
111
111
  // Rollback support
112
- func notifyAppReady(bundleId: String) -> [String: Any]
112
+ func markLaunchCompleted(bundleId: String?)
113
+ func notifyAppReady() -> [String: Any]
113
114
  func getCrashHistory() -> CrashedHistory
114
115
  func clearCrashHistory() -> Bool
115
116
 
@@ -139,8 +140,7 @@ class BundleFileStorageService: BundleStorageService {
139
140
 
140
141
  private var activeTasks: [URLSessionTask] = []
141
142
 
142
- // Session-only rollback tracking (in-memory)
143
- private var sessionRollbackBundleId: String?
143
+ private var currentLaunchReport: LaunchReport?
144
144
 
145
145
  public init(fileSystem: FileSystemService,
146
146
  downloadService: DownloadService,
@@ -182,6 +182,13 @@ class BundleFileStorageService: BundleStorageService {
182
182
  return URL(fileURLWithPath: storeDir).appendingPathComponent(CrashedHistory.crashedHistoryFilename)
183
183
  }
184
184
 
185
+ private func launchReportFileURL() -> URL? {
186
+ guard case .success(let storeDir) = bundleStoreDir() else {
187
+ return nil
188
+ }
189
+ return URL(fileURLWithPath: storeDir).appendingPathComponent(LaunchReport.launchReportFilename)
190
+ }
191
+
185
192
  // MARK: - Metadata Operations
186
193
 
187
194
  private func loadMetadataOrNull() -> BundleMetadata? {
@@ -200,6 +207,51 @@ class BundleFileStorageService: BundleStorageService {
200
207
  return updatedMetadata.save(to: file)
201
208
  }
202
209
 
210
+ private func loadLaunchReport() -> LaunchReport? {
211
+ if let currentLaunchReport {
212
+ return currentLaunchReport
213
+ }
214
+ guard let file = launchReportFileURL(),
215
+ let report = LaunchReport.load(from: file) else {
216
+ return nil
217
+ }
218
+ currentLaunchReport = report
219
+ return report
220
+ }
221
+
222
+ private func saveLaunchReport(_ report: LaunchReport?) {
223
+ currentLaunchReport = report
224
+ guard let file = launchReportFileURL() else {
225
+ return
226
+ }
227
+
228
+ guard let report else {
229
+ if FileManager.default.fileExists(atPath: file.path) {
230
+ try? FileManager.default.removeItem(at: file)
231
+ }
232
+ return
233
+ }
234
+
235
+ _ = report.save(to: file)
236
+ }
237
+
238
+ private func createInitialMetadata() -> BundleMetadata {
239
+ let currentBundleId = getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent
240
+ return BundleMetadata(
241
+ isolationKey: isolationKey,
242
+ stableBundleId: nil,
243
+ stagingBundleId: currentBundleId,
244
+ verificationPending: false
245
+ )
246
+ }
247
+
248
+ private func getCurrentVerifiedBundleId(_ metadata: BundleMetadata) -> String? {
249
+ if let stagingBundleId = metadata.stagingBundleId, !metadata.verificationPending {
250
+ return stagingBundleId
251
+ }
252
+ return metadata.stableBundleId
253
+ }
254
+
203
255
  /**
204
256
  * Checks if isolationKey has changed and cleans up old bundles if needed.
205
257
  * This handles migration when isolationKey format changes.
@@ -289,100 +341,78 @@ class BundleFileStorageService: BundleStorageService {
289
341
  return metadata.verificationPending && metadata.stagingBundleId != nil
290
342
  }
291
343
 
292
- private func wasVerificationAttempted(_ metadata: BundleMetadata) -> Bool {
293
- return metadata.verificationAttemptedAt != nil
344
+ private func prepareMetadataForNewStagingBundle(_ metadata: BundleMetadata, bundleId: String) -> BundleMetadata {
345
+ let currentVerifiedBundleId = getCurrentVerifiedBundleId(metadata).flatMap { $0 == bundleId ? nil : $0 }
346
+ return BundleMetadata(
347
+ isolationKey: isolationKey,
348
+ stableBundleId: currentVerifiedBundleId,
349
+ stagingBundleId: bundleId,
350
+ verificationPending: true,
351
+ updatedAt: Date().timeIntervalSince1970 * 1000
352
+ )
294
353
  }
295
354
 
296
- private func markVerificationAttempted() {
297
- guard var metadata = loadMetadataOrNull() else {
298
- return
355
+ @discardableResult
356
+ private func rollbackPendingBundle(_ stagingId: String) -> Bool {
357
+ guard var metadata = loadMetadataOrNull(), metadata.stagingBundleId == stagingId else {
358
+ return false
299
359
  }
300
- metadata.verificationAttemptedAt = Date().timeIntervalSince1970 * 1000
301
- let _ = saveMetadata(metadata)
302
- NSLog("[BundleStorage] Marked verification attempted for staging bundle: \(metadata.stagingBundleId ?? "nil")")
303
- }
304
360
 
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
- }
361
+ var crashedHistory = loadCrashedHistory()
362
+ crashedHistory.addEntry(stagingId)
363
+ let _ = saveCrashedHistory(crashedHistory)
314
364
 
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
365
+ let fallbackBundleId = metadata.stableBundleId.flatMap { candidate in
366
+ if case .success(let storeDir) = bundleStoreDir() {
367
+ let stableBundleDir = (storeDir as NSString).appendingPathComponent(candidate)
368
+ if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), bundlePath != nil {
369
+ return candidate
370
+ }
371
+ }
372
+ return nil
322
373
  }
323
374
 
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
375
+ metadata = BundleMetadata(
376
+ isolationKey: isolationKey,
377
+ stableBundleId: nil,
378
+ stagingBundleId: fallbackBundleId,
379
+ verificationPending: false,
380
+ updatedAt: Date().timeIntervalSince1970 * 1000
381
+ )
331
382
 
332
- if saveMetadata(metadata) {
333
- NSLog("[BundleStorage] Promoted staging '\(stagingId)' to stable (old stable: \(oldStableId ?? "nil"))")
383
+ guard saveMetadata(metadata) else {
384
+ return false
385
+ }
334
386
 
335
- // Clean up old stable bundle
336
- if let oldId = oldStableId, oldId != stagingId {
337
- let _ = cleanupOldBundles(currentBundleId: stagingId, bundleId: nil)
387
+ if let fallbackBundleId,
388
+ case .success(let storeDir) = bundleStoreDir() {
389
+ let fallbackBundleDir = (storeDir as NSString).appendingPathComponent(fallbackBundleId)
390
+ if case .success(let bundlePath) = findBundleFile(in: fallbackBundleDir), let bundlePath {
391
+ let _ = setBundleURL(localPath: bundlePath)
338
392
  }
393
+ } else {
394
+ let _ = setBundleURL(localPath: nil)
339
395
  }
340
- }
341
396
 
342
- private func rollbackToStable() {
343
- guard var metadata = loadMetadataOrNull() else {
344
- return
345
- }
346
- guard let stagingId = metadata.stagingBundleId else {
347
- NSLog("[BundleStorage] No staging bundle to rollback from")
348
- return
397
+ if case .success(let storeDir) = bundleStoreDir() {
398
+ let stagingDir = (storeDir as NSString).appendingPathComponent(stagingId)
399
+ try? fileSystem.removeItem(atPath: stagingDir)
349
400
  }
350
401
 
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
-
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
- }
402
+ saveLaunchReport(LaunchReport(status: "RECOVERED", crashedBundleId: stagingId))
403
+ return true
404
+ }
382
405
 
383
- // Clean up failed staging bundle
384
- let _ = cleanupOldBundles(currentBundleId: metadata.stableBundleId, bundleId: nil)
406
+ private func applyPendingRecoveryIfNeeded(_ pendingRecovery: PendingCrashRecovery?) {
407
+ guard let metadata = loadMetadataOrNull(),
408
+ let stagingBundleId = metadata.stagingBundleId,
409
+ pendingRecovery?.shouldRollback == true,
410
+ pendingRecovery?.launchedBundleId == stagingBundleId,
411
+ isVerificationPending(metadata) else {
412
+ return
385
413
  }
414
+
415
+ _ = rollbackPendingBundle(stagingBundleId)
386
416
  }
387
417
 
388
418
  // MARK: - Directory Management
@@ -599,66 +629,55 @@ class BundleFileStorageService: BundleStorageService {
599
629
  func getFallbackBundleURL(bundle: Bundle) -> URL? {
600
630
  return bundle.url(forResource: "main", withExtension: "jsbundle")
601
631
  }
602
-
603
- public func getBundleURL(bundle: Bundle) -> URL? {
604
- // Try to load metadata
605
- let metadata = loadMetadataOrNull()
606
-
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
632
 
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
- }
633
+ private func selectLaunch(bundle: Bundle) -> LaunchSelection {
634
+ guard let metadata = loadMetadataOrNull() else {
635
+ return LaunchSelection(
636
+ bundleURL: getCachedBundleURL() ?? getFallbackBundleURL(bundle: bundle),
637
+ launchedBundleId: getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent,
638
+ shouldRollbackOnCrash: false
639
+ )
631
640
  }
632
641
 
633
- // Reload metadata after potential rollback
634
- guard let currentMetadata = loadMetadataOrNull() else {
635
- return getCachedBundleURL() ?? getFallbackBundleURL(bundle: bundle)
636
- }
642
+ if let stagingId = metadata.stagingBundleId,
643
+ case .success(let storeDir) = bundleStoreDir() {
644
+ let stagingBundleDir = (storeDir as NSString).appendingPathComponent(stagingId)
645
+ if case .success(let bundlePath) = findBundleFile(in: stagingBundleDir), let bundlePath {
646
+ return LaunchSelection(
647
+ bundleURL: URL(fileURLWithPath: bundlePath),
648
+ launchedBundleId: stagingId,
649
+ shouldRollbackOnCrash: metadata.verificationPending
650
+ )
651
+ }
637
652
 
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
- }
653
+ if metadata.verificationPending, rollbackPendingBundle(stagingId) {
654
+ return selectLaunch(bundle: bundle)
646
655
  }
647
656
  }
648
657
 
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
- }
658
+ if let stableId = metadata.stableBundleId,
659
+ case .success(let storeDir) = bundleStoreDir() {
660
+ let stableBundleDir = (storeDir as NSString).appendingPathComponent(stableId)
661
+ if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), let bundlePath {
662
+ return LaunchSelection(
663
+ bundleURL: URL(fileURLWithPath: bundlePath),
664
+ launchedBundleId: stableId,
665
+ shouldRollbackOnCrash: false
666
+ )
657
667
  }
658
668
  }
659
669
 
660
- // Fallback to app bundle
661
- return getFallbackBundleURL(bundle: bundle)
670
+ return LaunchSelection(
671
+ bundleURL: getFallbackBundleURL(bundle: bundle),
672
+ launchedBundleId: getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent,
673
+ shouldRollbackOnCrash: false
674
+ )
675
+ }
676
+
677
+ func prepareLaunch(bundle: Bundle, pendingRecovery: PendingCrashRecovery?) -> LaunchSelection {
678
+ saveLaunchReport(nil)
679
+ applyPendingRecoveryIfNeeded(pendingRecovery)
680
+ return selectLaunch(bundle: bundle)
662
681
  }
663
682
 
664
683
  // MARK: - Bundle Update
@@ -691,6 +710,8 @@ class BundleFileStorageService: BundleStorageService {
691
710
  let setResult = self.setBundleURL(localPath: nil)
692
711
  switch setResult {
693
712
  case .success:
713
+ let _ = self.saveMetadata(self.createInitialMetadata())
714
+ self.saveLaunchReport(nil)
694
715
  let cleanupResult = self.cleanupOldBundles(currentBundleId: currentBundleId, bundleId: bundleId)
695
716
  switch cleanupResult {
696
717
  case .success:
@@ -727,19 +748,13 @@ class BundleFileStorageService: BundleStorageService {
727
748
  let setResult = self.setBundleURL(localPath: bundlePath)
728
749
  switch setResult {
729
750
  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)
751
+ let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
752
+ let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
753
+ let _ = self.saveMetadata(updatedMetadata)
738
754
  NSLog("[BundleStorage] Set staging bundle (cached): \(bundleId), verificationPending: true")
739
755
 
740
- // Clean up old bundles, preserving stable and new staging
741
- let stableId = metadata.stableBundleId
742
- let bundleIdsToKeep = [stableId, bundleId].compactMap { $0 }
756
+ // Clean up old bundles, preserving the fallback and new staging bundle.
757
+ let bundleIdsToKeep = [updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
743
758
  if bundleIdsToKeep.count > 0 {
744
759
  let _ = self.cleanupOldBundles(currentBundleId: bundleIdsToKeep.first, bundleId: bundleIdsToKeep.count > 1 ? bundleIdsToKeep[1] : nil)
745
760
  }
@@ -1045,21 +1060,16 @@ class BundleFileStorageService: BundleStorageService {
1045
1060
  NSLog("[BundleStorage] Successfully set bundle URL: \(finalBundlePath)")
1046
1061
 
1047
1062
  // 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)
1063
+ let currentMetadata = self.loadMetadataOrNull() ?? self.createInitialMetadata()
1064
+ let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
1065
+ let _ = self.saveMetadata(updatedMetadata)
1055
1066
  NSLog("[BundleStorage] Set staging bundle: \(bundleId), verificationPending: true")
1056
1067
 
1057
1068
  // 14) Clean up the temporary directory
1058
1069
  self.cleanupTemporaryFiles([tempDirectory])
1059
1070
 
1060
- // 15) Clean up old bundles, preserving current, stable, and new staging
1061
- let stableId = metadata.stableBundleId
1062
- let bundleIdsToKeep = [stableId, bundleId].compactMap { $0 }
1071
+ // 15) Clean up old bundles, preserving the fallback and new staging bundle.
1072
+ let bundleIdsToKeep = [updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
1063
1073
  if bundleIdsToKeep.count > 0 {
1064
1074
  let _ = self.cleanupOldBundles(currentBundleId: bundleIdsToKeep.first, bundleId: bundleIdsToKeep.count > 1 ? bundleIdsToKeep[1] : nil)
1065
1075
  }
@@ -1110,57 +1120,31 @@ class BundleFileStorageService: BundleStorageService {
1110
1120
  // MARK: - Rollback Support
1111
1121
 
1112
1122
  /**
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
1123
+ * Marks the current launch as successful after the first content appeared.
1117
1124
  */
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
- ]
1125
+ func markLaunchCompleted(bundleId: String?) {
1126
+ guard let bundleId,
1127
+ var metadata = loadMetadataOrNull(),
1128
+ metadata.verificationPending,
1129
+ metadata.stagingBundleId == bundleId else {
1130
+ return
1137
1131
  }
1138
1132
 
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
- }
1133
+ metadata.verificationPending = false
1134
+ metadata.updatedAt = Date().timeIntervalSince1970 * 1000
1135
+ let _ = saveMetadata(metadata)
1136
+ }
1145
1137
 
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
- }
1138
+ func notifyAppReady() -> [String: Any] {
1139
+ guard let report = loadLaunchReport() else {
1158
1140
  return ["status": "STABLE"]
1159
1141
  }
1160
1142
 
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"]
1143
+ var result: [String: Any] = ["status": report.status]
1144
+ if let crashedBundleId = report.crashedBundleId {
1145
+ result["crashedBundleId"] = crashedBundleId
1146
+ }
1147
+ return result
1164
1148
  }
1165
1149
 
1166
1150
  /**
@@ -1190,8 +1174,8 @@ class BundleFileStorageService: BundleStorageService {
1190
1174
  let metadata = loadMetadataOrNull()
1191
1175
  let activeBundleId: String?
1192
1176
 
1193
- // Prefer staging bundle if verification is pending
1194
- if let meta = metadata, meta.verificationPending, let staging = meta.stagingBundleId {
1177
+ // Prefer the current staging bundle regardless of verification state.
1178
+ if let staging = metadata?.stagingBundleId {
1195
1179
  activeBundleId = staging
1196
1180
  } else if let stable = metadata?.stableBundleId {
1197
1181
  activeBundleId = stable
@@ -1240,22 +1224,24 @@ class BundleFileStorageService: BundleStorageService {
1240
1224
  isolationKey: isolationKey,
1241
1225
  stableBundleId: nil,
1242
1226
  stagingBundleId: nil,
1243
- verificationPending: false,
1244
- verificationAttemptedAt: nil,
1245
- stagingExecutionCount: nil
1227
+ verificationPending: false
1246
1228
  )
1247
1229
 
1248
1230
  guard saveMetadata(clearedMetadata) else {
1249
1231
  return .failure(BundleStorageError.unknown(nil))
1250
1232
  }
1251
1233
 
1234
+ saveLaunchReport(nil)
1235
+
1252
1236
  guard case .success(let storeDir) = bundleStoreDir() else {
1253
1237
  return .failure(BundleStorageError.unknown(nil))
1254
1238
  }
1255
1239
 
1256
1240
  do {
1257
1241
  for item in try fileSystem.contentsOfDirectory(atPath: storeDir) {
1258
- if item == BundleMetadata.metadataFilename || item == CrashedHistory.crashedHistoryFilename {
1242
+ if item == BundleMetadata.metadataFilename ||
1243
+ item == CrashedHistory.crashedHistoryFilename ||
1244
+ item == LaunchReport.launchReportFilename {
1259
1245
  continue
1260
1246
  }
1261
1247
 
@@ -12,8 +12,6 @@ public struct BundleMetadata: Codable {
12
12
  var stableBundleId: String?
13
13
  var stagingBundleId: String?
14
14
  var verificationPending: Bool
15
- var verificationAttemptedAt: Double?
16
- var stagingExecutionCount: Int?
17
15
  var updatedAt: Double
18
16
 
19
17
  enum CodingKeys: String, CodingKey {
@@ -22,8 +20,6 @@ public struct BundleMetadata: Codable {
22
20
  case stableBundleId = "stable_bundle_id"
23
21
  case stagingBundleId = "staging_bundle_id"
24
22
  case verificationPending = "verification_pending"
25
- case verificationAttemptedAt = "verification_attempted_at"
26
- case stagingExecutionCount = "staging_execution_count"
27
23
  case updatedAt = "updated_at"
28
24
  }
29
25
 
@@ -33,8 +29,6 @@ public struct BundleMetadata: Codable {
33
29
  stableBundleId: String? = nil,
34
30
  stagingBundleId: String? = nil,
35
31
  verificationPending: Bool = false,
36
- verificationAttemptedAt: Double? = nil,
37
- stagingExecutionCount: Int? = nil,
38
32
  updatedAt: Double = Date().timeIntervalSince1970 * 1000
39
33
  ) {
40
34
  self.schema = schema
@@ -42,8 +36,6 @@ public struct BundleMetadata: Codable {
42
36
  self.stableBundleId = stableBundleId
43
37
  self.stagingBundleId = stagingBundleId
44
38
  self.verificationPending = verificationPending
45
- self.verificationAttemptedAt = verificationAttemptedAt
46
- self.stagingExecutionCount = stagingExecutionCount
47
39
  self.updatedAt = updatedAt
48
40
  }
49
41
 
@@ -191,3 +183,64 @@ public struct CrashedHistory: Codable {
191
183
  bundles.removeAll()
192
184
  }
193
185
  }
186
+
187
+ public struct PendingCrashRecovery {
188
+ let launchedBundleId: String?
189
+ let shouldRollback: Bool
190
+
191
+ static func from(json: [String: Any]) -> PendingCrashRecovery {
192
+ PendingCrashRecovery(
193
+ launchedBundleId: (json["bundleId"] as? String).flatMap { $0.isEmpty ? nil : $0 },
194
+ shouldRollback: json["shouldRollback"] as? Bool ?? false
195
+ )
196
+ }
197
+ }
198
+
199
+ public struct LaunchSelection {
200
+ let bundleURL: URL?
201
+ let launchedBundleId: String?
202
+ let shouldRollbackOnCrash: Bool
203
+ }
204
+
205
+ public struct LaunchReport: Codable {
206
+ static let launchReportFilename = "launch-report.json"
207
+
208
+ let status: String
209
+ let crashedBundleId: String?
210
+
211
+ init(status: String = "STABLE", crashedBundleId: String? = nil) {
212
+ self.status = status
213
+ self.crashedBundleId = crashedBundleId
214
+ }
215
+
216
+ static func load(from file: URL) -> LaunchReport? {
217
+ guard FileManager.default.fileExists(atPath: file.path) else {
218
+ return nil
219
+ }
220
+
221
+ do {
222
+ let data = try Data(contentsOf: file)
223
+ return try JSONDecoder().decode(LaunchReport.self, from: data)
224
+ } catch {
225
+ print("[LaunchReport] Failed to load launch report: \(error)")
226
+ return nil
227
+ }
228
+ }
229
+
230
+ func save(to file: URL) -> Bool {
231
+ do {
232
+ let encoder = JSONEncoder()
233
+ encoder.outputFormatting = .prettyPrinted
234
+ let data = try encoder.encode(self)
235
+ let directory = file.deletingLastPathComponent()
236
+ if !FileManager.default.fileExists(atPath: directory.path) {
237
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
238
+ }
239
+ try data.write(to: file)
240
+ return true
241
+ } catch {
242
+ print("[LaunchReport] Failed to save launch report: \(error)")
243
+ return false
244
+ }
245
+ }
246
+ }