From 0c6ffd87926852ba07a5aba6afa80763467fba34 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 30 Oct 2026 18:07:43 -0500 Subject: [PATCH] Pair with libre3sim in the iOS Simulator The Simulator has no NFC and no Libre 3. In Simulator builds only, pairing talks to libre3sim: its HTTP API stands in for the sensor's NFC tag and supplies the app credential and the key that signs its sensors' certificates, and Bluetooth reaches it through ImpossiBLE. Device builds are unchanged. --- LibreLoop.xcodeproj/project.pbxproj | 4 + .../Pairing/LibreLoopPairingService.swift | 10 ++ .../Pairing/LibreLoopRuntimeTables.swift | 5 + LibreLoop/Pairing/LibreLoopSimulator.swift | 119 ++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 LibreLoop/Pairing/LibreLoopSimulator.swift diff --git a/LibreLoop.xcodeproj/project.pbxproj b/LibreLoop.xcodeproj/project.pbxproj index fe73f70..3b53507 100644 --- a/LibreLoop.xcodeproj/project.pbxproj +++ b/LibreLoop.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ A2BF7E31EE7A24E1130871FE /* LibreLoop.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B556C4EB4504CA4CFCF51EA2 /* LibreLoop.framework */; }; AE16E0F3A62344D974DF129C /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2509954985674E6150F6C160 /* LoopKit.framework */; }; A1B2C3D4E5F6A7B8C9D0E102 /* LibreLoopRuntimeTables.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E101 /* LibreLoopRuntimeTables.swift */; }; + 5A1B3C5D7E9F0A2B4C6D8E02 /* LibreLoopSimulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1B3C5D7E9F0A2B4C6D8E01 /* LibreLoopSimulator.swift */; }; B04E3C5ED32E80EEC213D178 /* LibreLoopPairingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693CAA1DD7F8B8662B3ED42B /* LibreLoopPairingService.swift */; }; B793A348DB4300FC386AF780 /* LibreLoopCGMManagerStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9D5D87695F004FCFDA5075 /* LibreLoopCGMManagerStateTests.swift */; }; B87069E2100DD9DD3B3B897D /* LibreLoopPairingProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 659E4212E046544D5827E2D1 /* LibreLoopPairingProgressView.swift */; }; @@ -127,6 +128,7 @@ 651E3C856170F6BD369EA0DD /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; path = README.md; sourceTree = ""; }; 659E4212E046544D5827E2D1 /* LibreLoopPairingProgressView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibreLoopPairingProgressView.swift; sourceTree = ""; }; A1B2C3D4E5F6A7B8C9D0E101 /* LibreLoopRuntimeTables.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibreLoopRuntimeTables.swift; sourceTree = ""; }; + 5A1B3C5D7E9F0A2B4C6D8E01 /* LibreLoopSimulator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibreLoopSimulator.swift; sourceTree = ""; }; 693CAA1DD7F8B8662B3ED42B /* LibreLoopPairingService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibreLoopPairingService.swift; sourceTree = ""; }; 6A64C620E258CF55CE0B0E11 /* LibreLoopCGMManager+Observers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "LibreLoopCGMManager+Observers.swift"; sourceTree = ""; }; 6D0763A875B13A9543390BC5 /* LibreLoopSensorLifecycle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibreLoopSensorLifecycle.swift; sourceTree = ""; }; @@ -366,6 +368,7 @@ D912C6E4A9CF07D06EEF98BA /* LibreLoopKeychain.swift */, 693CAA1DD7F8B8662B3ED42B /* LibreLoopPairingService.swift */, A1B2C3D4E5F6A7B8C9D0E101 /* LibreLoopRuntimeTables.swift */, + 5A1B3C5D7E9F0A2B4C6D8E01 /* LibreLoopSimulator.swift */, ); name = Pairing; path = Pairing; @@ -604,6 +607,7 @@ 93AC995063491BFA4B6D22C3 /* LibreLoopKeychain.swift in Sources */, B04E3C5ED32E80EEC213D178 /* LibreLoopPairingService.swift in Sources */, A1B2C3D4E5F6A7B8C9D0E102 /* LibreLoopRuntimeTables.swift in Sources */, + 5A1B3C5D7E9F0A2B4C6D8E02 /* LibreLoopSimulator.swift in Sources */, C558A41FBB49E4CFE8C0CD6B /* LibreLoopGlucoseSample.swift in Sources */, 70BCAB4E71383256521B6B16 /* LibreLoopSensorLifecycle.swift in Sources */, AAEC520180AAB1FC572D3EFE /* LibreLoopExpiryAlerts.swift in Sources */, diff --git a/LibreLoop/Pairing/LibreLoopPairingService.swift b/LibreLoop/Pairing/LibreLoopPairingService.swift index 18f5198..7643573 100644 --- a/LibreLoop/Pairing/LibreLoopPairingService.swift +++ b/LibreLoop/Pairing/LibreLoopPairingService.swift @@ -313,7 +313,11 @@ public final class LibreLoopPairingService { // matches what the sensor remembers; pass-through here, the sensor // validates server-side. onStage(.nfcScanning) + #if targetEnvironment(simulator) + let nfcReader = LibreLoopSimulatedNFCReader() + #else let nfcReader = Libre3NFCActivationReader() + #endif let scanResult: Libre3NFCScanResult let receiverID: UInt32 do { @@ -719,10 +723,16 @@ public final class LibreLoopPairingService { blePIN: Data ) async throws -> (FirstPairHandshakeResult, authKey: Data) { let phoneEph = EphemeralKeyPair() + #if targetEnvironment(simulator) + let signingKeys = try await LibreLoopSimulator.patchSigningKeys() + #else + let signingKeys = Libre3PatchSigningKey.known + #endif let flow = PairingFlow( transport: SensorSessionTransport(session: session), phoneCert: identity.certificate, phoneEph: phoneEph, + sensorCertSigningKeys: signingKeys, eventLogger: { message in llog(message) } ) var authKey = Data() diff --git a/LibreLoop/Pairing/LibreLoopRuntimeTables.swift b/LibreLoop/Pairing/LibreLoopRuntimeTables.swift index 8a71985..1ab01fa 100644 --- a/LibreLoop/Pairing/LibreLoopRuntimeTables.swift +++ b/LibreLoop/Pairing/LibreLoopRuntimeTables.swift @@ -30,6 +30,10 @@ public enum LibreLoopAppIdentities { } private static func install() async throws { + #if targetEnvironment(simulator) + try AppIdentities.install(json: await LibreLoopSimulator.appCredentials()) + llog("app credentials: installed libre3sim's") + #else if let saved = try? Data(contentsOf: fileURL) { do { try installBlob(saved) @@ -59,6 +63,7 @@ public enum LibreLoopAppIdentities { } } throw lastError + #endif } private static func installBlob(_ blob: Data) throws { diff --git a/LibreLoop/Pairing/LibreLoopSimulator.swift b/LibreLoop/Pairing/LibreLoopSimulator.swift new file mode 100644 index 0000000..77f9d3c --- /dev/null +++ b/LibreLoop/Pairing/LibreLoopSimulator.swift @@ -0,0 +1,119 @@ +#if targetEnvironment(simulator) +import Foundation +import RoundWhiteDiscKit + +/// The iOS Simulator has no NFC and no Libre 3, so pairing there talks to +/// libre3sim (https://github.com/LoopKit/libre3sim), which reaches the app's +/// Bluetooth through ImpossiBLE. Its HTTP API stands in for the sensor's NFC +/// tag and supplies the app credential and the key that signs its sensors' +/// certificates. Set LIBRE3SIM_URL to reach it somewhere other than port 8080. +enum LibreLoopSimulator { + struct Failure: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + static var baseURL: URL { + URL(string: ProcessInfo.processInfo.environment["LIBRE3SIM_URL"] ?? "http://127.0.0.1:8080")! + } + + static func appCredentials() async throws -> Data { + try await get("app-credentials") + } + + static func patchSigningKeys() async throws -> [Data] { + struct Key: Decodable { let publicKey: String } + let key = try JSONDecoder().decode(Key.self, from: try await get("patch-signing-key")) + guard let data = Data(simulatorHex: key.publicKey) else { + throw Failure(message: "libre3sim sent a malformed patch-signing key") + } + return [data] + } + + /// An ISO 15693 custom command, answered as CoreNFC would return it. + static func nfc(code: UInt8, parameters: Data) async throws -> Data { + struct Reply: Decodable { + let response: String? + let error: String? + } + var request = URLRequest(url: baseURL.appendingPathComponent("nfc")) + request.httpMethod = "POST" + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "code": Int(code), + "parameters": parameters.map { String(format: "%02x", $0) }.joined(), + ]) + let reply = try JSONDecoder().decode(Reply.self, from: try await load(request)) + guard let hex = reply.response, let data = Data(simulatorHex: hex) else { + throw Failure(message: reply.error ?? "libre3sim gave no NFC response") + } + return data + } + + private static func get(_ path: String) async throws -> Data { + try await load(URLRequest(url: baseURL.appendingPathComponent(path))) + } + + private static func load(_ request: URLRequest) async throws -> Data { + do { + let (data, _) = try await URLSession.shared.data(for: request) + return data + } catch { + throw Failure(message: "libre3sim is not running at \(baseURL.absoluteString): \(error.localizedDescription)") + } + } +} + +/// Libre3NFCActivationReader's scan, with libre3sim as the tag. +final class LibreLoopSimulatedNFCReader { + func scan(mode: Libre3NFCScanMode) async throws -> Libre3NFCScanResult { + let patchInfo = try Libre3NFCPatchInfo(raw: await LibreLoopSimulator.nfc(code: 0xa1, parameters: Data())) + let commandCode: NFCActivationCommandCode + let receiverID: UInt32 + let explicitTime: UInt32? + switch mode { + case .readPatchInfo: + return Libre3NFCScanResult(patchInfo: patchInfo) + case .activateFreshSensor(let id, let time): + guard patchInfo.recommendedCommandCode == .activate else { + throw Libre3NFCError.unexpectedSensorState(patchInfo: patchInfo) + } + (commandCode, receiverID, explicitTime) = (.activate, id, time) + case .switchReceiver(let id, let time): + guard patchInfo.recommendedCommandCode == .switchReceiver else { + throw Libre3NFCError.unexpectedSensorState(patchInfo: patchInfo) + } + (commandCode, receiverID, explicitTime) = (.switchReceiver, id, time) + case .activateOrSwitchReceiver(let id, let time): + (commandCode, receiverID, explicitTime) = (patchInfo.recommendedCommandCode, id, time) + case .forceActivationCommand(let code, let id, let time): + (commandCode, receiverID, explicitTime) = (code, id, time) + } + let parameters = NFCActivationCommand.customRequestParameters( + timeSeconds: explicitTime ?? UInt32(Date().timeIntervalSince1970) - 1, + receiverID: receiverID + ) + let raw = try await LibreLoopSimulator.nfc(code: commandCode.rawValue, parameters: parameters) + return Libre3NFCScanResult( + patchInfo: patchInfo, + commandCode: commandCode, + commandParameters: parameters, + activationResponse: try Libre3NFCActivationResponse(raw: raw) + ) + } +} + +private extension Data { + init?(simulatorHex hex: String) { + guard hex.count % 2 == 0 else { return nil } + var bytes = [UInt8]() + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index..