StorageReadiness.swift 4.4 KB

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