Ivan Valkou 5 лет назад
Родитель
Сommit
33dca3ccdb

+ 20 - 8
FreeAPS.xcodeproj/project.pbxproj

@@ -131,9 +131,12 @@
 		38BF021725E7CBBC00579895 /* PumpManagerExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BF021625E7CBBC00579895 /* PumpManagerExtensions.swift */; };
 		38BF021B25E7D06400579895 /* PumpSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BF021A25E7D06400579895 /* PumpSettingsView.swift */; };
 		38BF021D25E7E3AF00579895 /* Reservoir.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BF021C25E7E3AF00579895 /* Reservoir.swift */; };
-		38BF021F25E7F0DE00579895 /* DeviceDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BF021E25E7F0DE00579895 /* DeviceDataManager.swift */; };
+		38BF021F25E7F0DE00579895 /* BaseDeviceDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BF021E25E7F0DE00579895 /* BaseDeviceDataManager.swift */; };
+		38C4D33725E9A1A300D30B77 /* DispatchQueue+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C4D33625E9A1A200D30B77 /* DispatchQueue+Extensions.swift */; };
+		38C4D33A25E9A1ED00D30B77 /* NSObject+AssociatedValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C4D33925E9A1ED00D30B77 /* NSObject+AssociatedValues.swift */; };
 		38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3D525E8FDF40078B0D1 /* MD5.swift */; };
 		38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */; };
+		38FCF3FD25E997A80078B0D1 /* PumpHistoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */; };
 		38FE826A25CC82DB001FF17A /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FE826925CC82DB001FF17A /* NetworkService.swift */; };
 		38FE826D25CC8461001FF17A /* NightscoutAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FE826C25CC8461001FF17A /* NightscoutAPI.swift */; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
@@ -615,11 +618,14 @@
 		38BF021625E7CBBC00579895 /* PumpManagerExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerExtensions.swift; sourceTree = "<group>"; };
 		38BF021A25E7D06400579895 /* PumpSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpSettingsView.swift; sourceTree = "<group>"; };
 		38BF021C25E7E3AF00579895 /* Reservoir.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reservoir.swift; sourceTree = "<group>"; };
-		38BF021E25E7F0DE00579895 /* DeviceDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataManager.swift; sourceTree = "<group>"; };
+		38BF021E25E7F0DE00579895 /* BaseDeviceDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseDeviceDataManager.swift; sourceTree = "<group>"; };
+		38C4D33625E9A1A200D30B77 /* DispatchQueue+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Extensions.swift"; sourceTree = "<group>"; };
+		38C4D33925E9A1ED00D30B77 /* NSObject+AssociatedValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSObject+AssociatedValues.swift"; sourceTree = "<group>"; };
 		38FCF3D525E8FDF40078B0D1 /* MD5.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5.swift; sourceTree = "<group>"; };
 		38FCF3ED25E9028E0078B0D1 /* FreeAPSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FreeAPSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		38FCF3F125E9028E0078B0D1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileStorageTests.swift; sourceTree = "<group>"; };
+		38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpHistoryStorage.swift; sourceTree = "<group>"; };
 		38FE826925CC82DB001FF17A /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = "<group>"; };
 		38FE826C25CC8461001FF17A /* NightscoutAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutAPI.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
@@ -1002,7 +1008,8 @@
 			children = (
 				3811DF0B25CAAABD00A708ED /* APSManager.swift */,
 				3811DF0F25CAAAE200A708ED /* BaseAPSManager.swift */,
-				38BF021E25E7F0DE00579895 /* DeviceDataManager.swift */,
+				38BF021E25E7F0DE00579895 /* BaseDeviceDataManager.swift */,
+				38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */,
 				38A504F625DDA0E200C5B9E8 /* Extensions */,
 				388E5A5825B6F0070019842D /* OpenAPS */,
 			);
@@ -1066,14 +1073,16 @@
 		388E5A5A25B6F05F0019842D /* Helpers */ = {
 			isa = PBXGroup;
 			children = (
-				3811DEE325CA063400A708ED /* PropertyWrappers */,
+				38C4D33625E9A1A200D30B77 /* DispatchQueue+Extensions.swift */,
 				3811DE5425C9D4D500A708ED /* Formatters.swift */,
+				38B4F3AE25E2979F00E76A18 /* IndexedCollection.swift */,
+				388E5A5B25B6F0770019842D /* JSON.swift */,
+				38FCF3D525E8FDF40078B0D1 /* MD5.swift */,
+				38C4D33925E9A1ED00D30B77 /* NSObject+AssociatedValues.swift */,
 				3811DE5725C9D4D500A708ED /* ProgressBar.swift */,
 				3811DE5525C9D4D500A708ED /* Publisher.swift */,
 				3811DE5925C9D4D500A708ED /* ViewModifiers.swift */,
-				388E5A5B25B6F0770019842D /* JSON.swift */,
-				38B4F3AE25E2979F00E76A18 /* IndexedCollection.swift */,
-				38FCF3D525E8FDF40078B0D1 /* MD5.swift */,
+				3811DEE325CA063400A708ED /* PropertyWrappers */,
 			);
 			path = Helpers;
 			sourceTree = "<group>";
@@ -1636,6 +1645,8 @@
 				3811DE8F25C9D80400A708ED /* User.swift in Sources */,
 				3811DF0C25CAAABD00A708ED /* APSManager.swift in Sources */,
 				3811DEB225C9D88300A708ED /* KeychainItemAccessibility.swift in Sources */,
+				38FCF3FD25E997A80078B0D1 /* PumpHistoryStorage.swift in Sources */,
+				38C4D33725E9A1A300D30B77 /* DispatchQueue+Extensions.swift in Sources */,
 				3811DE6B25C9D62600A708ED /* OnboardingProvider.swift in Sources */,
 				38B4F3C325E2A20B00E76A18 /* PumpSetupView.swift in Sources */,
 				38B4F3CA25E502E200E76A18 /* SwiftNotificationCenter.swift in Sources */,
@@ -1708,7 +1719,7 @@
 				38BF021B25E7D06400579895 /* PumpSettingsView.swift in Sources */,
 				3811DEEA25CA063400A708ED /* SyncAccess.swift in Sources */,
 				3811DE4F25C9D4B800A708ED /* AuthotizedRootDataFlow.swift in Sources */,
-				38BF021F25E7F0DE00579895 /* DeviceDataManager.swift in Sources */,
+				38BF021F25E7F0DE00579895 /* BaseDeviceDataManager.swift in Sources */,
 				3811DE5025C9D4B800A708ED /* AuthotizedRootProvider.swift in Sources */,
 				38A504A425DD9C4000C5B9E8 /* UserDefaultsExtensions.swift in Sources */,
 				38FE826A25CC82DB001FF17A /* NetworkService.swift in Sources */,
@@ -1725,6 +1736,7 @@
 				3811DE4325C9D4A100A708ED /* SettingsProvider.swift in Sources */,
 				E102DE9C3E9C8AEDCB3C61BB /* ConfigEditorBuilder.swift in Sources */,
 				45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */,
+				38C4D33A25E9A1ED00D30B77 /* NSObject+AssociatedValues.swift in Sources */,
 				72F1BD388F42FCA6C52E4500 /* ConfigEditorProvider.swift in Sources */,
 				E39E418C56A5A46B61D960EE /* ConfigEditorViewModel.swift in Sources */,
 				45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */,

+ 2 - 3
FreeAPS/Sources/APS/BaseAPSManager.swift

@@ -4,9 +4,9 @@ import LoopKitUI
 import Swinject
 
 final class BaseAPSManager: APSManager, Injectable {
-    @Injected() var storage: FileStorage!
+    @Injected() private var storage: FileStorage!
+    @Injected() private var deviceDataManager: DeviceDataManager!
     private var openAPS: OpenAPS!
-    private var deviceDataManager: DeviceDataManager!
 
     var pumpManager: PumpManagerUI? {
         get {
@@ -21,7 +21,6 @@ final class BaseAPSManager: APSManager, Injectable {
 
     init(resolver: Resolver) {
         injectServices(resolver)
-        deviceDataManager = DeviceDataManager(storage: storage)
         openAPS = OpenAPS(storage: storage)
     }
 

+ 14 - 43
FreeAPS/Sources/APS/DeviceDataManager.swift

@@ -5,8 +5,14 @@ import LoopKitUI
 import MinimedKit
 import OmniKit
 import SwiftDate
+import Swinject
 import UserNotifications
 
+protocol DeviceDataManager {
+    var pumpManager: PumpManagerUI? { get set }
+    var pumpDisplayState: CurrentValueSubject<PumpDisplayState?, Never> { get }
+}
+
 private let staticPumpManagers: [PumpManagerUI.Type] = [
     MinimedPumpManager.self,
     OmnipodPumpManager.self
@@ -16,8 +22,8 @@ private let staticPumpManagersByIdentifier: [String: PumpManagerUI.Type] = stati
     map[Type.managerIdentifier] = Type
 }
 
-final class DeviceDataManager {
-    private let storage: FileStorage
+final class BaseDeviceDataManager: DeviceDataManager, Injectable {
+    @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
 
     var pumpManager: PumpManagerUI? {
         didSet {
@@ -33,8 +39,8 @@ final class DeviceDataManager {
 
     let pumpDisplayState = CurrentValueSubject<PumpDisplayState?, Never>(nil)
 
-    init(storage: FileStorage) {
-        self.storage = storage
+    init(resolver: Resolver) {
+        injectServices(resolver)
         setupPumpManager()
     }
 
@@ -61,44 +67,9 @@ final class DeviceDataManager {
 
         return staticPumpManagersByIdentifier[managerIdentifier]
     }
-
-    private func storePumpEvents(_ events: [NewPumpEvent]) {
-        print(
-            "[DeviceDataManager] new pump events: \(events.map(\.title))"
-        )
-
-        let numberFormatter = NumberFormatter()
-        numberFormatter.numberStyle = .decimal
-
-        let eventsToStore = events.flatMap { event -> [PumpHistoryEvent] in
-            switch event.type {
-            case .bolus:
-                guard let dose = event.dose else { return [] }
-                let decimal = Decimal(string: dose.unitsInDeliverableIncrements.description)
-                return [PumpHistoryEvent(
-                    id: event.raw.md5String,
-                    type: .bolus,
-                    timestamp: event.date,
-                    amount: decimal,
-                    duration: nil,
-                    durationMin: nil,
-                    rate: nil,
-                    temp: nil
-                )]
-            default:
-                return []
-            }
-        }
-
-        do {
-            try storage.append(eventsToStore, to: OpenAPS.Monitor.pumpHistory, uniqBy: \.id)
-        } catch {
-            try? storage.save(eventsToStore, as: OpenAPS.Monitor.pumpHistory)
-        }
-    }
 }
 
-extension DeviceDataManager: PumpManagerDelegate {
+extension BaseDeviceDataManager: PumpManagerDelegate {
     func pumpManager(_: PumpManager, didAdjustPumpClockBy _: TimeInterval) {
 //        log.debug("didAdjustPumpClockBy %@", adjustment)
     }
@@ -139,7 +110,7 @@ extension DeviceDataManager: PumpManagerDelegate {
         lastReconciliation _: Date?,
         completion: @escaping (_ error: Error?) -> Void
     ) {
-        storePumpEvents(events)
+        pumpHistoryStorage.storePumpEvents(events)
         completion(nil)
     }
 
@@ -174,7 +145,7 @@ extension DeviceDataManager: PumpManagerDelegate {
 
 // MARK: - DeviceManagerDelegate
 
-extension DeviceDataManager: DeviceManagerDelegate {
+extension BaseDeviceDataManager: DeviceManagerDelegate {
     func scheduleNotification(
         for _: DeviceManager,
         identifier: String,
@@ -215,7 +186,7 @@ extension DeviceDataManager: DeviceManagerDelegate {
 
 // MARK: - AlertPresenter
 
-extension DeviceDataManager: AlertPresenter {
+extension BaseDeviceDataManager: AlertPresenter {
     func issueAlert(_: Alert) {}
 
     func retractAlert(identifier _: Alert.Identifier) {}

+ 140 - 0
FreeAPS/Sources/APS/PumpHistoryStorage.swift

@@ -0,0 +1,140 @@
+import Foundation
+import LoopKit
+import SwiftDate
+import Swinject
+
+protocol PumpHistoryStorage {
+    func storePumpEvents(_ events: [NewPumpEvent])
+}
+
+final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
+    private let processQueue = DispatchQueue(label: "BasePumpHistoryStorage.processQueue")
+    @Injected() private var storage: FileStorage!
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+    }
+
+    func storePumpEvents(_ events: [NewPumpEvent]) {
+        processQueue.async {
+            let eventsToStore = events.flatMap { event -> [PumpHistoryEvent] in
+                let id = event.raw.md5String
+                switch event.type {
+                case .bolus:
+                    print("[PUMP EVENT] Bolus event:\n\(event.title))")
+                    guard let dose = event.dose else { return [] }
+                    let amount = Decimal(string: dose.unitsInDeliverableIncrements.description)
+                    return [PumpHistoryEvent(
+                        id: id,
+                        type: .bolus,
+                        timestamp: event.date,
+                        amount: amount,
+                        duration: nil,
+                        durationMin: nil,
+                        rate: nil,
+                        temp: nil
+                    )]
+                case .tempBasal:
+                    print("[PUMP EVENT] Temp basal event:\n\(event.title))")
+                    guard let dose = event.dose else { return [] }
+                    let rate = Decimal(string: dose.unitsPerHour.description)
+                    let minutes = Int((dose.endDate - dose.startDate).timeInterval / 60)
+                    return [
+                        PumpHistoryEvent(
+                            id: id,
+                            type: .tempBasalDuration,
+                            timestamp: event.date,
+                            amount: nil,
+                            duration: nil,
+                            durationMin: minutes,
+                            rate: rate,
+                            temp: nil
+                        ),
+                        PumpHistoryEvent(
+                            id: "_" + id,
+                            type: .tempBasal,
+                            timestamp: event.date,
+                            amount: nil,
+                            duration: nil,
+                            durationMin: nil,
+                            rate: rate,
+                            temp: .absolute
+                        )
+                    ]
+                case .suspend:
+                    print("[PUMP EVENT] Suspend event:\n\(event.title))")
+                    return [
+                        PumpHistoryEvent(
+                            id: id,
+                            type: .pumpSuspend,
+                            timestamp: event.date,
+                            amount: nil,
+                            duration: nil,
+                            durationMin: nil,
+                            rate: nil,
+                            temp: nil
+                        )
+                    ]
+                case .resume:
+                    print("[PUMP EVENT] Resume event:\n\(event.title))")
+                    return [
+                        PumpHistoryEvent(
+                            id: id,
+                            type: .pumpResume,
+                            timestamp: event.date,
+                            amount: nil,
+                            duration: nil,
+                            durationMin: nil,
+                            rate: nil,
+                            temp: nil
+                        )
+                    ]
+                case .rewind:
+                    print("[PUMP EVENT] Rewind event:\n\(event.title))")
+                    return [
+                        PumpHistoryEvent(
+                            id: id,
+                            type: .rewind,
+                            timestamp: event.date,
+                            amount: nil,
+                            duration: nil,
+                            durationMin: nil,
+                            rate: nil,
+                            temp: nil
+                        )
+                    ]
+                case .prime:
+                    print("[PUMP EVENT] Prime event:\n\(event.title))")
+                    return [
+                        PumpHistoryEvent(
+                            id: id,
+                            type: .prime,
+                            timestamp: event.date,
+                            amount: nil,
+                            duration: nil,
+                            durationMin: nil,
+                            rate: nil,
+                            temp: nil
+                        )
+                    ]
+                default:
+                    return []
+                }
+            }
+
+            self.processNewEvents(eventsToStore)
+        }
+    }
+
+    private func processNewEvents(_ events: [PumpHistoryEvent]) {
+        dispatchPrecondition(condition: .onQueue(processQueue))
+        try? storage.transaction { storage in
+            try storage.append(events, to: OpenAPS.Monitor.pumpHistory, uniqBy: \.id)
+            let uniqEvents = try storage.retrieve(OpenAPS.Monitor.pumpHistory, as: [PumpHistoryEvent].self)
+                .filter { $0.timestamp.addingTimeInterval(1.days.timeInterval) > Date() }
+                .sorted { $0.timestamp > $1.timestamp }
+            print("[HISTORY] New Events\n\(uniqEvents)")
+            try storage.save(Array(uniqEvents), as: OpenAPS.Monitor.pumpHistory)
+        }
+    }
+}

+ 1 - 0
FreeAPS/Sources/Application/FreeAPSApp.swift

@@ -27,6 +27,7 @@ private extension Swinject.Resolver {
 
     private static func loadServices() {
         resolver.resolve(AppearanceManager.self)!.setupGlobalAppearance()
+        _ = resolver.resolve(DeviceDataManager.self)!
         _ = resolver.resolve(APSManager.self)!
     }
 

+ 2 - 0
FreeAPS/Sources/Containers/APSContainer.swift

@@ -5,6 +5,8 @@ private let resolver = FreeAPSApp.resolver
 
 enum APSContainer: DependeciesContainer {
     static func register(container: Container) {
+        container.register(PumpHistoryStorage.self) { _ in BasePumpHistoryStorage(resolver: resolver) }
+        container.register(DeviceDataManager.self) { _ in BaseDeviceDataManager(resolver: resolver) }
         container.register(APSManager.self) { _ in BaseAPSManager(resolver: resolver) }
     }
 }

+ 83 - 0
FreeAPS/Sources/Helpers/DispatchQueue+Extensions.swift

@@ -0,0 +1,83 @@
+import Foundation
+
+extension DispatchQueue {
+//    static let reloadQueue = DispatchQueue.markedQueue(label: "reloadQueue", qos: .ui)
+}
+
+extension DispatchQueue {
+    static var isMain: Bool {
+        Thread.isMainThread && OperationQueue.main === OperationQueue.current
+    }
+
+    static func safeMainSync<T>(_ block: () throws -> T) rethrows -> T {
+        if isMain {
+            return try block()
+        } else {
+            return try DispatchQueue.main.sync {
+                try autoreleasepool(invoking: block)
+            }
+        }
+    }
+
+    static func safeMainAsync(_ block: @escaping () -> Void) {
+        RunLoop.main.perform(inModes: [.default], block: block)
+    }
+}
+
+extension DispatchQueue {
+    private enum QueueSpecific {
+        static let key = DispatchSpecificKey<String>()
+        static let value = AssociationKey<String?>("DispatchQueue.Specific.value")
+    }
+
+    private(set) var specificValue: String? {
+        get { associations.value(forKey: QueueSpecific.value) }
+        set { associations.setValue(newValue, forKey: QueueSpecific.value) }
+    }
+
+    static func markedQueue(
+        label: String = "MarkedQueue",
+        qos: DispatchQoS = .default,
+        attributes: DispatchQueue.Attributes = [],
+        target: DispatchQueue? = nil
+    ) -> DispatchQueue {
+        let queueLabel = "\(label).\(UUID())"
+        let queue = DispatchQueue(
+            label: queueLabel,
+            qos: qos,
+            attributes: attributes,
+            autoreleaseFrequency: .workItem,
+            target: target
+        )
+        let specificValue = target?.label ?? queueLabel
+        queue.specificValue = specificValue
+        queue.setSpecific(key: QueueSpecific.key, value: specificValue)
+        return queue
+    }
+
+    static var currentLabel: String? { DispatchQueue.getSpecific(key: QueueSpecific.key) }
+
+    var isCurrentQueue: Bool {
+        if let staticSpecific = DispatchQueue.currentLabel,
+           let instanceSpecific = specificValue,
+           staticSpecific == instanceSpecific
+        {
+            return true
+        }
+        return false
+    }
+
+    func safeSync<T>(execute block: () throws -> T) rethrows -> T {
+        try autoreleasepool {
+            if self === DispatchQueue.main {
+                return try DispatchQueue.safeMainSync(block)
+            } else if isCurrentQueue {
+                return try block()
+            } else {
+                return try sync {
+                    try autoreleasepool(invoking: block)
+                }
+            }
+        }
+    }
+}

+ 82 - 0
FreeAPS/Sources/Helpers/NSObject+AssociatedValues.swift

@@ -0,0 +1,82 @@
+import Foundation
+
+struct AssociationKey<Value> {
+    fileprivate let address: UnsafeRawPointer
+    fileprivate let `default`: Value!
+    /// Create an ObjC association key from a `StaticString`.
+    ///
+    /// - precondition: `key` has a pointer representation.
+    ///
+    /// - parameters:
+    ///   - default: The default value, or `nil` to trap on undefined value. It is
+    ///              ignored if `Value` is an optional.
+    init(_ key: StaticString, default: Value? = nil) {
+        assert(key.hasPointerRepresentation, "AssociationKey.init key has no hasPointerRepresentation")
+        address = UnsafeRawPointer(key.utf8Start)
+        self.default = `default`
+    }
+}
+
+struct Associations<Base: AnyObject> {
+    private let base: Base
+
+    init(_ base: Base) {
+        self.base = base
+    }
+}
+
+extension NSObjectProtocol {
+    @nonobjc var associations: Associations<Self> {
+        Associations(self)
+    }
+
+    func getAssociatedValue<T>(forKey key: StaticString = #function) -> T? {
+        associations.value(forKey: AssociationKey<T?>(key))
+    }
+
+    func setAssociatedValue<T>(forKey key: StaticString = #function, value: T?) {
+        associations.setValue(value, forKey: AssociationKey<T?>(key))
+    }
+}
+
+extension Associations {
+    /// Retrieve the associated value for the specified key.
+    ///
+    /// - parameters:
+    ///   - key: The key.
+    ///
+    /// - returns: The associated value, or the default value if no value has been
+    ///            associated with the key.
+    func value<Value>(forKey key: AssociationKey<Value>) -> Value {
+        (objc_getAssociatedObject(base, key.address) as! Value?) ?? key.default
+    }
+
+    /// Retrieve the associated value for the specified key.
+    ///
+    /// - parameters:
+    ///   - key: The key.
+    ///
+    /// - returns: The associated value, or `nil` if no value is associated with
+    ///            the key.
+    func value<Value>(forKey key: AssociationKey<Value?>) -> Value? {
+        objc_getAssociatedObject(base, key.address) as! Value?
+    }
+
+    /// Set the associated value for the specified key.
+    ///
+    /// - parameters:
+    ///   - value: The value to be associated.
+    ///   - key: The key.
+    func setValue<Value>(_ value: Value, forKey key: AssociationKey<Value>) {
+        objc_setAssociatedObject(base, key.address, value, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+    }
+
+    /// Set the associated value for the specified key.
+    ///
+    /// - parameters:
+    ///   - value: The value to be associated.
+    ///   - key: The key.
+    func setValue<Value>(_ value: Value?, forKey key: AssociationKey<Value?>) {
+        objc_setAssociatedObject(base, key.address, value, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+    }
+}

+ 15 - 7
FreeAPS/Sources/Services/Storage/FileStorage.swift

@@ -10,10 +10,12 @@ protocol FileStorage {
     func append<Value: JSON, T: Equatable>(_ newValues: [Value], to name: String, uniqBy keyPath: KeyPath<Value, T>) throws
     func remove(_ name: String) throws
     func rename(_ name: String, to newName: String) throws
+    func transaction(_ exec: (FileStorage) throws -> Void) throws
 }
 
 final class BaseFileStorage: FileStorage {
-    private let processQueue = DispatchQueue(label: "BaseFileStorage.processQueue")
+    private let processQueue = DispatchQueue.markedQueue(label: "BaseFileStorage.processQueue", qos: .utility)
+
     private var encoder: JSONEncoder {
         let encoder = JSONEncoder()
         encoder.outputFormatting = .prettyPrinted
@@ -28,25 +30,25 @@ final class BaseFileStorage: FileStorage {
     }
 
     func save<Value: JSON>(_ value: Value, as name: String) throws {
-        try processQueue.sync {
+        try processQueue.safeSync {
             try Disk.save(value, to: .documents, as: name, encoder: self.encoder)
         }
     }
 
     func retrieve<Value: JSON>(_ name: String, as type: Value.Type) throws -> Value {
-        try processQueue.sync {
+        try processQueue.safeSync {
             try Disk.retrieve(name, from: .documents, as: type, decoder: decoder)
         }
     }
 
     func append<Value: JSON>(_ newValue: Value, to name: String) throws {
-        try processQueue.sync {
+        try processQueue.safeSync {
             try Disk.append(newValue, to: name, in: .documents, decoder: decoder, encoder: encoder)
         }
     }
 
     func append<Value: JSON>(_ newValues: [Value], to name: String) throws {
-        try processQueue.sync {
+        try processQueue.safeSync {
             try Disk.append(newValues, to: name, in: .documents, decoder: decoder, encoder: encoder)
         }
     }
@@ -85,14 +87,20 @@ final class BaseFileStorage: FileStorage {
     }
 
     func remove(_ name: String) throws {
-        try processQueue.sync {
+        try processQueue.safeSync {
             try Disk.remove(name, from: .documents)
         }
     }
 
     func rename(_ name: String, to newName: String) throws {
-        try processQueue.sync {
+        try processQueue.safeSync {
             try Disk.rename(name, in: .documents, to: newName)
         }
     }
+
+    func transaction(_ exec: (FileStorage) throws -> Void) throws {
+        try processQueue.safeSync {
+            try exec(self)
+        }
+    }
 }