StorageReadiness.swift 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. // LoopFollow
  2. // StorageReadiness.swift
  3. import Combine
  4. import Foundation
  5. /// A cached value that can re-read itself from disk after a BFU launch.
  6. protocol BFUReloadable: AnyObject {
  7. func reload()
  8. }
  9. /// Before-First-Unlock (BFU) storage recovery + the app-wide readiness gate.
  10. ///
  11. /// On a launch before the device's first unlock since boot, `UserDefaults` is
  12. /// encrypted, so every `StorageValue`/`SecureStorageValue` initialises to its
  13. /// default. This distinguishes true BFU from an ordinary locked launch (where the
  14. /// store reads fine), gates the UI until storage is trustworthy, hydrates every
  15. /// registered value once readable, and suppresses writes meanwhile so poisoned
  16. /// defaults can't be flushed over real settings on unlock.
  17. ///
  18. /// All mutators run on the main thread (launch + the protected-data/foreground
  19. /// notifications); `register(_:)` runs during `Storage` init. So no locking.
  20. enum StorageReadiness {
  21. /// Published gate; `false` until storage is known-good. The root shows a
  22. /// storage-free loading view until `true`.
  23. static let ready = ObservableValue<Bool>(default: false)
  24. /// True for the duration of a suspected true-BFU launch, until hydration runs.
  25. private(set) static var suspectBFU = false
  26. /// While true, storage values keep writes in memory only (hydration reconciles
  27. /// from disk). Off-main reads see a benign eventually-consistent Bool snapshot.
  28. static var isSuppressingWrites: Bool { suspectBFU && !ready.value }
  29. /// Debug surface for any writer active during the suspect window — such a write
  30. /// is memory-only and lost if it was needed on disk. None exist today.
  31. static func noteSuppressedWrite(key: String) {
  32. #if DEBUG
  33. LogManager.shared.log(category: .general, message: "Storage write to '\(key)' suppressed during BFU window")
  34. #endif
  35. }
  36. // MARK: - Sentinel
  37. private static let markerKey = "storageReadinessMarker"
  38. /// Present once storage has been confirmed readable. Absent in true BFU (store
  39. /// encrypted) — presence, not a value, so it can't drift.
  40. static var markerExists: Bool {
  41. UserDefaults.standard.object(forKey: markerKey) != nil
  42. }
  43. // MARK: - Registry
  44. private struct WeakReloadable {
  45. weak var ref: BFUReloadable?
  46. }
  47. private static var registry: [WeakReloadable] = []
  48. /// Called by every storage value at init, so hydration covers all by construction.
  49. static func register(_ reloadable: BFUReloadable) {
  50. registry.append(WeakReloadable(ref: reloadable))
  51. }
  52. // MARK: - Deferred work
  53. private static var pending: [() -> Void] = []
  54. /// Run `block` now if ready, else once ready. For launch work that reads real
  55. /// config or mutates persisted history (telemetry, network).
  56. static func whenReady(_ block: @escaping () -> Void) {
  57. if ready.value {
  58. block()
  59. } else {
  60. pending.append(block)
  61. }
  62. }
  63. // MARK: - Lifecycle
  64. /// Call once at launch. A non-suspect launch is marked ready synchronously (the
  65. /// common path); a suspect launch stays gated until `recover()`.
  66. static func configure(suspectBFU: Bool) {
  67. self.suspectBFU = suspectBFU
  68. if !suspectBFU {
  69. markReady()
  70. }
  71. }
  72. /// Hydrate every registered value and open the gate. Idempotent; returns `true`
  73. /// only on the transition that did the work.
  74. @discardableResult
  75. static func recover() -> Bool {
  76. guard suspectBFU, !ready.value else { return false }
  77. registry.removeAll { $0.ref == nil } // drop dead weak slots
  78. for entry in registry {
  79. entry.ref?.reload()
  80. }
  81. markReady()
  82. return true
  83. }
  84. private static func markReady() {
  85. guard !ready.value else { return }
  86. // Clear suspect BEFORE publishing: @Published emits in willSet, so a
  87. // ready.$value subscriber (BLEManager reconnect) runs while ready is still
  88. // false — clearing first lets its persists land.
  89. suspectBFU = false
  90. // Store is readable here (proven by the probes, or by protected data
  91. // becoming available), and class-C UserDefaults is writable while readable.
  92. if !markerExists {
  93. UserDefaults.standard.set(true, forKey: markerKey)
  94. }
  95. ready.value = true
  96. let blocks = pending
  97. pending.removeAll()
  98. blocks.forEach { $0() }
  99. }
  100. }