Просмотр исходного кода

Gate startup on storage readiness for Before-First-Unlock recovery (#710)

Jonas Björkert 1 неделя назад
Родитель
Сommit
590024ffb5

+ 8 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -124,6 +124,8 @@
 		DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; };
 		DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; };
 		DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; };
+		5106EAD100000000000000A2 /* StorageReadiness.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5106EAD100000000000000A1 /* StorageReadiness.swift */; };
+		5106EAD100000000000000B2 /* StorageLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5106EAD100000000000000B1 /* StorageLoadingView.swift */; };
 		DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; };
 		DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; };
 		DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */; };
@@ -587,6 +589,8 @@
 		DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = "<group>"; };
 		DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = "<group>"; };
 		DD4878022C7B297E0048F05C /* StorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageValue.swift; sourceTree = "<group>"; };
+		5106EAD100000000000000A1 /* StorageReadiness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageReadiness.swift; sourceTree = "<group>"; };
+		5106EAD100000000000000B1 /* StorageLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageLoadingView.swift; sourceTree = "<group>"; };
 		DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
 		DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = "<group>"; };
 		DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViewModel.swift; sourceTree = "<group>"; };
@@ -1533,6 +1537,7 @@
 			children = (
 				FC9788172485969B00A7906C /* AppDelegate.swift */,
 				AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */,
+				5106EAD100000000000000B1 /* StorageLoadingView.swift */,
 				BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */,
 				FC9788272485969C00A7906C /* LaunchScreen.storyboard */,
 			);
@@ -1758,6 +1763,7 @@
 				DDD10F042C529DA200D76A8E /* ObservableValue.swift */,
 				DD4878022C7B297E0048F05C /* StorageValue.swift */,
 				DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */,
+				5106EAD100000000000000A1 /* StorageReadiness.swift */,
 			);
 			path = Framework;
 			sourceTree = "<group>";
@@ -2436,6 +2442,8 @@
 				DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */,
 				DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */,
 				DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */,
+				5106EAD100000000000000A2 /* StorageReadiness.swift in Sources */,
+				5106EAD100000000000000B2 /* StorageLoadingView.swift in Sources */,
 				FC1BDD2B24A22650001B652C /* Stats.swift in Sources */,
 				DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */,
 				DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */,

+ 52 - 44
LoopFollow/Application/AppDelegate.swift

@@ -13,6 +13,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         LogManager.shared.log(category: .general, message: "App started")
         LogManager.shared.cleanupOldLogs()
 
+        // Before-First-Unlock detection. isProtectedDataAvailable is false on ANY
+        // locked launch, so it alone isn't a BFU signal — post-first-unlock
+        // UserDefaults (class C) reads fine while locked. Only true BFU makes a key
+        // that should exist read as absent. Suspect only when ALL presence probes
+        // are absent, so an existing user who updated but hasn't foregrounded this
+        // build isn't misread on an ordinary locked launch. Probes cover every user
+        // shape (marker / migrated / consented / NS-configured); presence, not value.
+        _ = Storage.shared // ensure every StorageValue is registered before recovery
+        let storageConfirmedReadable = StorageReadiness.markerExists
+            || Storage.shared.migrationStep.exists
+            || Storage.shared.telemetryConsentDecisionMade.exists
+            || Storage.shared.url.exists
+        let suspectBFU = !UIApplication.shared.isProtectedDataAvailable && !storageConfirmedReadable
+        StorageReadiness.configure(suspectBFU: suspectBFU)
+        LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(UIApplication.shared.isProtectedDataAvailable), storageConfirmedReadable=\(storageConfirmedReadable), suspectBFU=\(suspectBFU)")
+
+        if suspectBFU {
+            // Driven here, not MainViewController: on a BG-only launch (BGAppRefreshTask,
+            // BLE wake) the home VC may not exist yet. protectedDataDidBecomeAvailable is
+            // authoritative; willEnterForeground is a fallback.
+            let nc = NotificationCenter.default
+            nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil)
+            nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
+
+            // Race guard: protected data may have become available between the check
+            // above and the observer registration just now.
+            if UIApplication.shared.isProtectedDataAvailable {
+                completeStorageRecovery()
+            }
+        }
+
         let options: UNAuthorizationOptions = [.alert, .sound, .badge]
         notificationCenter.requestAuthorization(options: options) {
             didAllow, _ in
@@ -46,42 +77,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
         BackgroundRefreshManager.shared.register()
 
-        // Telemetry: record this cold launch (used by the rolling
-        // coldLaunches7d signal). If the running build's SHA differs from
-        // the one we last sent for, fire an immediate ping — the scheduler
-        // alone can't notice an app update. Otherwise let the 24h scheduler
-        // handle cadence: its first run is lastSentAt + 24h, so a relaunch
-        // a few hours after the previous send simply waits out the
-        // remainder. See Helpers/Telemetry.swift.
-        TelemetryClient.shared.recordColdLaunch()
-        Task.detached {
-            if TelemetryClient.shared.buildShaChangedSinceLastSend() {
-                await TelemetryClient.shared.maybeSend()
+        // Telemetry mutates rolling history (coldLaunches7d), so defer past a BFU
+        // window — poisoned defaults would discard real history. Runs synchronously
+        // on a normal launch.
+        StorageReadiness.whenReady {
+            // SHA change fires an immediate ping (the scheduler can't notice an app
+            // update); otherwise the 24h scheduler handles cadence. See Telemetry.swift.
+            TelemetryClient.shared.recordColdLaunch()
+            Task.detached {
+                if TelemetryClient.shared.buildShaChangedSinceLastSend() {
+                    await TelemetryClient.shared.maybeSend()
+                }
+                TelemetryClient.shared.scheduleRecurring()
             }
-            TelemetryClient.shared.scheduleRecurring()
-        }
-
-        // Detect Before-First-Unlock launch. If protected data is unavailable here,
-        // StorageValues were cached from encrypted UserDefaults and need a reload
-        // once the device is unlocked.
-        let bfu = !UIApplication.shared.isProtectedDataAvailable
-        Storage.shared.needsBFUReload = bfu
-        LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)")
-
-        // Recovery is driven from AppDelegate (not MainViewController) because under
-        // the SwiftUI App lifecycle the home tab's UIHostingController is materialized
-        // lazily — on a BG-only launch (BGAppRefreshTask, BLE wake) MainViewController
-        // may not exist when the device is unlocked, and would miss willEnterForeground.
-        // protectedDataDidBecomeAvailable fires the moment file protection lifts and
-        // is the authoritative signal; willEnterForeground is a fallback.
-        let nc = NotificationCenter.default
-        nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil)
-        nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
-
-        // Race guard: protected data may have become available between the check
-        // above and the observer registration just now.
-        if Storage.shared.needsBFUReload, UIApplication.shared.isProtectedDataAvailable {
-            performBFUReloadIfNeeded()
         }
 
         return true
@@ -90,19 +98,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
     // MARK: - BFU recovery
 
     @objc private func protectedDataDidBecomeAvailable() {
-        performBFUReloadIfNeeded()
+        completeStorageRecovery()
     }
 
     @objc private func handleWillEnterForeground() {
-        performBFUReloadIfNeeded()
+        completeStorageRecovery()
     }
 
-    private func performBFUReloadIfNeeded() {
-        guard Storage.shared.needsBFUReload else { return }
-        Storage.shared.needsBFUReload = false
-        LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues")
-        Storage.shared.reloadAll()
-        LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(Storage.shared.url.value)'")
+    private func completeStorageRecovery() {
+        // recover() hydrates every value and opens the gate; true only on the one
+        // transition that did the work, so the notification fires exactly once.
+        guard StorageReadiness.recover() else { return }
+        LogManager.shared.log(category: .general, message: "BFU recovery complete: url='\(Storage.shared.url.value)'")
         NotificationCenter.default.post(name: .bfuReloadCompleted, object: nil)
     }
 
@@ -207,7 +214,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
 extension Notification.Name {
     /// Posted by AppDelegate after a Before-First-Unlock recovery completes
-    /// (Storage.reloadAll has run with the now-decrypted UserDefaults).
+    /// (StorageReadiness.recover has hydrated every value from the now-decrypted
+    /// UserDefaults).
     static let bfuReloadCompleted = Notification.Name("LoopFollow.bfuReloadCompleted")
 }
 

+ 18 - 9
LoopFollow/Application/LoopFollowApp.swift

@@ -6,18 +6,27 @@ import SwiftUI
 @main
 struct LoopFollowApp: App {
     @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
+    @ObservedObject private var storageReady = StorageReadiness.ready
 
     var body: some Scene {
         WindowGroup {
-            MainTabView()
-                .onOpenURL { url in
-                    guard url.scheme == AppGroupID.urlScheme, url.host == "la-tap" else { return }
-                    #if !targetEnvironment(macCatalyst)
-                        DispatchQueue.main.async {
-                            NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
-                        }
-                    #endif
-                }
+            // Gate the UI on storage readiness so nothing (bootstrap, telemetry
+            // consent, onboarding) is built against a poisoned cache. True
+            // synchronously on a normal launch; only false briefly while a BFU
+            // background launch is foregrounded mid-hydration.
+            if storageReady.value {
+                MainTabView()
+                    .onOpenURL { url in
+                        guard url.scheme == AppGroupID.urlScheme, url.host == "la-tap" else { return }
+                        #if !targetEnvironment(macCatalyst)
+                            DispatchQueue.main.async {
+                                NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
+                            }
+                        #endif
+                    }
+            } else {
+                StorageLoadingView()
+            }
         }
     }
 }

+ 19 - 0
LoopFollow/Application/StorageLoadingView.swift

@@ -0,0 +1,19 @@
+// LoopFollow
+// StorageLoadingView.swift
+
+import SwiftUI
+
+/// Shown at the root until `StorageReadiness.ready` — only while a BFU background
+/// launch is foregrounded mid-hydration; a normal launch opens straight to
+/// `MainTabView`. Must not read `Storage` (values are poisoned here) — system
+/// colors only.
+struct StorageLoadingView: View {
+    var body: some View {
+        ZStack {
+            Color(.systemBackground)
+                .ignoresSafeArea()
+            ProgressView()
+                .controlSize(.large)
+        }
+    }
+}

+ 21 - 5
LoopFollow/BackgroundRefresh/BT/BLEManager.swift

@@ -12,6 +12,7 @@ class BLEManager: NSObject, ObservableObject {
 
     private var centralManager: CBCentralManager!
     private var activeDevice: BluetoothDevice?
+    private var readinessCancellable: AnyCancellable?
 
     override private init() {
         super.init()
@@ -20,13 +21,28 @@ class BLEManager: NSObject, ObservableObject {
             delegate: self,
             queue: .main
         )
-        if let device = Storage.shared.selectedBLEDevice.value {
-            devices.append(device)
-            findAndUpdateDevice(with: device.id.uuidString) { device in
-                device.rssi = 0
+        connectSelectedDeviceIfNeeded()
+
+        // After BFU, selectedBLEDevice reads nil until hydration — reconnect when
+        // storage becomes ready. dropFirst skips the current value (init handled the
+        // already-ready case), so this fires only on the false→true recovery.
+        readinessCancellable = StorageReadiness.ready.$value
+            .dropFirst()
+            .filter { $0 }
+            .sink { [weak self] _ in
+                self?.connectSelectedDeviceIfNeeded()
             }
-            connect(device: device)
+    }
+
+    private func connectSelectedDeviceIfNeeded() {
+        guard activeDevice == nil, let device = Storage.shared.selectedBLEDevice.value else { return }
+        if !devices.contains(where: { $0.id == device.id }) {
+            devices.append(device)
+        }
+        findAndUpdateDevice(with: device.id.uuidString) { device in
+            device.rssi = 0
         }
+        connect(device: device)
     }
 
     func getSelectedDevice() -> BLEDevice? {

+ 19 - 1
LoopFollow/Storage/Framework/SecureStorageValue.swift

@@ -4,12 +4,17 @@
 import Combine
 import Foundation
 
-class SecureStorageValue<T: NSObject & NSSecureCoding & Equatable>: ObservableObject {
+class SecureStorageValue<T: NSObject & NSSecureCoding & Equatable>: ObservableObject, BFUReloadable {
     let key: String
 
     @Published var value: T {
         didSet {
             guard value != oldValue else { return }
+            // Memory-only during a suspect BFU window; see StorageValue's guard.
+            guard !StorageReadiness.isSuppressingWrites else {
+                StorageReadiness.noteSuppressedWrite(key: key)
+                return
+            }
             if let data = try? NSKeyedArchiver.archivedData(withRootObject: value, requiringSecureCoding: true) {
                 SecureStorageValue.defaults.set(data, forKey: key)
             }
@@ -33,9 +38,22 @@ class SecureStorageValue<T: NSObject & NSSecureCoding & Equatable>: ObservableOb
         } else {
             value = defaultValue
         }
+
+        StorageReadiness.register(self)
     }
 
     func remove() {
         SecureStorageValue.defaults.removeObject(forKey: key)
     }
+
+    /// Re-reads from UserDefaults during BFU recovery. Same class-C backing as
+    /// StorageValue, so it recovers identically.
+    func reload() {
+        if let data = SecureStorageValue.defaults.data(forKey: key),
+           let decodedValue = try? NSKeyedUnarchiver.unarchivedObject(ofClass: T.self, from: data),
+           decodedValue != value
+        {
+            value = decodedValue
+        }
+    }
 }

+ 125 - 0
LoopFollow/Storage/Framework/StorageReadiness.swift

@@ -0,0 +1,125 @@
+// LoopFollow
+// StorageReadiness.swift
+
+import Combine
+import Foundation
+import UIKit
+
+/// A cached value that can re-read itself from disk after a BFU launch.
+protocol BFUReloadable: AnyObject {
+    func reload()
+}
+
+/// Before-First-Unlock (BFU) storage recovery + the app-wide readiness gate.
+///
+/// On a launch before the device's first unlock since boot, `UserDefaults` is
+/// encrypted, so every `StorageValue`/`SecureStorageValue` initialises to its
+/// default. This distinguishes true BFU from an ordinary locked launch (where the
+/// store reads fine), gates the UI until storage is trustworthy, hydrates every
+/// registered value once readable, and suppresses writes meanwhile so poisoned
+/// defaults can't be flushed over real settings on unlock.
+///
+/// All mutators run on the main thread (launch + the protected-data/foreground
+/// notifications); `register(_:)` runs during `Storage` init. So no locking.
+enum StorageReadiness {
+    /// Published gate; `false` until storage is known-good. The root shows a
+    /// storage-free loading view until `true`.
+    static let ready = ObservableValue<Bool>(default: false)
+
+    /// True for the duration of a suspected true-BFU launch, until hydration runs.
+    private(set) static var suspectBFU = false
+
+    /// While true, storage values keep writes in memory only (hydration reconciles
+    /// from disk). Off-main reads see a benign eventually-consistent Bool snapshot.
+    static var isSuppressingWrites: Bool { suspectBFU && !ready.value }
+
+    /// Debug surface for any writer active during the suspect window — such a write
+    /// is memory-only and lost if it was needed on disk. None exist today.
+    static func noteSuppressedWrite(key: String) {
+        #if DEBUG
+            LogManager.shared.log(category: .general, message: "Storage write to '\(key)' suppressed during BFU window")
+        #endif
+    }
+
+    // MARK: - Sentinel
+
+    private static let markerKey = "storageReadinessMarker"
+
+    /// Present once storage has been confirmed readable. Absent in true BFU (store
+    /// encrypted) — presence, not a value, so it can't drift.
+    static var markerExists: Bool {
+        UserDefaults.standard.object(forKey: markerKey) != nil
+    }
+
+    // MARK: - Registry
+
+    private struct WeakReloadable {
+        weak var ref: BFUReloadable?
+    }
+
+    private static var registry: [WeakReloadable] = []
+
+    /// Called by every storage value at init, so hydration covers all by construction.
+    static func register(_ reloadable: BFUReloadable) {
+        registry.append(WeakReloadable(ref: reloadable))
+    }
+
+    // MARK: - Deferred work
+
+    private static var pending: [() -> Void] = []
+
+    /// Run `block` now if ready, else once ready. For launch work that reads real
+    /// config or mutates persisted history (telemetry, network).
+    static func whenReady(_ block: @escaping () -> Void) {
+        if ready.value {
+            block()
+        } else {
+            pending.append(block)
+        }
+    }
+
+    // MARK: - Lifecycle
+
+    /// Call once at launch. A non-suspect launch is marked ready synchronously (the
+    /// common path); a suspect launch stays gated until `recover()`.
+    static func configure(suspectBFU: Bool) {
+        self.suspectBFU = suspectBFU
+        if !suspectBFU {
+            markReady()
+        }
+    }
+
+    /// Hydrate every registered value and open the gate. Idempotent; returns `true`
+    /// only on the transition that did the work.
+    @discardableResult
+    static func recover() -> Bool {
+        guard suspectBFU, !ready.value else { return false }
+        registry.removeAll { $0.ref == nil } // drop dead weak slots
+        for entry in registry {
+            entry.ref?.reload()
+        }
+        markReady()
+        return true
+    }
+
+    private static func markReady() {
+        guard !ready.value else { return }
+
+        // Clear suspect BEFORE publishing: @Published emits in willSet, so a
+        // ready.$value subscriber (BLEManager reconnect) runs while ready is still
+        // false — clearing first lets its persists land.
+        suspectBFU = false
+
+        // Store is readable here (proven by the probes, or by protected data
+        // becoming available), and class-C UserDefaults is writable while readable.
+        if !markerExists {
+            UserDefaults.standard.set(true, forKey: markerKey)
+        }
+
+        ready.value = true
+
+        let blocks = pending
+        pending.removeAll()
+        blocks.forEach { $0() }
+    }
+}

+ 10 - 1
LoopFollow/Storage/Framework/StorageValue.swift

@@ -4,13 +4,20 @@
 import Combine
 import Foundation
 
-class StorageValue<T: Codable & Equatable>: ObservableObject {
+class StorageValue<T: Codable & Equatable>: ObservableObject, BFUReloadable {
     let key: String
 
     @Published var value: T {
         didSet {
             guard value != oldValue else { return }
 
+            // Suspect BFU: keep in memory only — a poisoned-default write could be
+            // flushed over real data on unlock. Hydration reconciles from disk.
+            guard !StorageReadiness.isSuppressingWrites else {
+                StorageReadiness.noteSuppressedWrite(key: key)
+                return
+            }
+
             if let encodedData = try? JSONEncoder().encode(value) {
                 StorageValue.defaults.set(encodedData, forKey: key)
             }
@@ -35,6 +42,8 @@ class StorageValue<T: Codable & Equatable>: ObservableObject {
         } else {
             value = defaultValue
         }
+
+        StorageReadiness.register(self)
     }
 
     func remove() {

+ 0 - 193
LoopFollow/Storage/Storage.swift

@@ -249,199 +249,6 @@ class Storage {
     static let shared = Storage()
     private init() {}
 
-    /// Set to true at launch if isProtectedDataAvailable was false (BFU state).
-    /// Consumed and cleared on the first foreground after that launch.
-    var needsBFUReload = false
-
-    /// Re-reads every StorageValue from UserDefaults, firing @Published only where the value
-    /// actually changed. Call this when foregrounding after a Before-First-Unlock (BFU) background
-    /// launch, where Storage was initialized while UserDefaults was encrypted and all values were
-    /// cached as their defaults.
-    ///
-    /// `migrationStep` is intentionally excluded: viewDidLoad writes it to the latest step during
-    /// the BFU launch; if we reloaded it and the flush had somehow not landed yet, migrations would re-run.
-    ///
-    /// SecureStorageValue properties (maxBolus, maxCarbs, maxProtein, maxFat, bolusIncrement) are
-    /// not covered here — SecureStorageValue does not implement reload() and Keychain has the same
-    /// BFU inaccessibility; that is a separate problem.
-    func reloadAll() {
-        remoteType.reload()
-        deviceToken.reload()
-        expirationDate.reload()
-        sharedSecret.reload()
-        productionEnvironment.reload()
-        remoteApnsKey.reload()
-        teamId.reload()
-        remoteKeyId.reload()
-
-        lfApnsKey.reload()
-        lfKeyId.reload()
-        bundleId.reload()
-        user.reload()
-
-        mealWithBolus.reload()
-        mealWithFatProtein.reload()
-        hasSeenFatProteinOrderChange.reload()
-
-        backgroundRefreshType.reload()
-        selectedBLEDevice.reload()
-        debugLogLevel.reload()
-
-        contactTrend.reload()
-        contactDelta.reload()
-        contactEnabled.reload()
-        contactBackgroundColor.reload()
-        contactTextColor.reload()
-
-        sensorScheduleOffset.reload()
-        alarms.reload()
-        alarmConfiguration.reload()
-
-        lastOverrideStartNotified.reload()
-        lastOverrideEndNotified.reload()
-        lastTempTargetStartNotified.reload()
-        lastTempTargetEndNotified.reload()
-        lastRecBolusNotified.reload()
-        lastCOBNotified.reload()
-        lastMissedBolusNotified.reload()
-
-        appBadge.reload()
-        colorBGText.reload()
-        appearanceMode.reload()
-        showStats.reload()
-        useIFCC.reload()
-        showSmallGraph.reload()
-        screenlockSwitchState.reload()
-        showDisplayName.reload()
-        snoozerEmoji.reload()
-        forcePortraitMode.reload()
-
-        speakBG.reload()
-        speakBGAlways.reload()
-        speakLowBG.reload()
-        speakProactiveLowBG.reload()
-        speakFastDropDelta.reload()
-        speakLowBGLimit.reload()
-        speakHighBGLimit.reload()
-        speakHighBG.reload()
-        speakLanguage.reload()
-
-        lastBgReadingTimeSeconds.reload()
-        lastDeltaMgdl.reload()
-        lastTrendCode.reload()
-        lastIOB.reload()
-        lastCOB.reload()
-        projectedBgMgdl.reload()
-
-        lastBasal.reload()
-        lastPumpReservoirU.reload()
-        lastAutosens.reload()
-        lastTdd.reload()
-        lastTargetLowMgdl.reload()
-        lastTargetHighMgdl.reload()
-        lastIsfMgdlPerU.reload()
-        lastCarbRatio.reload()
-        lastCarbsToday.reload()
-        lastProfileName.reload()
-        iageInsertTime.reload()
-        lastMinBgMgdl.reload()
-        lastMaxBgMgdl.reload()
-
-        laEnabled.reload()
-        laRenewBy.reload()
-        laRenewalFailed.reload()
-        laPushToStartToken.reload()
-        laLastPushToStartAt.reload()
-        laPushToStartBackoff.reload()
-
-        showDots.reload()
-        showLines.reload()
-        showValues.reload()
-        showAbsorption.reload()
-        showDIALines.reload()
-        show30MinLine.reload()
-        show90MinLine.reload()
-        showMidnightLines.reload()
-        smallGraphTreatments.reload()
-        smallGraphHeight.reload()
-        predictionToLoad.reload()
-        predictionDisplayType.reload()
-        minBasalScale.reload()
-        minBGScale.reload()
-        lowLine.reload()
-        highLine.reload()
-        downloadDays.reload()
-        graphTimeZoneEnabled.reload()
-        graphTimeZoneIdentifier.reload()
-
-        writeCalendarEvent.reload()
-        calendarIdentifier.reload()
-        watchLine1.reload()
-        watchLine2.reload()
-
-        shareUserName.reload()
-        sharePassword.reload()
-        shareServer.reload()
-
-        chartScaleX.reload()
-
-        downloadTreatments.reload()
-        downloadPrediction.reload()
-        graphOtherTreatments.reload()
-        graphBasal.reload()
-        graphBolus.reload()
-        graphCarbs.reload()
-        bgUpdateDelay.reload()
-
-        cageInsertTime.reload()
-        sageInsertTime.reload()
-
-        cachedForVersion.reload()
-        latestVersion.reload()
-        latestVersionChecked.reload()
-        currentVersionBlackListed.reload()
-        lastBlacklistNotificationShown.reload()
-        lastVersionUpdateNotificationShown.reload()
-        lastExpirationNotificationShown.reload()
-
-        hideInfoTable.reload()
-        token.reload()
-        units.reload()
-        infoSort.reload()
-        infoVisible.reload()
-
-        url.reload()
-        device.reload()
-        nsWriteAuth.reload()
-        nsAdminAuth.reload()
-
-        // migrationStep intentionally excluded — see method comment above.
-
-        persistentNotification.reload()
-        persistentNotificationLastBGTime.reload()
-
-        lastLoopingChecked.reload()
-        lastBGChecked.reload()
-        lastLoopTime.reload()
-
-        homePosition.reload()
-        alarmsPosition.reload()
-        snoozerPosition.reload()
-        nightscoutPosition.reload()
-        remotePosition.reload()
-        statisticsPosition.reload()
-        treatmentsPosition.reload()
-
-        loopAPNSQrCodeURL.reload()
-        bolusIncrementDetected.reload()
-        remoteBolusHistory.reload()
-        remoteMealHistory.reload()
-        showGMI.reload()
-        showStdDev.reload()
-        showTITR.reload()
-        timeInRangeModeRaw.reload()
-    }
-
     // MARK: - Tab Position Helpers
 
     /// Get the position for a given tab item

+ 6 - 5
LoopFollow/ViewControllers/MainViewController.swift

@@ -218,9 +218,9 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
         // when runMigrationsIfNeeded() is called. This catches migrations deferred by a
         // background BGAppRefreshTask launch in Before-First-Unlock state.
         notificationCenter.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
-        // Posted by AppDelegate after Storage.reloadAll has refreshed every StorageValue
-        // following a BFU launch. If we're alive when this fires, our scheduled tasks
-        // were set up with BFU defaults (url='') and need to be redone.
+        // Posted by AppDelegate after BFU recovery. Vestigial with the readiness gate
+        // (this controller is built only after storage is ready, so it never fires
+        // while we're alive); retained one release as a safety net.
         notificationCenter.addObserver(self, selector: #selector(handleBFUReloadCompleted), name: .bfuReloadCompleted, object: nil)
 
         #if !targetEnvironment(macCatalyst)
@@ -680,8 +680,9 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
     }
 
     @objc func appCameToForeground() {
-        // BFU recovery (Storage.reloadAll) is driven by AppDelegate; this controller
-        // reacts via .bfuReloadCompleted in handleBFUReloadCompleted() above.
+        // BFU recovery (StorageReadiness.recover) is driven by AppDelegate before this
+        // controller exists (the readiness gate), so handleBFUReloadCompleted() above
+        // is a vestigial no-op in the gated flow.
 
         // reset screenlock state if needed
         UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value