diff --git a/LibreLoop.xcodeproj/project.pbxproj b/LibreLoop.xcodeproj/project.pbxproj index 0ff0cf6..279930c 100644 --- a/LibreLoop.xcodeproj/project.pbxproj +++ b/LibreLoop.xcodeproj/project.pbxproj @@ -1047,7 +1047,7 @@ repositoryURL = "https://github.com/airedev326/RoundWhiteDiscKit.git"; requirement = { kind = revision; - revision = dccf013622cc6f6fa67751894d5255e09dc3b2a6; + revision = 0e076b2d9278fcd3067ede6d48c33523eda180fb; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d33c678..43f730f 100644 --- a/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LibreLoop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/airedev326/RoundWhiteDiscKit.git", "state" : { - "revision" : "dccf013622cc6f6fa67751894d5255e09dc3b2a6" + "revision" : "0e076b2d9278fcd3067ede6d48c33523eda180fb" } } ], diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift index 0cb6bd1..cf975d2 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift @@ -19,7 +19,14 @@ public final class LibreLoopCGMManager: CGMManager { public var pluginIdentifier: String { Self.pluginIdentifier } public func markAsDepedency(_ isDependency: Bool) {} - public weak var cgmManagerDelegate: CGMManagerDelegate? + public weak var cgmManagerDelegate: CGMManagerDelegate? { + didSet { flushPendingDeviceLog() } + } + + /// First-time pairing runs on a manager Loop hasn't adopted yet, so its + /// device-log lines are held until the delegate arrives. + private let pendingDeviceLogLock = NSLock() + private var pendingDeviceLog: [(identifier: String?, type: DeviceLogEntryType, message: String)] = [] public var delegateQueue: DispatchQueue! public internal(set) var state: LibreLoopCGMManagerState @@ -428,9 +435,14 @@ public final class LibreLoopCGMManager: CGMManager { } public var debugDescription: String { - """ + let keys = state.sensorSerial.flatMap { try? LibreLoopKeychain.load(forSensorSerial: $0) } + let pairing = keys.map { $0.appIdentity.map { "app identity \($0)" } ?? "whitebox" } ?? "unknown" + return """ ## LibreLoopCGMManager * sensorSerial: \(state.sensorSerial ?? "nil") + * pairedWith: \(pairing) + * appIdentitiesInstalled: \(AppIdentities.isInstalled) + * appIdentityPairingEnabled: \(LibreLoopDebugSettings.appIdentityPairingEnabled) * activatedAt: \(String(describing: state.activatedAt)) * latestReadingTimestamp: \(String(describing: state.latestReadingTimestamp)) """ @@ -647,7 +659,7 @@ public final class LibreLoopCGMManager: CGMManager { guard let self else { return } let lower = message.lowercased() let type: DeviceLogEntryType - if lower.contains("error") || lower.contains("fail") { + if lower.contains("error") || lower.contains("fail") || lower.contains("reject") { type = .error } else if lower.contains("connect") || lower.contains("disconnect") { type = .connection @@ -655,18 +667,42 @@ public final class LibreLoopCGMManager: CGMManager { type = .receive } let identifier = self.state.sensorSerial - let forward = { [weak self] in - guard let self else { return } - self.cgmManagerDelegate?.deviceManager(self, logEventForDeviceIdentifier: identifier, type: type, message: message, completion: nil) + let held: Bool = self.pendingDeviceLogLock.withLock { + guard self.cgmManagerDelegate == nil else { return false } + if self.pendingDeviceLog.count < 1000 { + self.pendingDeviceLog.append((identifier, type, message)) + } + return true } - if let queue = self.delegateQueue { - queue.async(execute: forward) - } else { - forward() + if !held { + self.forwardDeviceLog(identifier: identifier, type: type, message: message) } } } + private func flushPendingDeviceLog() { + guard cgmManagerDelegate != nil else { return } + let pending = pendingDeviceLogLock.withLock { + defer { pendingDeviceLog.removeAll() } + return pendingDeviceLog + } + for entry in pending { + forwardDeviceLog(identifier: entry.identifier, type: entry.type, message: entry.message) + } + } + + private func forwardDeviceLog(identifier: String?, type: DeviceLogEntryType, message: String) { + let forward = { [weak self] in + guard let self else { return } + self.cgmManagerDelegate?.deviceManager(self, logEventForDeviceIdentifier: identifier, type: type, message: message, completion: nil) + } + if let queue = delegateQueue { + queue.async(execute: forward) + } else { + forward() + } + } + private func observeAppLifecycle() { let nc = NotificationCenter.default let names: [(Notification.Name, String)] = [ diff --git a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift index a93cea1..149b07c 100644 --- a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift +++ b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift @@ -106,7 +106,8 @@ extension LibreLoopCGMManager { kEnc: outcome.result.kEnc, ivEnc: outcome.result.ivEnc, phase5RawKey: outcome.result.phase5RawKey, - receiverID: Self.receiverIDFromState(state.receiverID) + receiverID: Self.receiverIDFromState(state.receiverID), + appIdentity: outcome.result.appIdentity ), forSensorSerial: outcome.result.sensorSerial ) @@ -994,7 +995,8 @@ extension LibreLoopCGMManager { } // Pull whatever we previously persisted, but reconnect can still run // if Keychain has nothing — it'll just use the full handshake path. - let cachedPhase5 = (try? LibreLoopKeychain.load(forSensorSerial: serial))?.phase5RawKey + let cachedKeys = try? LibreLoopKeychain.load(forSensorSerial: serial) + let cachedPhase5 = cachedKeys?.phase5RawKey if cachedPhase5 != nil { llog("reconnect: cached phase5RawKey available; will try fast path first") } else { @@ -1005,6 +1007,7 @@ extension LibreLoopCGMManager { scanner: scanner, blePIN: blePIN, phase5RawKey: cachedPhase5, + appIdentity: cachedKeys?.appIdentity, expectedPeripheralID: expectedPeripheral ) { [weak self] stage in llog("reconnect stage: \(String(describing: stage))") @@ -1018,7 +1021,8 @@ extension LibreLoopCGMManager { kEnc: outcome.kEnc, ivEnc: outcome.ivEnc, phase5RawKey: phase5ToPersist, - receiverID: Self.receiverIDFromState(state.receiverID) + receiverID: Self.receiverIDFromState(state.receiverID), + appIdentity: outcome.phase5RawKey == nil ? cachedKeys?.appIdentity : nil ), forSensorSerial: serial ) diff --git a/LibreLoop/Pairing/LibreLoopKeychain.swift b/LibreLoop/Pairing/LibreLoopKeychain.swift index fa5420c..0a18c96 100644 --- a/LibreLoop/Pairing/LibreLoopKeychain.swift +++ b/LibreLoop/Pairing/LibreLoopKeychain.swift @@ -17,10 +17,14 @@ import Security /// is what the sensor remembers as its current /// receiver; needed to issue switchReceiver after /// a CGMManager rawState wipe. +/// v4: v3 with `0x04` magic, followed by the UTF-8 label of +/// the `AppIdentity` that paired. phase5RawKey is then +/// a standard-AES kAuth rather than a whitebox key. enum LibreLoopKeychain { private static let service = "org.loopkit.LibreLoop.sessionKeys" private static let v2Magic: UInt8 = 0x02 private static let v3Magic: UInt8 = 0x03 + private static let v4Magic: UInt8 = 0x04 struct SessionKeys: Equatable { let kEnc: Data @@ -34,6 +38,7 @@ enum LibreLoopKeychain { /// after a rawState wipe by issuing switchReceiver with the stored /// ID. Nil for sensors paired before this field was persisted. let receiverID: UInt32? + var appIdentity: String? = nil } static func save(_ keys: SessionKeys, forSensorSerial serial: String) throws { @@ -45,7 +50,11 @@ enum LibreLoopKeychain { ridLE[1] = UInt8(truncatingIfNeeded: receiverID >> 8) ridLE[2] = UInt8(truncatingIfNeeded: receiverID >> 16) ridLE[3] = UInt8(truncatingIfNeeded: receiverID >> 24) - payload = Data([v3Magic]) + keys.kEnc + keys.ivEnc + phase5RawKey + ridLE + if let label = keys.appIdentity { + payload = Data([v4Magic]) + keys.kEnc + keys.ivEnc + phase5RawKey + ridLE + Data(label.utf8) + } else { + payload = Data([v3Magic]) + keys.kEnc + keys.ivEnc + phase5RawKey + ridLE + } } else { payload = Data([v2Magic]) + keys.kEnc + keys.ivEnc + phase5RawKey } @@ -83,7 +92,7 @@ enum LibreLoopKeychain { guard status == errSecSuccess, let data = item as? Data else { throw LibreLoopKeychainError.osStatus(status) } - if data.count == 45 && data.first == v3Magic { + if (data.count == 45 && data.first == v3Magic) || (data.count > 45 && data.first == v4Magic) { // v3: 0x03 || kEnc(16) || ivEnc(8) || phase5RawKey(16) || receiverID(4 LE) let kEnc = data.subdata(in: 1..<17) let ivEnc = data.subdata(in: 17..<25) @@ -93,7 +102,8 @@ enum LibreLoopKeychain { | (UInt32(ridLE[1]) << 8) | (UInt32(ridLE[2]) << 16) | (UInt32(ridLE[3]) << 24) - return SessionKeys(kEnc: kEnc, ivEnc: ivEnc, phase5RawKey: phase5RawKey, receiverID: receiverID) + let label = data.first == v4Magic ? String(decoding: data.suffix(from: data.startIndex + 45), as: UTF8.self) : nil + return SessionKeys(kEnc: kEnc, ivEnc: ivEnc, phase5RawKey: phase5RawKey, receiverID: receiverID, appIdentity: label) } if data.count == 41 && data.first == v2Magic { // v2: 0x02 || kEnc(16) || ivEnc(8) || phase5RawKey(16) diff --git a/LibreLoop/Pairing/LibreLoopPairingService.swift b/LibreLoop/Pairing/LibreLoopPairingService.swift index 56c62e1..8c64026 100644 --- a/LibreLoop/Pairing/LibreLoopPairingService.swift +++ b/LibreLoop/Pairing/LibreLoopPairingService.swift @@ -25,6 +25,9 @@ public final class LibreLoopPairingService { /// callers must persist this alongside kEnc/ivEnc. Nil only on /// legacy/cached reconnect outcomes where the value isn't re-derived. public let phase5RawKey: Data? + /// Label of the `AppIdentity` that paired, making `phase5RawKey` a + /// standard-AES kAuth. Nil when the whitebox path paired. + public let appIdentity: String? } /// Intermediate state captured immediately after a successful NFC @@ -110,6 +113,7 @@ public final class LibreLoopPairingService { scanner: SensorScannerNG, blePIN: Data, phase5RawKey: Data? = nil, + appIdentity: String? = nil, expectedPeripheralID: UUID? = nil, scanTimeout: TimeInterval = 120, onStage: @Sendable @escaping (Stage) -> Void = { _ in } @@ -215,10 +219,12 @@ public final class LibreLoopPairingService { // persisted), whose only option it is. if let phase5RawKey { let cachedFlow = PairingFlow(transport: transport, eventLogger: { message in llog(message) }) + llog("reconnect: cached key from \(appIdentity.map { "app identity \($0), standard AES" } ?? "whitebox")") let cached = try await cachedFlow.runCachedReconnectHandshake( tail4: blePIN, phase5RawKey: phase5RawKey, - r2Provider: { try Self.secureRandomBytes(count: 16) } + r2Provider: { try Self.secureRandomBytes(count: 16) }, + phase5Cipher: appIdentity == nil ? LibAES.phase5BlockEncryptor : AppIdentity.phase5Cipher ) let material = cached.sessionMaterial let monitor = try LibreLoopSensorMonitor.make( @@ -396,15 +402,67 @@ public final class LibreLoopPairingService { let sensor = try await Self.scanForAnyPeripheralNG(scanner: scanner, timeout: 120) onStage(.bleConnecting) - let session: SensorSession - do { - session = try await Self.connectAndBuildSession( - scanner: scanner, - peripheral: sensor.peripheral, - timeout: 120 - ) - } catch { - throw Failure.underlying("BLE connection failed: \(error.localizedDescription)") + func connect() async throws -> SensorSession { + do { + return try await Self.connectAndBuildSession( + scanner: scanner, + peripheral: sensor.peripheral, + timeout: 120 + ) + } catch { + throw Failure.underlying("BLE connection failed: \(error.localizedDescription)") + } + } + var session = try await connect() + + // 3a. Handshake with a plain-key app identity, chosen from the NFC + // patch info. Each identity is tried in turn, then the whitebox below. + if LibreLoopDebugSettings.appIdentityPairingEnabled { + onStage(.handshaking) + let patchInfo = scanResult.patchInfo + let attempts = await Self.appIdentityAttempts(for: patchInfo) + for (index, identity) in attempts.enumerated() { + if index > 0 { + scanner.cancelConnection(sensor.peripheral) + session = try await connect() + } + do { + let (handshake, authKey) = try await Self.appIdentityHandshake(identity, session: session, blePIN: activation.blePIN) + if index > 0 { + llog("app identity: GUESS WRONG — sensor securityVersion=\(patchInfo.securityVersion) region=\(patchInfo.region) paired with \(identity.label), not \(attempts[0].label)") + } + llog("app identity: paired with \(identity.label)") + let material = handshake.sessionMaterial + let result = Result( + receiverID: receiverID, + sensorSerial: patchInfo.serialNumber, + bleAddress: activation.bleAddressDisplay, + blePIN: activation.blePIN, + activatedAt: nfcResponse.activatedAt ?? Date(), + kEnc: material.kEnc, + ivEnc: material.ivEnc, + phase5RawKey: authKey, + appIdentity: identity.label + ) + let monitor = try LibreLoopSensorMonitor.make( + scanner: scanner, + session: session, + kEnc: material.kEnc, + ivEnc: material.ivEnc + ) + return PairOutcome(result: result, monitor: monitor, peripheralID: sensor.id) + } catch PairingFlowError.writeTimeout(label: "SendCertificateLoadDone", _) { + // The sensor drops the link on a certificate it won't accept. + llog("app identity: \(identity.label) certificate rejected — sensor disconnected after receiving it") + } catch { + llog("app identity: \(identity.label) rejected — \(error)") + } + } + if !attempts.isEmpty { + llog("app identity: no identity accepted for securityVersion=\(patchInfo.securityVersion) region=\(patchInfo.region); falling back to whitebox") + scanner.cancelConnection(sensor.peripheral) + session = try await connect() + } } // 3. Handshake -- candidate path with phone_cert_162b (03 03 family). @@ -456,7 +514,8 @@ public final class LibreLoopPairingService { activatedAt: nfcResponse.activatedAt ?? Date(), kEnc: material.kEnc, ivEnc: material.ivEnc, - phase5RawKey: handshake.phase5Material.rawKey + phase5RawKey: handshake.phase5Material.rawKey, + appIdentity: nil ) let monitor = try LibreLoopSensorMonitor.make( scanner: scanner, @@ -726,6 +785,51 @@ public final class LibreLoopPairingService { } /// The 03 03 first-pair cert, from the installed runtime tables. + /// The selected identity first, then the rest; empty if none load. + private static func appIdentityAttempts(for patchInfo: Libre3NFCPatchInfo) async -> [AppIdentity] { + do { + try await LibreLoopAppIdentities.ensureInstalled() + let identities = try AppIdentities.installed() + let selection = try AppIdentities.select(from: identities, for: patchInfo) + let how = selection.matched ? "matched" : "NO MATCH, using default" + let raw = patchInfo.raw.map { String(format: "%02x", $0) }.joined() + llog("app identity: productType=\(patchInfo.productType) securityVersion=\(patchInfo.securityVersion) region=\(patchInfo.region) -> \(selection.identity.label) (\(how)) patchInfo=\(raw)") + return [selection.identity] + identities.filter { $0.label != selection.identity.label } + } catch { + llog("app identity: unavailable (\(error)); whitebox only") + return [] + } + } + + private static func appIdentityHandshake( + _ identity: AppIdentity, + session: SensorSession, + blePIN: Data + ) async throws -> (FirstPairHandshakeResult, authKey: Data) { + let phoneEph = EphemeralKeyPair() + let flow = PairingFlow( + transport: SensorSessionTransport(session: session), + phoneCert: identity.certificate, + phoneEph: phoneEph, + eventLogger: { message in llog(message) } + ) + var authKey = Data() + let handshake = try await flow.runCommandGatedFirstPairHandshake( + blePIN: blePIN, + phase5RawKeyProvider: { preamble in + authKey = try identity.authKey( + phoneEphemeral: phoneEph.privateKey, + sensorEphemeral: preamble.phaseHandshake.sensorEphPub, + sensorStatic: EphemeralExchange.parsePeerPubkey(preamble.phaseHandshake.sensorCert.staticPub) + ) + return authKey + }, + r2Provider: { try secureRandomBytes(count: 16) }, + phase5Cipher: AppIdentity.phase5Cipher + ) + return (handshake, authKey) + } + private static func loadFirstPairCert() throws -> PhoneCert { try PhoneCert.bundled162b() } diff --git a/LibreLoop/Pairing/LibreLoopRuntimeTables.swift b/LibreLoop/Pairing/LibreLoopRuntimeTables.swift index 0af1a82..d237d64 100644 --- a/LibreLoop/Pairing/LibreLoopRuntimeTables.swift +++ b/LibreLoop/Pairing/LibreLoopRuntimeTables.swift @@ -5,22 +5,64 @@ import RoundWhiteDiscKit /// published on Arweave. It is downloaded once, kept in Application Support, /// and installed before any pairing or authorization. public enum LibreLoopRuntimeTables { - static let transactionID = "8ran9sd_2k7dvzVPPRGs3sVq5mgP74UZ4A0V-mr2ag0" + static let blob = ArweaveBlob( + name: "runtime tables", + transactionID: "8ran9sd_2k7dvzVPPRGs3sVq5mgP74UZ4A0V-mr2ag0", + fileName: "roundwhitedisckit-runtime-tables-v2.xz", + isInstalled: { RoundWhiteDiscKit.runtimeTablesInstalled }, + install: RoundWhiteDiscKit.installRuntimeTables + ) - /// Any gateway is safe: the package rejects a blob whose digest doesn't match. - static let gateways = ["https://arweave.net", "https://turbo-gateway.com"] + static var isInstalled: Bool { blob.isInstalled() } - static var isInstalled: Bool { - RoundWhiteDiscKit.runtimeTablesInstalled + public static func ensureInstalled() async throws { + try await blob.ensureInstalled() } +} - private static let lock = NSLock() - private static var inFlight: Task? +/// The plain-key app identities used to pair without the whitebox, published +/// the same way as the runtime tables. +public enum LibreLoopAppIdentities { + static let blob = ArweaveBlob( + name: "app identities", + transactionID: "9XxAxx8xSsr8uPNYPCycBjNSzVZFVpgxJsCoAo4OW1Y", + fileName: "roundwhitedisckit-app-identities-v1.xz", + isInstalled: { AppIdentities.isInstalled }, + install: AppIdentities.install + ) - /// Installs the saved blob, downloading and saving it first if there isn't - /// one. Concurrent callers share a single attempt. public static func ensureInstalled() async throws { - if isInstalled { return } + try await blob.ensureInstalled() + } +} + +/// A blob on Arweave that RoundWhiteDiscKit verifies against a pinned digest. +/// Downloaded once and kept in Application Support; concurrent callers share a +/// single attempt. +final class ArweaveBlob: @unchecked Sendable { + /// Any gateway is safe: the package rejects a blob whose digest doesn't match. + static let gateways = ["https://arweave.net", "https://turbo-gateway.com"] + + let name: String + let transactionID: String + let fileName: String + let isInstalled: () -> Bool + private let installBlob: (Data) throws -> Void + + private let lock = NSLock() + private var inFlight: Task? + + init(name: String, transactionID: String, fileName: String, + isInstalled: @escaping () -> Bool, install: @escaping (Data) throws -> Void) { + self.name = name + self.transactionID = transactionID + self.fileName = fileName + self.isInstalled = isInstalled + self.installBlob = install + } + + func ensureInstalled() async throws { + if isInstalled() { return } let task: Task = lock.withLock { if let inFlight { return inFlight } let task = Task { @@ -33,46 +75,51 @@ public enum LibreLoopRuntimeTables { try await task.value } - private static func install() async throws { + private func install() async throws { if let saved = try? Data(contentsOf: fileURL) { do { - try RoundWhiteDiscKit.installRuntimeTables(saved) + try installBlob(saved) + llog("\(name): installed saved blob (\(saved.count) bytes)") return } catch { - llog("runtime tables: saved blob rejected (\(error)); downloading again") + llog("\(name): saved blob rejected (\(error)); downloading again") } } + guard !transactionID.isEmpty else { + llog("\(name): no Arweave transaction configured") + throw URLError(.badURL) + } var lastError: Error = URLError(.badURL) - for gateway in gateways { + for gateway in Self.gateways { guard let url = URL(string: "\(gateway)/\(transactionID)") else { continue } do { let data = try await download(url) try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: fileURL, options: .atomic) - llog("runtime tables: installed \(data.count) bytes from \(gateway)") + llog("\(name): installed \(data.count) bytes from \(gateway)") return } catch { - llog("runtime tables: \(gateway) failed: \(error)") + llog("\(name): \(gateway) failed: \(error)") lastError = error } } throw lastError } - private static func download(_ url: URL) async throws -> Data { + private func download(_ url: URL) async throws -> Data { let (data, response) = try await URLSession.shared.data(from: url) if let http = response as? HTTPURLResponse, http.statusCode != 200 { throw URLError(.badServerResponse) } // Verifies the payload against the digest pinned in RoundWhiteDiscKit. - try RoundWhiteDiscKit.installRuntimeTables(data) + try installBlob(data) return data } - private static var fileURL: URL { + private var fileURL: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("LibreLoop", isDirectory: true) - .appendingPathComponent("roundwhitedisckit-runtime-tables-v2.xz") + .appendingPathComponent(fileName) } } diff --git a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift index fc3706a..353022f 100644 --- a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift +++ b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift @@ -16,6 +16,12 @@ public enum LibreLoopDebugSettings { get { UserDefaults.standard.bool(forKey: continuousClinicalKey) } set { UserDefaults.standard.set(newValue, forKey: continuousClinicalKey) } } + + /// Pair with a plain-key `AppIdentity` before falling back to the whitebox. + public static let appIdentityPairingKey = "org.loopkit.LibreLoop.appIdentityPairing" + public static var appIdentityPairingEnabled: Bool { + UserDefaults.standard.object(forKey: appIdentityPairingKey) as? Bool ?? true + } } diff --git a/LibreLoopUI/Views/LibreLoopStreamDebugView.swift b/LibreLoopUI/Views/LibreLoopStreamDebugView.swift index 8b922d7..4ff5839 100644 --- a/LibreLoopUI/Views/LibreLoopStreamDebugView.swift +++ b/LibreLoopUI/Views/LibreLoopStreamDebugView.swift @@ -18,6 +18,7 @@ import LibreLoop struct LibreLoopStreamDebugView: View { @StateObject private var viewModel: LibreLoopStreamDebugViewModel @AppStorage(LibreLoopDebugSettings.continuousClinicalKey) private var continuousClinical = false + @AppStorage(LibreLoopDebugSettings.appIdentityPairingKey) private var appIdentityPairing = true init(viewModel: LibreLoopStreamDebugViewModel) { _viewModel = StateObject(wrappedValue: viewModel) @@ -37,6 +38,12 @@ struct LibreLoopStreamDebugView: View { Text("Keeps the clinical channel subscribed so the clinical/raw charts update every minute. Off by default — only needed for stream inspection, and it adds per-reconnect clinical traffic. Takes effect on the next reconnect.") } + Section { + Toggle("Pair with app identity", isOn: $appIdentityPairing) + } footer: { + Text("Tries the plain-key app identities before the whitebox when pairing. Takes effect on the next sensor scan.") + } + glucoseSection rawSection noiseSection