Sfoglia il codice sorgente

APNS Feedback to LoopFollow from Trio

Jonas Björkert 9 mesi fa
parent
commit
3416d603af

+ 25 - 0
Trio.xcodeproj/project.pbxproj

@@ -549,6 +549,8 @@
 		DD1745502C55CA5500211FAC /* UnitsLimitsSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17454F2C55CA5500211FAC /* UnitsLimitsSettingsProvider.swift */; };
 		DD1745522C55CA5D00211FAC /* UnitsLimitsSettingsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745512C55CA5D00211FAC /* UnitsLimitsSettingsStateModel.swift */; };
 		DD1745552C55CA6C00211FAC /* UnitsLimitsSettingsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745542C55CA6C00211FAC /* UnitsLimitsSettingsRootView.swift */; };
+		DD17A0292E3FE0BD008E1BF0 /* SwiftJWT in Frameworks */ = {isa = PBXBuildFile; productRef = DD17A0282E3FE0BD008E1BF0 /* SwiftJWT */; };
+		DD17A0322E3FEA1F008E1BF0 /* RemoteNotificationResponseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17A0312E3FEA1F008E1BF0 /* RemoteNotificationResponseManager.swift */; };
 		DD1DB7CC2BECCA1F0048B367 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1DB7CB2BECCA1F0048B367 /* BuildDetails.swift */; };
 		DD1E53592D273F26008F32A4 /* LoopStatusHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1E53582D273F20008F32A4 /* LoopStatusHelpView.swift */; };
 		DD21FCB52C6952AD00AF2C25 /* DecimalPickerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD21FCB42C6952AD00AF2C25 /* DecimalPickerSettings.swift */; };
@@ -599,6 +601,7 @@
 		DD73FA0F2D74F58E00D19D1E /* BackgroundTask+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */; };
 		DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */; };
 		DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */; };
+		DD868FD82E381A54005D3308 /* APNSJWTClaims.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */; };
 		DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88C8E12C50420800F2D558 /* DefinitionRow.swift */; };
 		DD940BAA2CA7585D000830A5 /* GlucoseColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */; };
 		DD940BAC2CA75889000830A5 /* DynamicGlucoseColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */; };
@@ -1373,6 +1376,7 @@
 		DD17454F2C55CA5500211FAC /* UnitsLimitsSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsLimitsSettingsProvider.swift; sourceTree = "<group>"; };
 		DD1745512C55CA5D00211FAC /* UnitsLimitsSettingsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsLimitsSettingsStateModel.swift; sourceTree = "<group>"; };
 		DD1745542C55CA6C00211FAC /* UnitsLimitsSettingsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsLimitsSettingsRootView.swift; sourceTree = "<group>"; };
+		DD17A0312E3FEA1F008E1BF0 /* RemoteNotificationResponseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNotificationResponseManager.swift; sourceTree = "<group>"; };
 		DD1DB7CB2BECCA1F0048B367 /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = "<group>"; };
 		DD1E53582D273F20008F32A4 /* LoopStatusHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStatusHelpView.swift; sourceTree = "<group>"; };
 		DD21FCB42C6952AD00AF2C25 /* DecimalPickerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecimalPickerSettings.swift; sourceTree = "<group>"; };
@@ -1420,6 +1424,7 @@
 		DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BackgroundTask+Helper.swift"; sourceTree = "<group>"; };
 		DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusConfirmationView.swift; sourceTree = "<group>"; };
 		DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyPersistentFlags.swift; sourceTree = "<group>"; };
+		DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTClaims.swift; sourceTree = "<group>"; };
 		DD88C8E12C50420800F2D558 /* DefinitionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinitionRow.swift; sourceTree = "<group>"; };
 		DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseColorScheme.swift; sourceTree = "<group>"; };
 		DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicGlucoseColor.swift; sourceTree = "<group>"; };
@@ -1581,6 +1586,7 @@
 				3B4BA7862D8DBD690069D5B8 /* RileyLinkKitUI.framework in Frameworks */,
 				CEB434FD28B90B7C00B70274 /* SwiftCharts in Frameworks */,
 				CE95BF5F2BA7715800DC3DE3 /* MockKit.framework in Frameworks */,
+				DD17A0292E3FE0BD008E1BF0 /* SwiftJWT in Frameworks */,
 				3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */,
 				38DF1789276FC8C400B3528F /* SwiftMessages in Frameworks */,
 				3B4BA7802D8DBD690069D5B8 /* OmniKitUI.framework in Frameworks */,
@@ -3394,6 +3400,8 @@
 		DD9ECB662CA99EFE00AA7C45 /* RemoteControl */ = {
 			isa = PBXGroup;
 			children = (
+				DD17A0312E3FEA1F008E1BF0 /* RemoteNotificationResponseManager.swift */,
+				DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */,
 				DD32CFA12CC824E1003686D6 /* TrioRemoteControl+Helpers.swift */,
 				DD32CF9F2CC824D3003686D6 /* TrioRemoteControl+APNS.swift */,
 				DD32CF9D2CC824C2003686D6 /* TrioRemoteControl+Override.swift */,
@@ -3750,6 +3758,7 @@
 				3BD9687B2D8DDD4600899469 /* SlideButton */,
 				3BD9687E2D8DDD8800899469 /* CryptoSwift */,
 				3B47C60F2DA0A28F00B0E5EF /* FirebaseCrashlytics */,
+				DD17A0282E3FE0BD008E1BF0 /* SwiftJWT */,
 			);
 			productName = Trio;
 			productReference = 388E595825AD948C0019842D /* Trio.app */;
@@ -3926,6 +3935,7 @@
 				3BD9687A2D8DDD4600899469 /* XCRemoteSwiftPackageReference "SlideButton" */,
 				3BD9687D2D8DDD8800899469 /* XCRemoteSwiftPackageReference "CryptoSwift" */,
 				3B47C60E2DA0A28F00B0E5EF /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
+				DD868FD92E381E1C005D3308 /* XCRemoteSwiftPackageReference "Swift-JWT" */,
 			);
 			productRefGroup = 388E595925AD948C0019842D /* Products */;
 			projectDirPath = "";
@@ -4186,6 +4196,7 @@
 				C2A6D1E42DB1581D0036DB66 /* GlucoseStatsSetup.swift in Sources */,
 				3811DE3125C9D49500A708ED /* HomeProvider.swift in Sources */,
 				FE41E4D629463EE20047FD55 /* NightscoutPreferences.swift in Sources */,
+				DD868FD82E381A54005D3308 /* APNSJWTClaims.swift in Sources */,
 				E013D872273AC6FE0014109C /* GlucoseSimulatorSource.swift in Sources */,
 				BD249D862D42FBEC00412DEB /* GlucoseMetricsView.swift in Sources */,
 				58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */,
@@ -4567,6 +4578,7 @@
 				38A00B2325FC2B55006BC0B0 /* LRUCache.swift in Sources */,
 				DDD163122C4C689900CD525A /* AdjustmentsStateModel.swift in Sources */,
 				BD47FDD72D8B64D20043966B /* CarbRatioStepView.swift in Sources */,
+				DD17A0322E3FEA1F008E1BF0 /* RemoteNotificationResponseManager.swift in Sources */,
 				3B2F77862D7E52ED005ED9FA /* TDD.swift in Sources */,
 				3BA8D1B32DDB87150006191F /* DecimalExtensions.swift in Sources */,
 				DD3F1F892D9E078D00DCE7B3 /* TherapySettingEditorView.swift in Sources */,
@@ -5434,6 +5446,14 @@
 				kind = branch;
 			};
 		};
+		DD868FD92E381E1C005D3308 /* XCRemoteSwiftPackageReference "Swift-JWT" */ = {
+			isa = XCRemoteSwiftPackageReference;
+			repositoryURL = "http://github.com/Kitura/Swift-JWT.git";
+			requirement = {
+				kind = exactVersion;
+				version = 4.0.1;
+			};
+		};
 /* End XCRemoteSwiftPackageReference section */
 
 /* Begin XCSwiftPackageProductDependency section */
@@ -5482,6 +5502,11 @@
 			package = CEB434FB28B90B7C00B70274 /* XCRemoteSwiftPackageReference "SwiftCharts" */;
 			productName = SwiftCharts;
 		};
+		DD17A0282E3FE0BD008E1BF0 /* SwiftJWT */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = DD868FD92E381E1C005D3308 /* XCRemoteSwiftPackageReference "Swift-JWT" */;
+			productName = SwiftJWT;
+		};
 /* End XCSwiftPackageProductDependency section */
 
 /* Begin XCVersionGroup section */

+ 64 - 1
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,5 +1,5 @@
 {
-  "originHash" : "89074a88ed67a58ecd7534519854c5a0928a4046d7c8a6123a7d70f27bf8b44d",
+  "originHash" : "94bad7ee77953ff12d8447c80f68d417ecb6f69ad08c1fdb1a8f59473b79c3b7",
   "pins" : [
     {
       "identity" : "abseil-cpp-binary",
@@ -20,6 +20,33 @@
       }
     },
     {
+      "identity" : "bluecryptor",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/BlueCryptor.git",
+      "state" : {
+        "revision" : "cec97c24b111351e70e448972a7d3fe68a756d6d",
+        "version" : "2.0.2"
+      }
+    },
+    {
+      "identity" : "blueecc",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/BlueECC.git",
+      "state" : {
+        "revision" : "1485268a54f8135435a825a855e733f026fa6cc8",
+        "version" : "1.2.201"
+      }
+    },
+    {
+      "identity" : "bluersa",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/BlueRSA.git",
+      "state" : {
+        "revision" : "f40325520344a966523b214394aa350132a6af68",
+        "version" : "1.0.203"
+      }
+    },
+    {
       "identity" : "cryptoswift",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/krzyzanowskim/CryptoSwift",
@@ -92,6 +119,15 @@
       }
     },
     {
+      "identity" : "kituracontracts",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/KituraContracts.git",
+      "state" : {
+        "revision" : "6edf7ac3dd2b3a2c61284778d430bbad7d8a6f23",
+        "version" : "2.0.1"
+      }
+    },
+    {
       "identity" : "leveldb",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/firebase/leveldb.git",
@@ -101,6 +137,15 @@
       }
     },
     {
+      "identity" : "loggerapi",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/LoggerAPI.git",
+      "state" : {
+        "revision" : "4e6b45e850ffa275e8e26a24c6454fd709d5b6ac",
+        "version" : "2.0.0"
+      }
+    },
+    {
       "identity" : "mkringprogressview",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/maxkonovalov/MKRingProgressView.git",
@@ -146,6 +191,24 @@
       }
     },
     {
+      "identity" : "swift-jwt",
+      "kind" : "remoteSourceControl",
+      "location" : "http://github.com/Kitura/Swift-JWT.git",
+      "state" : {
+        "revision" : "f68ec28fbd90a651597e9e825ea7f315f8d52a1f",
+        "version" : "4.0.1"
+      }
+    },
+    {
+      "identity" : "swift-log",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-log.git",
+      "state" : {
+        "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
+        "version" : "1.6.4"
+      }
+    },
+    {
       "identity" : "swift-numerics",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-numerics",

+ 26 - 4
Trio/Sources/Models/PushMessage.swift

@@ -13,6 +13,25 @@ struct PushMessage: Codable, Sendable {
     var timestamp: TimeInterval
     var overrideName: String?
     var scheduledTime: TimeInterval?
+    var returnNotification: ReturnNotificationInfo?
+
+    struct ReturnNotificationInfo: Codable, Sendable {
+        let productionEnvironment: Bool
+        let deviceToken: String
+        let bundleId: String
+        let teamId: String
+        let keyId: String
+        let apnsKey: String
+
+        enum CodingKeys: String, CodingKey {
+            case productionEnvironment = "production_environment"
+            case deviceToken = "device_token"
+            case bundleId = "bundle_id"
+            case teamId = "team_id"
+            case keyId = "key_id"
+            case apnsKey = "apns_key"
+        }
+    }
 
     enum CodingKeys: String, CodingKey {
         case aps
@@ -28,6 +47,7 @@ struct PushMessage: Codable, Sendable {
         case timestamp
         case overrideName
         case scheduledTime = "scheduled_time"
+        case returnNotification = "return_notification"
     }
 
     func encode(to encoder: Encoder) throws {
@@ -43,9 +63,8 @@ struct PushMessage: Codable, Sendable {
         try container.encode(sharedSecret, forKey: .sharedSecret)
         try container.encode(timestamp, forKey: .timestamp)
         try container.encodeIfPresent(overrideName, forKey: .overrideName)
-        if let scheduledTime = scheduledTime {
-            try container.encode(scheduledTime, forKey: .scheduledTime)
-        }
+        try container.encodeIfPresent(scheduledTime, forKey: .scheduledTime)
+        try container.encodeIfPresent(returnNotification, forKey: .returnNotification)
     }
 
     init(from decoder: Decoder) throws {
@@ -62,6 +81,7 @@ struct PushMessage: Codable, Sendable {
         timestamp = try container.decode(TimeInterval.self, forKey: .timestamp)
         overrideName = try container.decodeIfPresent(String.self, forKey: .overrideName)
         scheduledTime = try container.decodeIfPresent(TimeInterval.self, forKey: .scheduledTime)
+        returnNotification = try container.decodeIfPresent(ReturnNotificationInfo.self, forKey: .returnNotification)
     }
 
     init(
@@ -76,7 +96,8 @@ struct PushMessage: Codable, Sendable {
         sharedSecret: String,
         timestamp: TimeInterval,
         overrideName: String? = nil,
-        scheduledTime: TimeInterval? = nil
+        scheduledTime: TimeInterval? = nil,
+        returnNotification: ReturnNotificationInfo? = nil
     ) {
         self.user = user
         self.commandType = commandType
@@ -90,6 +111,7 @@ struct PushMessage: Codable, Sendable {
         self.timestamp = timestamp
         self.overrideName = overrideName
         self.scheduledTime = scheduledTime
+        self.returnNotification = returnNotification
     }
 
     func humanReadableDescription() -> String {

+ 79 - 0
Trio/Sources/Services/RemoteControl/APNSJWTClaims.swift

@@ -0,0 +1,79 @@
+import Foundation
+import SwiftJWT
+
+struct APNSJWTClaims: Claims {
+    let iss: String
+    let iat: Date
+}
+
+class APNSJWTManager {
+    static let shared = APNSJWTManager()
+
+    private init() {}
+
+    private struct JWTCacheKey: Hashable {
+        let keyId: String
+        let teamId: String
+    }
+
+    private struct CachedJWT {
+        let token: String
+        let expirationDate: Date
+    }
+
+    // Cache multiple JWTs for different LoopFollow instances
+    private var jwtCache: [JWTCacheKey: CachedJWT] = [:]
+    private let cacheQueue = DispatchQueue(label: "com.trio.apnsjwtmanager.cache", attributes: .concurrent)
+
+    func getOrGenerateJWT(keyId: String, teamId: String, apnsKey: String) -> String? {
+        let cacheKey = JWTCacheKey(keyId: keyId, teamId: teamId)
+
+        // Check cache first
+        if let cachedJWT = getCachedJWT(for: cacheKey) {
+            return cachedJWT
+        }
+
+        // Generate new JWT
+        let header = Header(kid: keyId)
+        let claims = APNSJWTClaims(iss: teamId, iat: Date())
+        var jwt = JWT(header: header, claims: claims)
+
+        do {
+            let privateKey = Data(apnsKey.utf8)
+            let jwtSigner = JWTSigner.es256(privateKey: privateKey)
+            let signedJWT = try jwt.sign(using: jwtSigner)
+
+            // Cache the JWT with 55 minute expiration (5 minute buffer before 1 hour)
+            let expirationDate = Date().addingTimeInterval(3300)
+            cacheJWT(signedJWT, for: cacheKey, expirationDate: expirationDate)
+
+            return signedJWT
+        } catch {
+            debug(.remoteControl, "Failed to sign JWT: \(error.localizedDescription)")
+            return nil
+        }
+    }
+
+    private func getCachedJWT(for key: JWTCacheKey) -> String? {
+        cacheQueue.sync {
+            guard let cached = jwtCache[key],
+                  Date() < cached.expirationDate
+            else {
+                return nil
+            }
+            return cached.token
+        }
+    }
+
+    private func cacheJWT(_ token: String, for key: JWTCacheKey, expirationDate: Date) {
+        cacheQueue.async(flags: .barrier) {
+            self.jwtCache[key] = CachedJWT(token: token, expirationDate: expirationDate)
+        }
+    }
+
+    func invalidateCache() {
+        cacheQueue.async(flags: .barrier) {
+            self.jwtCache.removeAll()
+        }
+    }
+}

+ 112 - 0
Trio/Sources/Services/RemoteControl/RemoteNotificationResponseManager.swift

@@ -0,0 +1,112 @@
+import Foundation
+
+class RemoteNotificationResponseManager {
+    static let shared = RemoteNotificationResponseManager()
+
+    private init() {}
+
+    struct NotificationPayload: Encodable {
+        let aps: APSPayload
+        let commandStatus: String
+        let commandType: String
+        let timestamp: TimeInterval
+
+        enum CodingKeys: String, CodingKey {
+            case aps
+            case commandStatus = "command_status"
+            case commandType = "command_type"
+            case timestamp
+        }
+    }
+
+    struct APSPayload: Encodable {
+        let alert: Alert
+        let sound: String = "default"
+        let badge: Int = 1
+    }
+
+    struct Alert: Encodable {
+        let title: String
+        let body: String
+    }
+
+    func sendResponseNotification(
+        to returnInfo: PushMessage.ReturnNotificationInfo?,
+        commandType: TrioRemoteControl.CommandType,
+        success: Bool,
+        message: String
+    ) async {
+        // Don't send notification if no return info provided (old LoopFollow version)
+        guard let returnInfo = returnInfo,
+              !returnInfo.deviceToken.isEmpty
+        else {
+            debug(.remoteControl, "No return notification info provided, skipping response")
+            return
+        }
+
+        let payload = NotificationPayload(
+            aps: APSPayload(
+                alert: Alert(
+                    title: success ? "Command Successful" : "Command Failed",
+                    body: message
+                )
+            ),
+            commandStatus: success ? "success" : "failed",
+            commandType: commandType.rawValue,
+            timestamp: Date().timeIntervalSince1970
+        )
+
+        await sendPushNotification(
+            payload: payload,
+            to: returnInfo.deviceToken,
+            using: returnInfo
+        )
+    }
+
+    private func sendPushNotification(
+        payload: NotificationPayload,
+        to deviceToken: String,
+        using returnInfo: PushMessage.ReturnNotificationInfo
+    ) async {
+        guard let jwt = APNSJWTManager.shared.getOrGenerateJWT(
+            keyId: returnInfo.keyId,
+            teamId: returnInfo.teamId,
+            apnsKey: returnInfo.apnsKey
+        ) else {
+            debug(.remoteControl, "Failed to generate JWT for response notification")
+            return
+        }
+
+        let host = returnInfo.productionEnvironment ? "api.push.apple.com" : "api.sandbox.push.apple.com"
+        guard let url = URL(string: "https://\(host)/3/device/\(deviceToken)") else {
+            debug(.remoteControl, "Failed to construct APNs URL")
+            return
+        }
+
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
+        request.setValue("application/json", forHTTPHeaderField: "content-type")
+        request.setValue("10", forHTTPHeaderField: "apns-priority")
+        request.setValue("0", forHTTPHeaderField: "apns-expiration")
+        request.setValue(returnInfo.bundleId, forHTTPHeaderField: "apns-topic")
+        request.setValue("alert", forHTTPHeaderField: "apns-push-type")
+
+        do {
+            let jsonData = try JSONEncoder().encode(payload)
+            request.httpBody = jsonData
+
+            let (_, response) = try await URLSession.shared.data(for: request)
+
+            if let httpResponse = response as? HTTPURLResponse {
+                if httpResponse.statusCode == 200 {
+                    debug(.remoteControl, "Response notification sent successfully")
+                } else {
+                    debug(.remoteControl, "Failed to send response notification: \(httpResponse.statusCode)")
+                }
+            }
+        } catch {
+            debug(.remoteControl, "Error sending response notification: \(error.localizedDescription)")
+        }
+    }
+}

+ 3 - 3
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift

@@ -50,9 +50,9 @@ extension TrioRemoteControl {
 
         await apsManager.enactBolus(amount: Double(truncating: bolusAmount as NSNumber), isSMB: false, callback: nil)
 
-        debug(
-            .remoteControl,
-            "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
+        await logSuccess(
+            "Remote command processed successfully. \(pushMessage.humanReadableDescription())",
+            pushMessage: pushMessage
         )
     }
 

+ 24 - 0
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Helpers.swift

@@ -5,8 +5,32 @@ extension TrioRemoteControl {
         var note = errorMessage
         if let pushMessage = pushMessage {
             note += " Details: \(pushMessage.humanReadableDescription())"
+
+            // Send error notification back to LoopFollow if return info exists
+            if let returnInfo = pushMessage.returnNotification {
+                await RemoteNotificationResponseManager.shared.sendResponseNotification(
+                    to: returnInfo,
+                    commandType: pushMessage.commandType,
+                    success: false,
+                    message: errorMessage
+                )
+            }
         }
         debug(.remoteControl, note)
         await nightscoutManager.uploadNoteTreatment(note: note)
     }
+
+    func logSuccess(_ message: String, pushMessage: PushMessage) async {
+        debug(.remoteControl, message)
+
+        // Send success notification back to LoopFollow if return info exists
+        if let returnInfo = pushMessage.returnNotification {
+            await RemoteNotificationResponseManager.shared.sendResponseNotification(
+                to: returnInfo,
+                commandType: pushMessage.commandType,
+                success: true,
+                message: "\(pushMessage.commandType.description) completed successfully"
+            )
+        }
+    }
 }

+ 8 - 4
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Meal.swift

@@ -85,9 +85,13 @@ extension TrioRemoteControl {
 
         try await carbsStorage.storeCarbs([mealEntry], areFetchedFromRemote: false)
 
-        debug(
-            .remoteControl,
-            "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
-        )
+        // Only send success notification if there's no bolus
+        // If there's a bolus, the bolus handler will send the notification
+        if pushMessage.bolusAmount == nil {
+            await logSuccess(
+                "Remote command processed successfully. \(pushMessage.humanReadableDescription())",
+                pushMessage: pushMessage
+            )
+        }
     }
 }

+ 8 - 4
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Override.swift

@@ -5,9 +5,9 @@ extension TrioRemoteControl {
     @MainActor internal func handleCancelOverrideCommand(_ pushMessage: PushMessage) async {
         await disableAllActiveOverrides()
 
-        debug(
-            .remoteControl,
-            "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
+        await logSuccess(
+            "Remote command processed successfully. \(pushMessage.humanReadableDescription())",
+            pushMessage: pushMessage
         )
     }
 
@@ -58,10 +58,14 @@ extension TrioRemoteControl {
                 Foundation.NotificationCenter.default.post(name: .willUpdateOverrideConfiguration, object: nil)
                 await awaitNotification(.didUpdateOverrideConfiguration)
 
-                debug(.remoteControl, "Remote command processed successfully. \(pushMessage.humanReadableDescription())")
+                await logSuccess(
+                    "Remote command processed successfully. \(pushMessage.humanReadableDescription())",
+                    pushMessage: pushMessage
+                )
             }
         } catch {
             debug(.remoteControl, "Failed to enact override preset: \(error)")
+            await logError("Failed to enact override preset: \(error.localizedDescription)", pushMessage: pushMessage)
         }
     }
 

+ 6 - 6
Trio/Sources/Services/RemoteControl/TrioRemoteControl+TempTarget.swift

@@ -29,9 +29,9 @@ extension TrioRemoteControl {
         try await tempTargetsStorage.storeTempTarget(tempTarget: tempTarget)
         tempTargetsStorage.saveTempTargetsToStorage([tempTarget])
 
-        debug(
-            .remoteControl,
-            "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
+        await logSuccess(
+            "Remote command processed successfully. \(pushMessage.humanReadableDescription())",
+            pushMessage: pushMessage
         )
     }
 
@@ -40,9 +40,9 @@ extension TrioRemoteControl {
 
         await disableAllActiveTempTargets()
 
-        debug(
-            .remoteControl,
-            "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
+        await logSuccess(
+            "Remote command processed successfully. \(pushMessage.humanReadableDescription())",
+            pushMessage: pushMessage
         )
     }