From bdd6d6c8d3cc0994fa84dacc0d509b71970f3e02 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Mon, 13 Apr 2026 23:05:08 -0400 Subject: [PATCH 01/27] add O5RegistrationData.fromJSON --- .../Bluetooth/Pair/O5RegistrationData.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift b/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift index 6b6e143..bb69f95 100644 --- a/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift +++ b/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift @@ -51,6 +51,23 @@ struct O5RegistrationData { return _registry.isEmpty } + /// Parse an O5RegistrationData from a JSON dictionary (e.g. from an .o5keypair file or API response). + static func fromJSON(_ json: [String: Any]) -> O5RegistrationData? { + guard let controllerId = (json["controllerId"] as? NSNumber)?.uint32Value, + let privateKeyHex = json["privateKey"] as? String, + let publicKeyHex = json["publicKey"] as? String, + let intermediateCABase64 = json["intermediateCA"] as? String, + let tlsCertificateBase64 = json["tlsCertificate"] as? String + else { return nil } + return O5RegistrationData( + controllerId: controllerId, + privateKeyHex: privateKeyHex, + publicKeyHex: publicKeyHex, + intermediateCABase64: intermediateCABase64, + tlsCertificateBase64: tlsCertificateBase64 + ) + } + // MARK: - Identity /// Becomes the 4-byte controller ID. From 4f51aa5820403520f1da66672de680481710d82e Mon Sep 17 00:00:00 2001 From: James Woglom Date: Mon, 13 Apr 2026 23:05:32 -0400 Subject: [PATCH 02/27] app attest v1 --- .../PumpManagerUI/O5AppAttestService.swift | 208 ++++++++++++++++++ .../PumpManagerUI/Views/O5KeyFetchView.swift | 68 ++++++ .../PumpManagerUI/Views/O5KeySetupView.swift | 127 +++++++++++ 3 files changed, 403 insertions(+) create mode 100644 OmnipodKit/PumpManagerUI/O5AppAttestService.swift create mode 100644 OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift create mode 100644 OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift diff --git a/OmnipodKit/PumpManagerUI/O5AppAttestService.swift b/OmnipodKit/PumpManagerUI/O5AppAttestService.swift new file mode 100644 index 0000000..503c302 --- /dev/null +++ b/OmnipodKit/PumpManagerUI/O5AppAttestService.swift @@ -0,0 +1,208 @@ +// +// O5AppAttestService.swift +// OmnipodKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import CryptoKit +import DeviceCheck + +let o5KeyManagerBaseURL = "https://api.osaid-keymanager.org" + +struct O5AuthError: Error { + let message: String + let httpStatusCode: Int? + let underlyingError: Error? + + init(message: String, httpStatusCode: Int? = nil, underlyingError: Error? = nil) { + self.message = message + self.httpStatusCode = httpStatusCode + self.underlyingError = underlyingError + } +} + +class O5AppAttestService { + + private let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 30 + return URLSession(configuration: config) + }() + + /// Runs the full App Attest + keypair fetch flow. + /// Calls completion on the main queue. + func fetchKeypair(completion: @escaping (Result) -> Void) { + Task { + do { + let result = try await performFetchFlow() + DispatchQueue.main.async { completion(.success(result)) } + } catch let error as O5AuthError { + DispatchQueue.main.async { completion(.failure(error)) } + } catch { + DispatchQueue.main.async { + completion(.failure(O5AuthError(message: error.localizedDescription, underlyingError: error))) + } + } + } + } + + // MARK: - Async flow + + private func performFetchFlow() async throws -> O5RegistrationData { + let attestService = DCAppAttestService.shared + + guard attestService.isSupported else { + throw O5AuthError(message: "App Attest is not supported on this device.") + } + + // Step 1: Generate a fresh App Attest key + let keyId = try await generateKey(attestService) + + // Step 2: Get challenge from server + let challenge = try await getChallenge() + + // Step 3: Attest the key with Apple + let challengeHash = Data(SHA256.hash(data: Data(challenge.utf8))) + let attestation = try await attestKey(attestService, keyId: keyId, clientDataHash: challengeHash) + + // Step 4: Exchange attestation for a keypair in a single request. + let appId = try getAppId() + return try await claimKeypair( + attestation: attestation, + keyId: keyId, + challenge: challenge, + appId: appId + ) + } + + // MARK: - App Attest + + private func generateKey(_ service: DCAppAttestService) async throws -> String { + do { + return try await service.generateKey() + } catch { + throw O5AuthError(message: "Failed to generate App Attest key: \(error.localizedDescription)", underlyingError: error) + } + } + + private func attestKey(_ service: DCAppAttestService, keyId: String, clientDataHash: Data) async throws -> Data { + do { + return try await service.attestKey(keyId, clientDataHash: clientDataHash) + } catch { + throw O5AuthError(message: "App Attest attestation failed: \(error.localizedDescription)", underlyingError: error) + } + } + + // MARK: - App Identity + + private func getAppId() throws -> String { + guard let bundleId = Bundle.main.bundleIdentifier else { + throw O5AuthError(message: "Could not determine bundle identifier.") + } + guard let teamId = getTeamId() else { + throw O5AuthError(message: "Could not determine Team ID from provisioning profile.") + } + return "\(teamId).\(bundleId)" + } + + private func getTeamId() -> String? { + guard let path = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision"), + let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { + return nil + } + + // The mobileprovision file is a CMS signed plist. Find the plist XML within it. + guard let plistString = String(data: data, encoding: .ascii), + let plistStart = plistString.range(of: "") else { + return nil + } + + let xml = String(plistString[plistStart.lowerBound...plistEnd.upperBound]) + guard let plistData = xml.data(using: .utf8), + let plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any], + let teamIds = plist["TeamIdentifier"] as? [String], + let teamId = teamIds.first else { + return nil + } + return teamId + } + + // MARK: - Server API + + private func getChallenge() async throws -> String { + var request = URLRequest(url: URL(string: "\(o5KeyManagerBaseURL)/api/auth/ios/challenge")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await performRequest(request) + + guard let json = parseJSON(data), + let challenge = json["challenge"] as? String + else { + throw authError(data: data, response: response, fallback: "Failed to get challenge.") + } + return challenge + } + + private func claimKeypair(attestation: Data, keyId: String, challenge: String, appId: String) async throws -> O5RegistrationData { + var request = URLRequest(url: URL(string: "\(o5KeyManagerBaseURL)/api/o5/keypair")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "attestation": attestation.base64EncodedString(), + "key_id": keyId, + "challenge": challenge, + "app_id": appId, + ]) + + let (data, response) = try await performRequest(request) + + guard let json = parseJSON(data), + let registrationData = O5RegistrationData.fromJSON(json) + else { + throw authError(data: data, response: response, fallback: "Failed to claim keypair.") + } + return registrationData + } + + // MARK: - Helpers + + private func performRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw O5AuthError(message: error.localizedDescription, underlyingError: error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw O5AuthError(message: "Invalid response from server.") + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw authError(data: data, response: httpResponse, fallback: "HTTP \(httpResponse.statusCode)") + } + + return (data, httpResponse) + } + + private func parseJSON(_ data: Data?) -> [String: Any]? { + guard let data = data else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + private func authError(data: Data?, response: URLResponse?, fallback: String) -> O5AuthError { + let statusCode = (response as? HTTPURLResponse)?.statusCode + let message: String + if let json = parseJSON(data) { + message = (json["message"] as? String) ?? (json["error"] as? String) ?? fallback + } else { + message = fallback + } + return O5AuthError(message: message, httpStatusCode: statusCode) + } +} diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift new file mode 100644 index 0000000..55095fb --- /dev/null +++ b/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift @@ -0,0 +1,68 @@ +// +// O5KeyFetchView.swift +// OmnipodKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct O5KeyFetchView: View { + + @State private var errorMessage: String? + + let onKeypairReceived: (O5RegistrationData) -> Void + let onCancel: () -> Void + + var body: some View { + VStack { + Spacer() + + if let errorMessage = errorMessage { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundColor(.red) + Text("An error occurred: \(errorMessage)") + .foregroundColor(.red) + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button(action: { performFetch() }) { + Text(LocalizedString("Retry", comment: "O5 key fetch retry button")) + .actionButtonStyle(.primary) + .padding() + } + } + } else { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + Text(LocalizedString("Downloading certificate...", comment: "O5 key fetch loading text")) + .foregroundColor(.secondary) + } + } + + Spacer() + } + .onAppear { + performFetch() + } + } + + private func performFetch() { + errorMessage = nil + + O5AppAttestService().fetchKeypair { result in + switch result { + case .success(let registrationData): + self.onKeypairReceived(registrationData) + case .failure(let error): + self.errorMessage = error.message + } + } + } +} diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift new file mode 100644 index 0000000..e4216b0 --- /dev/null +++ b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift @@ -0,0 +1,127 @@ +// +// O5KeySetupView.swift +// OmnipodKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import UniformTypeIdentifiers +import LoopKit +import LoopKitUI + +struct O5KeySetupView: View { + + @Environment(\.appName) private var appName + + @State private var o5KeypairsNotAvailable: Bool + @State private var showingFetchSheet = false + @State private var showingFileImporter = false + @State private var fileImportError: String? + private var didContinue: () -> Void + private var didCancel: () -> Void + + init(o5KeypairsNotAvailable: Bool, didContinue: @escaping () -> Void, didCancel: @escaping () -> Void) { + self._o5KeypairsNotAvailable = State(initialValue: o5KeypairsNotAvailable) + self.didContinue = didContinue + self.didCancel = didCancel + } + + var body: some View { + VStack(alignment: .leading) { + List { + Section { + if o5KeypairsNotAvailable { + Text(LocalizedString("We need to briefly connect to the internet to download a certificate in order to pair Omnipod 5 pods. An internet connection won't be required after you complete this one-time step.", comment: "Description when O5 keypairs are not available")) + .padding(.vertical, 4) + } else { + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.title2) + Text(LocalizedString("Ready to connect to an Omnipod 5 pod.", comment: "Description when O5 keypairs are available")) + } + .padding(.vertical, 4) + } + } + + if o5KeypairsNotAvailable { + Section { + Button(action: { showingFileImporter = true }) { + Text(LocalizedString("Have an '.o5keypair' file to use instead?", comment: "Link to import an o5keypair file")) + } + + if let fileImportError = fileImportError { + Text(fileImportError) + .foregroundColor(.red) + .font(.subheadline) + } + } + } + } + .insetGroupedListStyle() + + Button(action: { + if o5KeypairsNotAvailable { + showingFetchSheet = true + } else { + didContinue() + } + }) { + Text(LocalizedString("Continue", comment: "Text for Continue button on O5KeySetupView")) + .actionButtonStyle(.primary) + .padding() + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(LocalizedString("Cancel", comment: "Cancel button title"), action: { + didCancel() + }) + } + } + .fileImporter(isPresented: $showingFileImporter, allowedContentTypes: [.json, .item]) { result in + fileImportError = nil + switch result { + case .success(let url): + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + + guard let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let registrationData = O5RegistrationData.fromJSON(json) + else { + fileImportError = LocalizedString("The selected file is not a valid .o5keypair file.", comment: "Error when o5keypair file import fails") + return + } + O5RegistrationData.install(registrationData) + o5KeypairsNotAvailable = false + case .failure(let error): + fileImportError = error.localizedDescription + } + } + .sheet(isPresented: $showingFetchSheet) { + NavigationView { + O5KeyFetchView( + onKeypairReceived: { registrationData in + O5RegistrationData.install(registrationData) + o5KeypairsNotAvailable = false + showingFetchSheet = false + }, + onCancel: { + showingFetchSheet = false + } + ) + .navigationTitle(LocalizedString("Omnipod 5 Keys", comment: "Title for O5 key fetch view")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(LocalizedString("Cancel", comment: "Cancel button for O5 key fetch")) { + showingFetchSheet = false + } + } + } + } + } + } +} From 813dce9f034b60872e3f944bfb74c4bd374d0895 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Mon, 13 Apr 2026 23:24:10 -0400 Subject: [PATCH 03/27] add build flag --- Localization/Localizable.xcstrings | 30 ++++++++++++++++--- .../ViewControllers/OmniUICoordinator.swift | 4 +++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 631eb2b..48ad7d6 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -7613,6 +7613,10 @@ } } }, + "An error occurred: %@" : { + "comment" : "An error message displayed in the checkout view if there was a problem fetching the user's O5 certificate. The text inside the parentheses will be replaced with the actual error message.", + "isCommentAutoGenerated" : true + }, "Are you sure you want to cancel Pod setup?" : { "comment" : "Alert title for cancel pairing modal", "extractionState" : "manual", @@ -10690,7 +10694,7 @@ } }, "Cancel" : { - "comment" : "Button title for cancelling confidence reminders edit\nButton title for cancelling low reservoir edit\nButton title for cancelling scheduled reminder date edit\nButton title for cancelling silence pod edit\nCancel button text in navigation bar on insert cannula screen\nCancel button text in navigation bar on pair pod UI\nCancel button title\nPairing interface navigation bar button text for cancel action", + "comment" : "Button title for cancelling confidence reminders edit\nButton title for cancelling low reservoir edit\nButton title for cancelling scheduled reminder date edit\nButton title for cancelling silence pod edit\nCancel button for O5 key fetch\nCancel button text in navigation bar on insert cannula screen\nCancel button text in navigation bar on pair pod UI\nCancel button title\nPairing interface navigation bar button text for cancel action", "extractionState" : "manual", "localizations" : { "ar" : { @@ -14740,7 +14744,7 @@ } }, "Continue" : { - "comment" : "Action button description when deactivated\nAction button title for attach pod view\nButton title to continue\nCannula insertion button text when inserted\nPod pairing action button text when paired\nText for continue button\nText for continue button on PodSetupView\nTitle of button to continue discard", + "comment" : "Action button description when deactivated\nAction button title for attach pod view\nButton title to continue\nCannula insertion button text when inserted\nPod pairing action button text when paired\nText for Continue button on O5KeySetupView\nText for continue button\nText for continue button on PodSetupView\nTitle of button to continue discard", "extractionState" : "manual", "localizations" : { "ar" : { @@ -17817,6 +17821,9 @@ } } }, + "Downloading certificate..." : { + "comment" : "O5 key fetch loading text" + }, "Empty reservoir" : { "comment" : "Description for Empty reservoir pod fault", "extractionState" : "manual", @@ -24459,6 +24466,9 @@ } } }, + "Have an '.o5keypair' file to use instead?" : { + "comment" : "Link to import an o5keypair file" + }, "hour" : { "comment" : "Unit for singular hour in pod life remaining", "extractionState" : "manual", @@ -37579,6 +37589,9 @@ } } }, + "Omnipod 5 Keys" : { + "comment" : "Title for O5 key fetch view" + }, "Omnipod 5 Pods have a clear needle tab with a 12-character LOT number typically starting with 'PH1'. The Pod's \"SmartAdjust\" technology will not be used for closed loop control." : { "comment" : "Description for Omnipod 5 pods", "extractionState" : "manual", @@ -51347,6 +51360,9 @@ } } }, + "Ready to connect to an Omnipod 5 pod." : { + "comment" : "Description when O5 keypairs are available" + }, "Rebuild app with needed certificate data" : { "comment" : "Recovery suggestion with missing certificate", "extractionState" : "manual", @@ -53616,7 +53632,7 @@ } }, "Retry" : { - "comment" : "Action button description for deactivate after failed attempt\nCannula insertion button text while showing error\nPod pairing action button text while showing error", + "comment" : "Action button description for deactivate after failed attempt\nCannula insertion button text while showing error\nO5 key fetch retry button\nPod pairing action button text while showing error", "extractionState" : "manual", "localizations" : { "ar" : { @@ -62199,6 +62215,9 @@ } } }, + "The selected file is not a valid .o5keypair file." : { + "comment" : "Error when o5keypair file import fails" + }, "The time on your pump is different from the current time. Do you want to update the time on your pump to the current time?" : { "comment" : "Message for pod sync time action sheet", "extractionState" : "manual", @@ -69001,6 +69020,9 @@ } } }, + "We need to briefly connect to the internet to download a certificate in order to pair Omnipod 5 pods. An internet connection won't be required after you complete this one-time step." : { + "comment" : "Description when O5 keypairs are not available" + }, "Yes" : { "comment" : "Button label for user to answer cannula was properly inserted", "extractionState" : "manual", @@ -71109,4 +71131,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift index b39aed7..5c7027f 100644 --- a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift +++ b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift @@ -180,7 +180,11 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi self?.setupCanceled() } + #if ENABLE_O5 + let o5NotAvailable = false + #else let o5NotAvailable = O5CertificateStore.isEmpty + #endif let podTypeSelectionView = PodTypeSelection(initialValue: self.podType, o5NotAvailable: o5NotAvailable, didConfirm: didConfirm, didCancel: didCancel) let hostedView = hostingController(rootView: podTypeSelectionView) hostedView.navigationItem.title = LocalizedString("Pod Type", comment: "Title for Pod Type selection screen") From 250b91d679f99d18593363338831165f2fc39164 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sun, 10 May 2026 13:44:00 -0400 Subject: [PATCH 04/27] add Config.xcconfig loading ConfigOverride.xcconfig from parent --- Config.xcconfig | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Config.xcconfig diff --git a/Config.xcconfig b/Config.xcconfig new file mode 100644 index 0000000..cf27a8b --- /dev/null +++ b/Config.xcconfig @@ -0,0 +1,3 @@ +// OmnipodKit build configuration +// Inherits overrides from the parent project's ConfigOverride.xcconfig +#include? "../ConfigOverride.xcconfig" From 150bce9b6ebec301cdfe5914a74df3cd03a7d46c Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sun, 10 May 2026 13:48:24 -0400 Subject: [PATCH 05/27] move O5AppAttestService --- OmnipodKit/{PumpManagerUI => Services}/O5AppAttestService.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename OmnipodKit/{PumpManagerUI => Services}/O5AppAttestService.swift (100%) diff --git a/OmnipodKit/PumpManagerUI/O5AppAttestService.swift b/OmnipodKit/Services/O5AppAttestService.swift similarity index 100% rename from OmnipodKit/PumpManagerUI/O5AppAttestService.swift rename to OmnipodKit/Services/O5AppAttestService.swift From 0bafbb0bb46494ebc9edf8561e6c8d1cbbed1053 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sun, 10 May 2026 14:01:31 -0400 Subject: [PATCH 06/27] read Config.xcconfig --- OmnipodKit.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/OmnipodKit.xcodeproj/project.pbxproj b/OmnipodKit.xcodeproj/project.pbxproj index 09fc57c..b8ddcb6 100644 --- a/OmnipodKit.xcodeproj/project.pbxproj +++ b/OmnipodKit.xcodeproj/project.pbxproj @@ -62,6 +62,7 @@ /* Begin PBXFileReference section */ B66A70BC2E6CFDB800F1641B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + D8F83FC02D155ADA0005D165 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; D850B71B2D81733D0095CDF2 /* RileyLinkBLEKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkBLEKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D850B71C2D81733D0095CDF2 /* RileyLinkKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D850B71D2D81733D0095CDF2 /* RileyLinkKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -244,6 +245,7 @@ D8F83F7E2D155ADA0005D165 = { isa = PBXGroup; children = ( + D8F83FC02D155ADA0005D165 /* Config.xcconfig */, B66A6F082E6CFD4B00F1641B /* Localization */, D8DF84DD2D5D243B00798277 /* README.md */, D8F83F8A2D155ADA0005D165 /* OmnipodKit */, @@ -793,6 +795,7 @@ }; D8F83F912D155ADA0005D165 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = D8F83FC02D155ADA0005D165 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -868,6 +871,7 @@ }; D8F83F922D155ADA0005D165 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = D8F83FC02D155ADA0005D165 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; From 7ed3b8c06aab9cf92a7aa4df886756f43620ae2d Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sun, 10 May 2026 14:16:44 -0400 Subject: [PATCH 07/27] Store certificates in keychain --- Localization/Localizable.xcstrings | 14 +- OmnipodKit.xcodeproj/project.pbxproj | 2 +- .../Pair/O5CertificateKeychain.swift | 152 ++++++++++++++++++ .../Bluetooth/Pair/O5CertificateStore.swift | 4 + .../Bluetooth/Pair/O5RegistrationData.swift | 18 +++ .../PumpManagerUI/Views/O5KeySetupView.swift | 2 + .../Views/PodCertificatesView.swift | 95 +++++++++++ .../Views/PodDiagnosticsView.swift | 5 + 8 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 OmnipodKit/Bluetooth/Pair/O5CertificateKeychain.swift create mode 100644 OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 48ad7d6..e260245 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -10694,7 +10694,7 @@ } }, "Cancel" : { - "comment" : "Button title for cancelling confidence reminders edit\nButton title for cancelling low reservoir edit\nButton title for cancelling scheduled reminder date edit\nButton title for cancelling silence pod edit\nCancel button for O5 key fetch\nCancel button text in navigation bar on insert cannula screen\nCancel button text in navigation bar on pair pod UI\nCancel button title\nPairing interface navigation bar button text for cancel action", + "comment" : "Button title for cancelling confidence reminders edit\nButton title for cancelling low reservoir edit\nButton title for cancelling scheduled reminder date edit\nButton title for cancelling silence pod edit\nCancel button\nCancel button for O5 key fetch\nCancel button text in navigation bar on insert cannula screen\nCancel button text in navigation bar on pair pod UI\nCancel button title\nPairing interface navigation bar button text for cancel action", "extractionState" : "manual", "localizations" : { "ar" : { @@ -24304,6 +24304,9 @@ } } }, + "Forget Saved Certificate" : { + "comment" : "Confirm destructive forget action\nDestructive button to remove a saved O5 certificate" + }, "Greater than %1$@ units remaining at %2$@" : { "comment" : "Accessibility format string for (1: localized volume)(2: time)", "extractionState" : "manual", @@ -35161,6 +35164,9 @@ } } }, + "No saved certificates" : { + "comment" : "Empty state for the Pod Certificates view" + }, "No, Continue With Pod" : { "comment" : "Continue pairing button title of in pairing cancel modal", "extractionState" : "manual", @@ -43260,6 +43266,9 @@ } } }, + "Pod Certificates" : { + "comment" : "Text for pod certificates navigation link\nnavigation title for pod certificates" + }, "Pod deactivated successfully. Continue." : { "comment" : "Deactivate pod action button accessibility label when deactivation complete", "extractionState" : "manual", @@ -70319,6 +70328,9 @@ } } }, + "You will be unable to pair to an Omnipod 5 pod until you reconnect to the internet to download a new certificate." : { + "comment" : "Confirmation message when forgetting a saved O5 certificate" + }, "You will now begin the process of configuring your reminders, selecting your insulin type, selecting the Omnipod pod type you will be using, filling your Pod with insulin, pairing to your device and placing it on your body." : { "comment" : "bodyText for PodSetupView", "extractionState" : "manual", diff --git a/OmnipodKit.xcodeproj/project.pbxproj b/OmnipodKit.xcodeproj/project.pbxproj index b8ddcb6..81a2c2f 100644 --- a/OmnipodKit.xcodeproj/project.pbxproj +++ b/OmnipodKit.xcodeproj/project.pbxproj @@ -62,7 +62,6 @@ /* Begin PBXFileReference section */ B66A70BC2E6CFDB800F1641B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; - D8F83FC02D155ADA0005D165 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; D850B71B2D81733D0095CDF2 /* RileyLinkBLEKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkBLEKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D850B71C2D81733D0095CDF2 /* RileyLinkKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D850B71D2D81733D0095CDF2 /* RileyLinkKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -73,6 +72,7 @@ D8D0ED182D74EF41000B0AF4 /* OmniTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OmniTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D8DF84DD2D5D243B00798277 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; D8F83F882D155ADA0005D165 /* OmnipodKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OmnipodKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D8F83FC02D155ADA0005D165 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ diff --git a/OmnipodKit/Bluetooth/Pair/O5CertificateKeychain.swift b/OmnipodKit/Bluetooth/Pair/O5CertificateKeychain.swift new file mode 100644 index 0000000..c968a13 --- /dev/null +++ b/OmnipodKit/Bluetooth/Pair/O5CertificateKeychain.swift @@ -0,0 +1,152 @@ +// +// O5CertificateKeychain.swift +// OmnipodKit +// +// Persists O5RegistrationData entries in the iOS Keychain so the user does +// not have to redo the O5 key fetch on every cold start. +// +// Note: the "forget pod" flow intentionally does NOT call into this module — +// these credentials are tied to the controller identity, not to any pod +// session, and must outlive pod un-pair. Removal happens only via the +// explicit "Forget Saved Certificate" UI in PodCertificatesView. +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import Security +import os.log + +enum O5CertificateKeychain { + + private static let log = OSLog(subsystem: "com.loopkit.OmnipodKit", category: "O5CertificateKeychain") + + private static let service = "org.nightscout.o5certificates" + private static let schemaVersion = 1 + + private static var restored = false + private static let restoreLock = NSLock() + + enum Error: Swift.Error { + case encodingFailed + case unhandled(OSStatus) + } + + // MARK: - Public API + + static func save(_ data: O5RegistrationData) throws { + let payload = try encode(data) + let account = String(data.controllerId) + + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let updateAttrs: [String: Any] = [ + kSecValueData as String: payload, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecAttrSynchronizable as String: false, + ] + + let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttrs as CFDictionary) + if updateStatus == errSecSuccess { return } + if updateStatus != errSecItemNotFound { + throw Error.unhandled(updateStatus) + } + + var addQuery = updateQuery + addQuery[kSecValueData as String] = payload + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + addQuery[kSecAttrSynchronizable as String] = false + + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus != errSecSuccess { + throw Error.unhandled(addStatus) + } + } + + static func delete(controllerId: UInt32) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: String(controllerId), + ] + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw Error.unhandled(status) + } + } + + static func deleteAll() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw Error.unhandled(status) + } + } + + static func loadAll() -> [O5RegistrationData] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { return [] } + guard let items = result as? [[String: Any]] else { return [] } + + return items.compactMap { item -> O5RegistrationData? in + guard let data = item[kSecValueData as String] as? Data else { return nil } + return decode(data) + } + } + + static func contains(controllerId: UInt32) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: String(controllerId), + kSecMatchLimit as String: kSecMatchLimitOne, + ] + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } + + /// Loads every persisted certificate into the in-memory `O5RegistrationData` registry. + /// Idempotent and cheap to call — the actual Keychain read happens at most once per process. + static func restoreIntoRegistry() { + restoreLock.lock() + defer { restoreLock.unlock() } + if restored { return } + restored = true + for data in loadAll() { + O5RegistrationData.install(data) + } + } + + // MARK: - Codec + + private static func encode(_ data: O5RegistrationData) throws -> Data { + var json = data.toJSON() + json["v"] = schemaVersion + do { + return try JSONSerialization.data(withJSONObject: json, options: []) + } catch { + throw Error.encodingFailed + } + } + + private static func decode(_ blob: Data) -> O5RegistrationData? { + guard let obj = try? JSONSerialization.jsonObject(with: blob), + let json = obj as? [String: Any] + else { return nil } + return O5RegistrationData.fromJSON(json) + } +} diff --git a/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift b/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift index f5792cb..2ff8f03 100644 --- a/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift +++ b/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift @@ -37,6 +37,7 @@ class O5CertificateStore { /// Randomly picks an available O5 controllerId with or 0 if none available static var pickControllerId: UInt32 { loadOptionalO5Data() + O5CertificateKeychain.restoreIntoRegistry() if let data = O5RegistrationData.allValues.randomElement() { return data.controllerId } @@ -46,12 +47,14 @@ class O5CertificateStore { // Returns true if no O5RegistrationData is available static var isEmpty: Bool { loadOptionalO5Data() + O5CertificateKeychain.restoreIntoRegistry() return O5RegistrationData.isEmpty } // Returns true if O5RegistrationData exists for the specific controllerId static func contains(_ controllerId: UInt32) -> Bool { loadOptionalO5Data() + O5CertificateKeychain.restoreIntoRegistry() return O5RegistrationData.get(controllerId) != nil } @@ -62,6 +65,7 @@ class O5CertificateStore { init(controllerId: UInt32) throws { loadOptionalO5Data() + O5CertificateKeychain.restoreIntoRegistry() guard let data = O5RegistrationData.get(controllerId) else { log.debug("@@@ O5CertificateStore has no data for 0x%08X", controllerId) throw PodCommsError.noCertificateFound diff --git a/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift b/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift index bb69f95..368ba5b 100644 --- a/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift +++ b/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift @@ -19,6 +19,12 @@ struct O5RegistrationData { _registry[value.controllerId] = value } + static func remove(controllerId: UInt32) { + lock.lock() + defer { lock.unlock() } + _registry.removeValue(forKey: controllerId) + } + static func get(_ controllerId: UInt32) -> O5RegistrationData? { lock.lock() defer { lock.unlock() } @@ -51,6 +57,18 @@ struct O5RegistrationData { return _registry.isEmpty } + /// Inverse of `fromJSON`. The shape matches the .o5keypair file format so that + /// persisted entries and imported files share a single representation. + func toJSON() -> [String: Any] { + return [ + "controllerId": NSNumber(value: controllerId), + "privateKey": privateKeyHex, + "publicKey": publicKeyHex, + "intermediateCA": intermediateCABase64, + "tlsCertificate": tlsCertificateBase64, + ] + } + /// Parse an O5RegistrationData from a JSON dictionary (e.g. from an .o5keypair file or API response). static func fromJSON(_ json: [String: Any]) -> O5RegistrationData? { guard let controllerId = (json["controllerId"] as? NSNumber)?.uint32Value, diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift index e4216b0..c24f45f 100644 --- a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift +++ b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift @@ -95,6 +95,7 @@ struct O5KeySetupView: View { return } O5RegistrationData.install(registrationData) + try? O5CertificateKeychain.save(registrationData) o5KeypairsNotAvailable = false case .failure(let error): fileImportError = error.localizedDescription @@ -105,6 +106,7 @@ struct O5KeySetupView: View { O5KeyFetchView( onKeypairReceived: { registrationData in O5RegistrationData.install(registrationData) + try? O5CertificateKeychain.save(registrationData) o5KeypairsNotAvailable = false showingFetchSheet = false }, diff --git a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift new file mode 100644 index 0000000..2226d07 --- /dev/null +++ b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift @@ -0,0 +1,95 @@ +// +// PodCertificatesView.swift +// OmnipodKit +// +// Displays the persisted O5 certificates and lets the user remove them +// individually. Removal is destructive: until the user reconnects to the +// internet to re-fetch a certificate, pairing to an Omnipod 5 pod will fail. +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct PodCertificatesView: View { + + @State private var certificates: [O5RegistrationData] = [] + @State private var pendingForget: UInt32? = nil + + private let title = LocalizedString("Pod Certificate Details", comment: "navigation title for pod certificate details") + private let confirmMessage = LocalizedString( + "You will be unable to pair to an Omnipod 5 pod until you reconnect to the internet to download a new certificate.", + comment: "Confirmation message when forgetting a saved O5 certificate" + ) + + var body: some View { + List { + if certificates.isEmpty { + Section { + Text(LocalizedString("No saved certificates", comment: "Empty state for the Pod Certificates view")) + .foregroundColor(.secondary) + } + } else { + ForEach(certificates, id: \.controllerId) { data in + Section(header: Text(String(format: "Controller 0x%08X", data.controllerId))) { + Text(dump(for: data)) + .font(Font.system(size: 12).monospaced()) + .textSelection(.enabled) + + Button(role: .destructive) { + pendingForget = data.controllerId + } label: { + Text(LocalizedString("Forget Saved Certificate", comment: "Destructive button to remove a saved O5 certificate")) + } + } + } + } + } + .insetGroupedListStyle() + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .task { reload() } + .confirmationDialog( + confirmMessage, + isPresented: Binding( + get: { pendingForget != nil }, + set: { if !$0 { pendingForget = nil } } + ), + titleVisibility: .visible + ) { + Button(LocalizedString("Forget Saved Certificate", comment: "Confirm destructive forget action"), role: .destructive) { + if let controllerId = pendingForget { + forget(controllerId: controllerId) + } + pendingForget = nil + } + Button(LocalizedString("Cancel", comment: "Cancel button"), role: .cancel) { + pendingForget = nil + } + } + } + + private func reload() { + certificates = O5CertificateKeychain.loadAll() + .sorted { $0.controllerId < $1.controllerId } + } + + private func forget(controllerId: UInt32) { + try? O5CertificateKeychain.delete(controllerId: controllerId) + O5RegistrationData.remove(controllerId: controllerId) + reload() + } + + private func dump(for data: O5RegistrationData) -> String { + var lines: [String] = [] + lines.append("## O5RegistrationData") + lines.append(String(format: "* controllerId: %u (0x%08X)", data.controllerId, data.controllerId)) + lines.append("* privateKey: \(data.privateKeyHex)") + lines.append("* publicKey: \(data.publicKeyHex)") + lines.append("* intermediateCA: \(data.intermediateCABase64)") + lines.append("* tlsCertificate: \(data.tlsCertificateBase64)") + return lines.joined(separator: "\n") + } +} diff --git a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift index 69b5089..df24f31 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift @@ -96,6 +96,11 @@ struct PodDiagnosticsView: View { FrameworkLocalText("Pump Manager Details", comment: "Text for pump manager details navigation link") .foregroundColor(Color.primary) } + + NavigationLink(destination: PodCertificatesView()) { + FrameworkLocalText("Pod Certificate Details", comment: "Text for pod certificate details navigation link") + .foregroundColor(Color.primary) + } } .insetGroupedListStyle() .navigationTitle(title) From d19901157376d0f7047085346045716849f5130e Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sun, 10 May 2026 14:29:22 -0400 Subject: [PATCH 08/27] store data source --- Localization/Localizable.xcstrings | 10 +-- .../Pair/O5CertificateKeychain.swift | 34 +++++++--- .../Bluetooth/Pair/O5CertificateStore.swift | 18 +++-- .../Bluetooth/Pair/O5RegistrationData.swift | 30 +++++++++ .../PumpManagerUI/Views/O5KeySetupView.swift | 8 +-- .../Views/PodCertificatesView.swift | 65 +++++++++++++------ 6 files changed, 120 insertions(+), 45 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index e260245..989167a 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -33544,6 +33544,9 @@ } } }, + "No certificates loaded." : { + "comment" : "Empty state for the Pod Certificate Details view" + }, "No confidence reminders are used." : { "comment" : "Description for BeepPreference.silent", "extractionState" : "manual", @@ -35164,9 +35167,6 @@ } } }, - "No saved certificates" : { - "comment" : "Empty state for the Pod Certificates view" - }, "No, Continue With Pod" : { "comment" : "Continue pairing button title of in pairing cancel modal", "extractionState" : "manual", @@ -43266,8 +43266,8 @@ } } }, - "Pod Certificates" : { - "comment" : "Text for pod certificates navigation link\nnavigation title for pod certificates" + "Pod Certificate Details" : { + "comment" : "Text for pod certificate details navigation link\nnavigation title for pod certificate details" }, "Pod deactivated successfully. Continue." : { "comment" : "Deactivate pod action button accessibility label when deactivation complete", diff --git a/OmnipodKit/Bluetooth/Pair/O5CertificateKeychain.swift b/OmnipodKit/Bluetooth/Pair/O5CertificateKeychain.swift index c968a13..d175418 100644 --- a/OmnipodKit/Bluetooth/Pair/O5CertificateKeychain.swift +++ b/OmnipodKit/Bluetooth/Pair/O5CertificateKeychain.swift @@ -32,10 +32,16 @@ enum O5CertificateKeychain { case unhandled(OSStatus) } + /// One persisted entry: registration data plus the source that originally produced it. + struct Entry { + let data: O5RegistrationData + let source: O5RegistrationSource + } + // MARK: - Public API - static func save(_ data: O5RegistrationData) throws { - let payload = try encode(data) + static func save(_ data: O5RegistrationData, source: O5RegistrationSource) throws { + let payload = try encode(data, source: source) let account = String(data.controllerId) let updateQuery: [String: Any] = [ @@ -89,7 +95,7 @@ enum O5CertificateKeychain { } } - static func loadAll() -> [O5RegistrationData] { + static func loadAll() -> [Entry] { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -102,7 +108,7 @@ enum O5CertificateKeychain { guard status == errSecSuccess else { return [] } guard let items = result as? [[String: Any]] else { return [] } - return items.compactMap { item -> O5RegistrationData? in + return items.compactMap { item -> Entry? in guard let data = item[kSecValueData as String] as? Data else { return nil } return decode(data) } @@ -126,16 +132,17 @@ enum O5CertificateKeychain { defer { restoreLock.unlock() } if restored { return } restored = true - for data in loadAll() { - O5RegistrationData.install(data) + for entry in loadAll() { + O5RegistrationData.install(entry.data, source: entry.source) } } // MARK: - Codec - private static func encode(_ data: O5RegistrationData) throws -> Data { + private static func encode(_ data: O5RegistrationData, source: O5RegistrationSource) throws -> Data { var json = data.toJSON() json["v"] = schemaVersion + json["source"] = source.rawValue do { return try JSONSerialization.data(withJSONObject: json, options: []) } catch { @@ -143,10 +150,17 @@ enum O5CertificateKeychain { } } - private static func decode(_ blob: Data) -> O5RegistrationData? { + private static func decode(_ blob: Data) -> Entry? { guard let obj = try? JSONSerialization.jsonObject(with: blob), - let json = obj as? [String: Any] + let json = obj as? [String: Any], + let data = O5RegistrationData.fromJSON(json) else { return nil } - return O5RegistrationData.fromJSON(json) + let source: O5RegistrationSource = { + if let raw = json["source"] as? String, let s = O5RegistrationSource(rawValue: raw) { + return s + } + return .imported + }() + return Entry(data: data, source: source) } } diff --git a/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift b/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift index 2ff8f03..36e892a 100644 --- a/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift +++ b/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift @@ -220,15 +220,21 @@ class O5CertificateStore { // MARK: - Runtime Installer -/// Load the data from the optional O5Data file if present by invoking its install() function using a unsafeBitCast +/// Load the data from the optional O5Data file if present by invoking its install() function using a unsafeBitCast. +/// Any registry entries that appear as a result of the call are tagged with `.builtIn`. fileprivate func loadOptionalO5Data() { // Use RTLD_DEFAULT (-2) to find the symbol if it was compiled into the binary - if let installSym = dlsym( + guard let installSym = dlsym( UnsafeMutableRawPointer(bitPattern: -2), "O5RegistrationDataInstall" - ) { - typealias InstallFunc = @convention(c) () -> Void - let install = unsafeBitCast(installSym, to: InstallFunc.self) - install() + ) else { return } + + let before = Set(O5RegistrationData.allValues.map { $0.controllerId }) + typealias InstallFunc = @convention(c) () -> Void + let install = unsafeBitCast(installSym, to: InstallFunc.self) + install() + let after = Set(O5RegistrationData.allValues.map { $0.controllerId }) + for newId in after.subtracting(before) { + O5RegistrationData.markSource(newId, .builtIn) } } diff --git a/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift b/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift index 368ba5b..37c3e0d 100644 --- a/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift +++ b/OmnipodKit/Bluetooth/Pair/O5RegistrationData.swift @@ -9,20 +9,50 @@ import Foundation import CryptoSwift +enum O5RegistrationSource: String { + case builtIn // compiled into the binary via the optional O5Data symbol + case imported // loaded from a user-supplied .o5keypair file + case fetched // downloaded from the keypair API +} + struct O5RegistrationData { private static var _registry: [UInt32: O5RegistrationData] = [:] + private static var _sources: [UInt32: O5RegistrationSource] = [:] private static let lock = NSLock() + /// Plain install (used by the embedded built-in installer that ships as compiled code + /// and cannot be modified to pass a source). Source is tagged separately by + /// `loadOptionalO5Data` once the symbol-call returns. static func install(_ value: O5RegistrationData) { lock.lock() defer { lock.unlock() } _registry[value.controllerId] = value } + static func install(_ value: O5RegistrationData, source: O5RegistrationSource) { + lock.lock() + defer { lock.unlock() } + _registry[value.controllerId] = value + _sources[value.controllerId] = source + } + + static func markSource(_ controllerId: UInt32, _ source: O5RegistrationSource) { + lock.lock() + defer { lock.unlock() } + _sources[controllerId] = source + } + + static func source(for controllerId: UInt32) -> O5RegistrationSource? { + lock.lock() + defer { lock.unlock() } + return _sources[controllerId] + } + static func remove(controllerId: UInt32) { lock.lock() defer { lock.unlock() } _registry.removeValue(forKey: controllerId) + _sources.removeValue(forKey: controllerId) } static func get(_ controllerId: UInt32) -> O5RegistrationData? { diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift index c24f45f..01f32fd 100644 --- a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift +++ b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift @@ -94,8 +94,8 @@ struct O5KeySetupView: View { fileImportError = LocalizedString("The selected file is not a valid .o5keypair file.", comment: "Error when o5keypair file import fails") return } - O5RegistrationData.install(registrationData) - try? O5CertificateKeychain.save(registrationData) + O5RegistrationData.install(registrationData, source: .imported) + try? O5CertificateKeychain.save(registrationData, source: .imported) o5KeypairsNotAvailable = false case .failure(let error): fileImportError = error.localizedDescription @@ -105,8 +105,8 @@ struct O5KeySetupView: View { NavigationView { O5KeyFetchView( onKeypairReceived: { registrationData in - O5RegistrationData.install(registrationData) - try? O5CertificateKeychain.save(registrationData) + O5RegistrationData.install(registrationData, source: .fetched) + try? O5CertificateKeychain.save(registrationData, source: .fetched) o5KeypairsNotAvailable = false showingFetchSheet = false }, diff --git a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift index 2226d07..32f2375 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift @@ -2,9 +2,9 @@ // PodCertificatesView.swift // OmnipodKit // -// Displays the persisted O5 certificates and lets the user remove them -// individually. Removal is destructive: until the user reconnects to the -// internet to re-fetch a certificate, pairing to an Omnipod 5 pod will fail. +// Displays the loaded O5 certificates and lets the user remove the saved ones +// individually. Built-in certificates (compiled into the binary) are listed +// read-only — they cannot be forgotten without rebuilding the app. // // Copyright © 2026 LoopKit Authors. All rights reserved. // @@ -15,7 +15,13 @@ import LoopKitUI struct PodCertificatesView: View { - @State private var certificates: [O5RegistrationData] = [] + private struct Row: Identifiable { + let data: O5RegistrationData + let source: O5RegistrationSource + var id: UInt32 { data.controllerId } + } + + @State private var rows: [Row] = [] @State private var pendingForget: UInt32? = nil private let title = LocalizedString("Pod Certificate Details", comment: "navigation title for pod certificate details") @@ -26,22 +32,24 @@ struct PodCertificatesView: View { var body: some View { List { - if certificates.isEmpty { + if rows.isEmpty { Section { - Text(LocalizedString("No saved certificates", comment: "Empty state for the Pod Certificates view")) + Text(LocalizedString("No certificates loaded.", comment: "Empty state for the Pod Certificate Details view")) .foregroundColor(.secondary) } } else { - ForEach(certificates, id: \.controllerId) { data in - Section(header: Text(String(format: "Controller 0x%08X", data.controllerId))) { - Text(dump(for: data)) + ForEach(rows) { row in + Section(header: Text(String(format: "Controller 0x%08X", row.data.controllerId))) { + Text(dump(for: row)) .font(Font.system(size: 12).monospaced()) .textSelection(.enabled) - Button(role: .destructive) { - pendingForget = data.controllerId - } label: { - Text(LocalizedString("Forget Saved Certificate", comment: "Destructive button to remove a saved O5 certificate")) + if row.source != .builtIn { + Button(role: .destructive) { + pendingForget = row.data.controllerId + } label: { + Text(LocalizedString("Forget Saved Certificate", comment: "Destructive button to remove a saved O5 certificate")) + } } } } @@ -72,8 +80,16 @@ struct PodCertificatesView: View { } private func reload() { - certificates = O5CertificateKeychain.loadAll() + // Make sure both built-in (dlsym) and Keychain-persisted certs are populated + // before we read the registry — opening this view in the diagnostics screen + // shouldn't depend on the pairing flow having run first. + _ = O5CertificateStore.isEmpty + + rows = O5RegistrationData.allValues .sorted { $0.controllerId < $1.controllerId } + .map { data in + Row(data: data, source: O5RegistrationData.source(for: data.controllerId) ?? .imported) + } } private func forget(controllerId: UInt32) { @@ -82,14 +98,23 @@ struct PodCertificatesView: View { reload() } - private func dump(for data: O5RegistrationData) -> String { + private func dump(for row: Row) -> String { var lines: [String] = [] lines.append("## O5RegistrationData") - lines.append(String(format: "* controllerId: %u (0x%08X)", data.controllerId, data.controllerId)) - lines.append("* privateKey: \(data.privateKeyHex)") - lines.append("* publicKey: \(data.publicKeyHex)") - lines.append("* intermediateCA: \(data.intermediateCABase64)") - lines.append("* tlsCertificate: \(data.tlsCertificateBase64)") + lines.append("* source: \(label(for: row.source))") + lines.append(String(format: "* controllerId: %u (0x%08X)", row.data.controllerId, row.data.controllerId)) + lines.append("* privateKey: \(row.data.privateKeyHex)") + lines.append("* publicKey: \(row.data.publicKeyHex)") + lines.append("* intermediateCA: \(row.data.intermediateCABase64)") + lines.append("* tlsCertificate: \(row.data.tlsCertificateBase64)") return lines.joined(separator: "\n") } + + private func label(for source: O5RegistrationSource) -> String { + switch source { + case .builtIn: return "Built-in (compiled into app)" + case .imported: return "Imported (.o5keypair file)" + case .fetched: return "Fetched (downloaded from API)" + } + } } From bada4fddfc60c674f5e0f2f565b6e546ac426d8c Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sun, 10 May 2026 14:39:42 -0400 Subject: [PATCH 09/27] add resolve routing step --- Localization/Localizable.xcstrings | 3 + .../Bluetooth/Pair/O5CertificateStore.swift | 17 ++++++ .../ViewControllers/OmniUICoordinator.swift | 59 ++++++++++++++----- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 989167a..0f70764 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -37760,6 +37760,9 @@ } } }, + "Omnipod 5 Setup" : { + "comment" : "Title for the Omnipod 5 key setup screen" + }, "Omnipod Classic" : { "comment" : "Title string for Omnipod Classic", "extractionState" : "manual", diff --git a/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift b/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift index 36e892a..c0fa685 100644 --- a/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift +++ b/OmnipodKit/Bluetooth/Pair/O5CertificateStore.swift @@ -218,6 +218,23 @@ class O5CertificateStore { } } +// MARK: - Public availability helper + +/// Whether Omnipod 5 pairing should be presented as available in the UI. +/// +/// In ENABLE_O5 builds we surface the O5 flow unconditionally — the build is +/// expected to ship with built-in registration data, or the user is expected +/// to import / fetch a keypair as part of setup. In other builds we only +/// consider O5 available if at least one registration record is currently +/// loaded (built-in, imported, fetched, or restored from Keychain). +public func isOmnipod5Enabled() -> Bool { + #if ENABLE_O5 + return true + #else + return !O5CertificateStore.isEmpty + #endif +} + // MARK: - Runtime Installer /// Load the data from the optional O5Data file if present by invoking its install() function using a unsafeBitCast. diff --git a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift index 5c7027f..cb28be8 100644 --- a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift +++ b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift @@ -24,6 +24,8 @@ enum OmniUIScreen { case lowReservoirReminderSetup case insulinTypeSelection case selectPodType + case podTypeSelected // virtual routing step — never presented; resolves to o5KeySetup / rileyLinkSetup / pairAndPrime + case o5KeySetup case rileyLinkSetup // will be skipped for non-Eros pods case pairAndPrime case insertCannula @@ -46,7 +48,14 @@ enum OmniUIScreen { case .insulinTypeSelection: return .selectPodType case .selectPodType: + return .podTypeSelected + case .podTypeSelected: + // Resolved by `navigateTo` to one of o5KeySetup / rileyLinkSetup / pairAndPrime. + // The fallback here (rileyLinkSetup) is never reached because this case is + // never the "currentScreen" — it's intercepted before being pushed. return .rileyLinkSetup + case .o5KeySetup: + return .pairAndPrime case .rileyLinkSetup: // will be skipped for non-Eros pods return .pairAndPrime case .pairAndPrime: @@ -64,7 +73,7 @@ enum OmniUIScreen { case .uncertaintyRecovered: return nil case .deactivate: - return .pairAndPrime + return .podTypeSelected case .settings: return nil } @@ -180,16 +189,27 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi self?.setupCanceled() } - #if ENABLE_O5 - let o5NotAvailable = false - #else - let o5NotAvailable = O5CertificateStore.isEmpty - #endif + let o5NotAvailable = !isOmnipod5Enabled() let podTypeSelectionView = PodTypeSelection(initialValue: self.podType, o5NotAvailable: o5NotAvailable, didConfirm: didConfirm, didCancel: didCancel) let hostedView = hostingController(rootView: podTypeSelectionView) hostedView.navigationItem.title = LocalizedString("Pod Type", comment: "Title for Pod Type selection screen") return hostedView + case .podTypeSelected: + // Virtual step: navigateTo resolves it before push. Reaching here would mean + // someone instantiated a view controller for the routing step itself. + fatalError("podTypeSelected is a virtual routing step and must be resolved before presentation") + + case .o5KeySetup: + let view = O5KeySetupView( + o5KeypairsNotAvailable: O5CertificateStore.isEmpty, + didContinue: { [weak self] in self?.stepFinished() }, + didCancel: { [weak self] in self?.setupCanceled() } + ) + let hostedView = hostingController(rootView: view) + hostedView.navigationItem.title = LocalizedString("Omnipod 5 Setup", comment: "Title for the Omnipod 5 key setup screen") + return hostedView + case .rileyLinkSetup: // This step will be skipped for non-Eros pods let dataSource = RileyLinkListDataSource(rileyLinkPumpManager: pumpManager) @@ -392,14 +412,22 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi return DismissibleHostingController(content: rootView, onDisappear: onDisappear, colorPalette: colorPalette) } + /// Resolves the virtual `.podTypeSelected` routing step into the concrete next + /// screen for the currently selected pod type. Other screens pass through. + private func resolveRoutingStep(_ screen: OmniUIScreen) -> OmniUIScreen { + guard screen == .podTypeSelected else { return screen } + if podType == omnipod5Type && O5CertificateStore.isEmpty { + return .o5KeySetup + } + if podType.usesRileyLink { + return .rileyLinkSetup + } + return .pairAndPrime + } + private func stepFinished() { if let nextStep = currentScreen.next() { - if nextStep == .rileyLinkSetup && !podType.usesRileyLink { - // Skip rileyLinkSetup to pairAndPrme for non-Eros - navigateTo(.pairAndPrime) - } else { - navigateTo(nextStep) - } + navigateTo(nextStep) } else if pumpManager.podType == unknownOmnipodType { // User selected switch pod type at bottom of pod settings with // no active pod, so we need to reselect the new pod type now. @@ -470,7 +498,7 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi if self.podType == unknownOmnipodType { return .selectPodType // need to first select a pod type } - return .pairAndPrime // pair and prime a new pod + return .podTypeSelected // route to o5KeySetup / rileyLinkSetup / pairAndPrime as appropriate } else { if self.podType == unknownOmnipodType { return .selectPodType // need to first select a pod type @@ -527,8 +555,9 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi extension OmniUICoordinator: OmniUINavigator { func navigateTo(_ screen: OmniUIScreen) { - screenStack.append(screen) - let viewController = viewControllerForScreen(screen) + let resolved = resolveRoutingStep(screen) + screenStack.append(resolved) + let viewController = viewControllerForScreen(resolved) viewController.isModalInPresentation = false self.pushViewController(viewController, animated: true) viewController.view.layoutSubviews() From de0525f2c6f95f0da218ceddad817a076cfa8c9b Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sun, 10 May 2026 14:45:39 -0400 Subject: [PATCH 10/27] make sure you need to have o5 certs before pairing an o5 pod --- .../ViewControllers/OmniUICoordinator.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift index cb28be8..2968507 100644 --- a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift +++ b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift @@ -412,13 +412,20 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi return DismissibleHostingController(content: rootView, onDisappear: onDisappear, colorPalette: colorPalette) } - /// Resolves the virtual `.podTypeSelected` routing step into the concrete next - /// screen for the currently selected pod type. Other screens pass through. + /// Resolves the virtual `.podTypeSelected` routing step (and intercepts direct + /// `.pairAndPrime` jumps that need an O5 key fetch first) into the concrete + /// next screen for the currently selected pod type. Other screens pass through. private func resolveRoutingStep(_ screen: OmniUIScreen) -> OmniUIScreen { - guard screen == .podTypeSelected else { return screen } + // Hard guard: O5 always needs a cert before pair/prime can succeed. + // Any caller asking to start pairing — whether through the routing step + // or directly via `.pairAndPrime` (e.g. the Pair Pod button in settings) + // gets diverted to the key setup screen if no cert is loaded. if podType == omnipod5Type && O5CertificateStore.isEmpty { - return .o5KeySetup + if screen == .podTypeSelected || screen == .pairAndPrime { + return .o5KeySetup + } } + guard screen == .podTypeSelected else { return screen } if podType.usesRileyLink { return .rileyLinkSetup } From 5dfa12e56a97014b0d54da8d3234222106b3a8d1 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sun, 10 May 2026 14:53:53 -0400 Subject: [PATCH 11/27] cohesively get teamid --- OmnipodKit/Info.plist | 2 + OmnipodKit/Services/O5AppAttestService.swift | 58 ++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/OmnipodKit/Info.plist b/OmnipodKit/Info.plist index 9bcb244..38ae315 100644 --- a/OmnipodKit/Info.plist +++ b/OmnipodKit/Info.plist @@ -18,5 +18,7 @@ 1.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) + OmnipodKitTeamIdentifier + $(DEVELOPMENT_TEAM) diff --git a/OmnipodKit/Services/O5AppAttestService.swift b/OmnipodKit/Services/O5AppAttestService.swift index 503c302..0cc0b2d 100644 --- a/OmnipodKit/Services/O5AppAttestService.swift +++ b/OmnipodKit/Services/O5AppAttestService.swift @@ -107,7 +107,21 @@ class O5AppAttestService { return "\(teamId).\(bundleId)" } + /// Resolves the Apple Team ID across environments. Tries, in order: + /// 1. The signed `embedded.mobileprovision` (real-device / TestFlight / App Store builds) + /// 2. The Keychain access-group prefix (works in simulator and on device whenever + /// the process has a code-signing identity — does not require a provisioning profile) + /// 3. An `OmnipodKitTeamIdentifier` key in OmnipodKit's Info.plist, populated from the + /// `$(DEVELOPMENT_TEAM)` build setting (last-ditch override for environments + /// where neither runtime source is available, e.g. unsigned test harnesses). private func getTeamId() -> String? { + if let id = teamIdFromMobileProvision(), !id.isEmpty { return id } + if let id = teamIdFromKeychainAccessGroup(), !id.isEmpty { return id } + if let id = teamIdFromInfoPlist(), !id.isEmpty, !id.contains("$(") { return id } + return nil + } + + private func teamIdFromMobileProvision() -> String? { guard let path = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision"), let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil @@ -130,6 +144,50 @@ class O5AppAttestService { return teamId } + /// Adds (or finds) a probe Keychain item and reads its `kSecAttrAccessGroup`, + /// which iOS prefixes with the team ID: `.`. + private func teamIdFromKeychainAccessGroup() -> String? { + let probeAccount = "org.nightscout.o5.teamid-probe" + let probeService = "org.nightscout.o5.teamid-probe" + + let baseQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: probeAccount, + kSecAttrService as String: probeService, + ] + + var query = baseQuery + query[kSecReturnAttributes as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + var status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + var addQuery = baseQuery + addQuery[kSecValueData as String] = Data() + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + addQuery[kSecReturnAttributes as String] = true + status = SecItemAdd(addQuery as CFDictionary, &result) + } + + guard status == errSecSuccess, + let attrs = result as? [String: Any], + let accessGroup = attrs[kSecAttrAccessGroup as String] as? String, + let prefix = accessGroup.split(separator: ".").first + else { return nil } + + return String(prefix) + } + + /// Reads `OmnipodKitTeamIdentifier` from OmnipodKit's Info.plist. The xcconfig + /// substitutes `$(DEVELOPMENT_TEAM)` at build time. We read from the framework's + /// bundle (not `Bundle.main`) because the substitution happens in OmnipodKit's plist. + private func teamIdFromInfoPlist() -> String? { + let frameworkBundle = Bundle(for: O5AppAttestService.self) + return frameworkBundle.object(forInfoDictionaryKey: "OmnipodKitTeamIdentifier") as? String + } + // MARK: - Server API private func getChallenge() async throws -> String { From 7e8c709078eb04769bcb23ff5ba09fd71e807382 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sun, 10 May 2026 15:04:44 -0400 Subject: [PATCH 12/27] show fetch progress --- Localization/Localizable.xcstrings | 64 ++++++- .../PumpManagerUI/Views/O5KeyFetchView.swift | 61 ++++-- .../Views/PodCertificatesView.swift | 177 +++++++++++++----- OmnipodKit/Services/O5AppAttestService.swift | 62 +++++- 4 files changed, 290 insertions(+), 74 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 0f70764..8c7e1b2 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -3401,6 +3401,10 @@ } } }, + "%@" : { + "comment" : "A label displaying an error message.", + "isCommentAutoGenerated" : true + }, "%@ ago" : { "comment" : "Format string for last status date on pod details screen", "extractionState" : "manual", @@ -7613,10 +7617,6 @@ } } }, - "An error occurred: %@" : { - "comment" : "An error message displayed in the checkout view if there was a problem fetching the user's O5 certificate. The text inside the parentheses will be replaced with the actual error message.", - "isCommentAutoGenerated" : true - }, "Are you sure you want to cancel Pod setup?" : { "comment" : "Alert title for cancel pairing modal", "extractionState" : "manual", @@ -8263,6 +8263,9 @@ } } }, + "Attesting with Apple…" : { + "comment" : "O5 fetch progress: attestation" + }, "Auto-off" : { "comment" : "Description for auto-off alert", "extractionState" : "manual", @@ -10693,6 +10696,9 @@ } } }, + "Built-in (compiled into app)" : { + "comment" : "O5 cert source: built-in" + }, "Cancel" : { "comment" : "Button title for cancelling confidence reminders edit\nButton title for cancelling low reservoir edit\nButton title for cancelling scheduled reminder date edit\nButton title for cancelling silence pod edit\nCancel button\nCancel button for O5 key fetch\nCancel button text in navigation bar on insert cannula screen\nCancel button text in navigation bar on pair pod UI\nCancel button title\nPairing interface navigation bar button text for cancel action", "extractionState" : "manual", @@ -12313,6 +12319,9 @@ } } }, + "Checking device support…" : { + "comment" : "O5 fetch progress: device support" + }, "Checking Insertion" : { "comment" : "Insert cannula action button accessibility label checking insertion", "extractionState" : "manual", @@ -17821,8 +17830,8 @@ } } }, - "Downloading certificate..." : { - "comment" : "O5 key fetch loading text" + "Downloading certificate…" : { + "comment" : "O5 fetch progress: download" }, "Empty reservoir" : { "comment" : "Description for Empty reservoir pod fault", @@ -20254,6 +20263,17 @@ } } }, + "Failed at step %d of %d: %@" : { + "comment" : "Format for fetch failure with step number", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed at step %1$d of %2$d: %3$@" + } + } + } + }, "Failed to Cancel Manual Basal" : { "comment" : "Alert title for failing to cancel manual basal error", "extractionState" : "manual", @@ -23008,6 +23028,9 @@ } } }, + "Fetched (downloaded from API)" : { + "comment" : "O5 cert source: fetched" + }, "Fill a new %1$@ pod with U-100 Insulin (leave %2$@ needle cap on)." : { "comment" : "Label text for step 1 of non-Eros pair pod instructions (1: pod type name) (2: pod tab color", "extractionState" : "manual", @@ -24307,6 +24330,9 @@ "Forget Saved Certificate" : { "comment" : "Confirm destructive forget action\nDestructive button to remove a saved O5 certificate" }, + "Generating App Attest key…" : { + "comment" : "O5 fetch progress: generate key" + }, "Greater than %1$@ units remaining at %2$@" : { "comment" : "Accessibility format string for (1: localized volume)(2: time)", "extractionState" : "manual", @@ -24958,6 +24984,12 @@ } } }, + "Import .o5keypair file" : { + "comment" : "Toolbar action to import an o5keypair file" + }, + "Imported (.o5keypair file)" : { + "comment" : "O5 cert source: imported" + }, "Incorrect Response" : { "comment" : "Error message description for PeripheralManagerError.incorrectResponse", "extractionState" : "manual", @@ -52509,6 +52541,12 @@ } } }, + "Requesting server challenge…" : { + "comment" : "O5 fetch progress: challenge" + }, + "Resolving app identity…" : { + "comment" : "O5 fetch progress: team id / bundle id" + }, "Resume" : { "comment" : "Pump Event title for UnfinalizedDose with doseType of .resume", "extractionState" : "manual", @@ -58177,6 +58215,20 @@ } } }, + "Starting…" : { + "comment" : "O5 key fetch initial loading text" + }, + "Step %d of %d" : { + "comment" : "Step counter, e.g. 'Step 2 of 6'", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Step %1$d of %2$d" + } + } + } + }, "Suspend" : { "comment" : "Pump Event title for UnfinalizedDose with doseType of .suspend", "extractionState" : "manual", diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift index 55095fb..fb8c311 100644 --- a/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift +++ b/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift @@ -12,6 +12,7 @@ import LoopKitUI struct O5KeyFetchView: View { @State private var errorMessage: String? + @State private var currentStep: O5KeyFetchProgress? let onKeypairReceived: (O5RegistrationData) -> Void let onCancel: () -> Void @@ -25,7 +26,18 @@ struct O5KeyFetchView: View { Image(systemName: "exclamationmark.triangle") .font(.largeTitle) .foregroundColor(.red) - Text("An error occurred: \(errorMessage)") + if let step = currentStep { + Text(String(format: LocalizedString("Failed at step %d of %d: %@", + comment: "Format for fetch failure with step number"), + step.index, + O5KeyFetchProgress.totalSteps, + step.localizedDescription)) + .foregroundColor(.secondary) + .font(.footnote) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + Text("\(errorMessage)") .foregroundColor(.red) .font(.subheadline) .multilineTextAlignment(.center) @@ -39,10 +51,24 @@ struct O5KeyFetchView: View { } } else { VStack(spacing: 16) { - ProgressView() - .scaleEffect(1.5) - Text(LocalizedString("Downloading certificate...", comment: "O5 key fetch loading text")) - .foregroundColor(.secondary) + ProgressView(value: progressFraction) + .progressViewStyle(.linear) + .padding(.horizontal, 40) + + if let step = currentStep { + Text(String(format: LocalizedString("Step %d of %d", + comment: "Step counter, e.g. 'Step 2 of 6'"), + step.index, + O5KeyFetchProgress.totalSteps)) + .font(.caption) + .foregroundColor(.secondary) + Text(step.localizedDescription) + .foregroundColor(.primary) + .multilineTextAlignment(.center) + } else { + Text(LocalizedString("Starting…", comment: "O5 key fetch initial loading text")) + .foregroundColor(.secondary) + } } } @@ -53,16 +79,27 @@ struct O5KeyFetchView: View { } } + private var progressFraction: Double { + guard let step = currentStep else { return 0 } + return Double(step.index) / Double(O5KeyFetchProgress.totalSteps) + } + private func performFetch() { errorMessage = nil + currentStep = nil - O5AppAttestService().fetchKeypair { result in - switch result { - case .success(let registrationData): - self.onKeypairReceived(registrationData) - case .failure(let error): - self.errorMessage = error.message + O5AppAttestService().fetchKeypair( + progress: { step in + self.currentStep = step + }, + completion: { result in + switch result { + case .success(let registrationData): + self.onKeypairReceived(registrationData) + case .failure(let error): + self.errorMessage = error.message + } } - } + ) } } diff --git a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift index 32f2375..3f8831e 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift @@ -2,14 +2,19 @@ // PodCertificatesView.swift // OmnipodKit // -// Displays the loaded O5 certificates and lets the user remove the saved ones -// individually. Built-in certificates (compiled into the binary) are listed -// read-only — they cannot be forgotten without rebuilding the app. +// Lists the loaded O5 certificates one row per controller; tapping a row +// navigates to a per-certificate detail view that holds the destructive +// "Forget Saved Certificate" action. A "+" toolbar button imports a +// .o5keypair file directly into the registry and Keychain. +// +// Built-in certificates (compiled into the binary) are listed read-only — +// they cannot be forgotten without rebuilding the app. // // Copyright © 2026 LoopKit Authors. All rights reserved. // import SwiftUI +import UniformTypeIdentifiers import LoopKit import LoopKitUI @@ -22,13 +27,10 @@ struct PodCertificatesView: View { } @State private var rows: [Row] = [] - @State private var pendingForget: UInt32? = nil + @State private var showingFileImporter = false + @State private var importError: String? private let title = LocalizedString("Pod Certificate Details", comment: "navigation title for pod certificate details") - private let confirmMessage = LocalizedString( - "You will be unable to pair to an Omnipod 5 pod until you reconnect to the internet to download a new certificate.", - comment: "Confirmation message when forgetting a saved O5 certificate" - ) var body: some View { List { @@ -39,44 +41,48 @@ struct PodCertificatesView: View { } } else { ForEach(rows) { row in - Section(header: Text(String(format: "Controller 0x%08X", row.data.controllerId))) { - Text(dump(for: row)) - .font(Font.system(size: 12).monospaced()) - .textSelection(.enabled) - - if row.source != .builtIn { - Button(role: .destructive) { - pendingForget = row.data.controllerId - } label: { - Text(LocalizedString("Forget Saved Certificate", comment: "Destructive button to remove a saved O5 certificate")) - } + NavigationLink(destination: PodCertificateDetailView( + data: row.data, + source: row.source, + onForgotten: { reload() } + )) { + VStack(alignment: .leading, spacing: 4) { + Text(String(format: "Controller 0x%08X", row.data.controllerId)) + .foregroundColor(.primary) + Text(label(for: row.source)) + .font(.caption) + .foregroundColor(.secondary) } } } } + + if let importError { + Section { + Text(importError) + .foregroundColor(.red) + .font(.subheadline) + } + } } .insetGroupedListStyle() .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) - .task { reload() } - .confirmationDialog( - confirmMessage, - isPresented: Binding( - get: { pendingForget != nil }, - set: { if !$0 { pendingForget = nil } } - ), - titleVisibility: .visible - ) { - Button(LocalizedString("Forget Saved Certificate", comment: "Confirm destructive forget action"), role: .destructive) { - if let controllerId = pendingForget { - forget(controllerId: controllerId) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + importError = nil + showingFileImporter = true + } label: { + Image(systemName: "plus") } - pendingForget = nil - } - Button(LocalizedString("Cancel", comment: "Cancel button"), role: .cancel) { - pendingForget = nil + .accessibilityLabel(LocalizedString("Import .o5keypair file", comment: "Toolbar action to import an o5keypair file")) } } + .fileImporter(isPresented: $showingFileImporter, allowedContentTypes: [.json, .item]) { result in + handleImport(result) + } + .task { reload() } } private func reload() { @@ -92,21 +98,100 @@ struct PodCertificatesView: View { } } - private func forget(controllerId: UInt32) { - try? O5CertificateKeychain.delete(controllerId: controllerId) - O5RegistrationData.remove(controllerId: controllerId) - reload() + private func handleImport(_ result: Result) { + switch result { + case .success(let url): + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + + guard let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let registrationData = O5RegistrationData.fromJSON(json) + else { + importError = LocalizedString("The selected file is not a valid .o5keypair file.", comment: "Error when o5keypair file import fails") + return + } + O5RegistrationData.install(registrationData, source: .imported) + try? O5CertificateKeychain.save(registrationData, source: .imported) + importError = nil + reload() + case .failure(let error): + importError = error.localizedDescription + } + } + + private func label(for source: O5RegistrationSource) -> String { + switch source { + case .builtIn: return LocalizedString("Built-in (compiled into app)", comment: "O5 cert source: built-in") + case .imported: return LocalizedString("Imported (.o5keypair file)", comment: "O5 cert source: imported") + case .fetched: return LocalizedString("Fetched (downloaded from API)", comment: "O5 cert source: fetched") + } + } +} + +struct PodCertificateDetailView: View { + + let data: O5RegistrationData + let source: O5RegistrationSource + let onForgotten: () -> Void + + @Environment(\.presentationMode) private var presentationMode + @State private var pendingForget = false + + private let confirmMessage = LocalizedString( + "You will be unable to pair to an Omnipod 5 pod until you reconnect to the internet to download a new certificate.", + comment: "Confirmation message when forgetting a saved O5 certificate" + ) + + var body: some View { + List { + Section { + Text(dump()) + .font(Font.system(size: 12).monospaced()) + .textSelection(.enabled) + } + + if source != .builtIn { + Section { + Button(role: .destructive) { + pendingForget = true + } label: { + Text(LocalizedString("Forget Saved Certificate", comment: "Destructive button to remove a saved O5 certificate")) + } + } + } + } + .insetGroupedListStyle() + .navigationTitle(String(format: "Controller 0x%08X", data.controllerId)) + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog( + confirmMessage, + isPresented: $pendingForget, + titleVisibility: .visible + ) { + Button(LocalizedString("Forget Saved Certificate", comment: "Confirm destructive forget action"), role: .destructive) { + forget() + } + Button(LocalizedString("Cancel", comment: "Cancel button"), role: .cancel) {} + } + } + + private func forget() { + try? O5CertificateKeychain.delete(controllerId: data.controllerId) + O5RegistrationData.remove(controllerId: data.controllerId) + onForgotten() + presentationMode.wrappedValue.dismiss() } - private func dump(for row: Row) -> String { + private func dump() -> String { var lines: [String] = [] lines.append("## O5RegistrationData") - lines.append("* source: \(label(for: row.source))") - lines.append(String(format: "* controllerId: %u (0x%08X)", row.data.controllerId, row.data.controllerId)) - lines.append("* privateKey: \(row.data.privateKeyHex)") - lines.append("* publicKey: \(row.data.publicKeyHex)") - lines.append("* intermediateCA: \(row.data.intermediateCABase64)") - lines.append("* tlsCertificate: \(row.data.tlsCertificateBase64)") + lines.append("* source: \(label(for: source))") + lines.append(String(format: "* controllerId: %u (0x%08X)", data.controllerId, data.controllerId)) + lines.append("* privateKey: \(data.privateKeyHex)") + lines.append("* publicKey: \(data.publicKeyHex)") + lines.append("* intermediateCA: \(data.intermediateCABase64)") + lines.append("* tlsCertificate: \(data.tlsCertificateBase64)") return lines.joined(separator: "\n") } diff --git a/OmnipodKit/Services/O5AppAttestService.swift b/OmnipodKit/Services/O5AppAttestService.swift index 0cc0b2d..83617cc 100644 --- a/OmnipodKit/Services/O5AppAttestService.swift +++ b/OmnipodKit/Services/O5AppAttestService.swift @@ -23,6 +23,37 @@ struct O5AuthError: Error { } } +/// Ordered phases of the keypair fetch flow. UI consumers can use `index` / +/// `totalSteps` to drive a determinate progress bar. +enum O5KeyFetchProgress: Int, CaseIterable { + case checkingDeviceSupport + case resolvingAppIdentity + case generatingAttestKey + case requestingChallenge + case attestingWithApple + case downloadingCertificate + + var index: Int { rawValue + 1 } + static var totalSteps: Int { Self.allCases.count } + + var localizedDescription: String { + switch self { + case .checkingDeviceSupport: + return LocalizedString("Checking device support…", comment: "O5 fetch progress: device support") + case .resolvingAppIdentity: + return LocalizedString("Resolving app identity…", comment: "O5 fetch progress: team id / bundle id") + case .generatingAttestKey: + return LocalizedString("Generating App Attest key…", comment: "O5 fetch progress: generate key") + case .requestingChallenge: + return LocalizedString("Requesting server challenge…", comment: "O5 fetch progress: challenge") + case .attestingWithApple: + return LocalizedString("Attesting with Apple…", comment: "O5 fetch progress: attestation") + case .downloadingCertificate: + return LocalizedString("Downloading certificate…", comment: "O5 fetch progress: download") + } + } +} + class O5AppAttestService { private let session: URLSession = { @@ -32,11 +63,18 @@ class O5AppAttestService { }() /// Runs the full App Attest + keypair fetch flow. - /// Calls completion on the main queue. - func fetchKeypair(completion: @escaping (Result) -> Void) { + /// Calls `progress` on the main queue before starting each phase, then `completion` + /// on the main queue with the final result. + func fetchKeypair( + progress: @escaping (O5KeyFetchProgress) -> Void = { _ in }, + completion: @escaping (Result) -> Void + ) { + let report: (O5KeyFetchProgress) -> Void = { step in + DispatchQueue.main.async { progress(step) } + } Task { do { - let result = try await performFetchFlow() + let result = try await performFetchFlow(progress: report) DispatchQueue.main.async { completion(.success(result)) } } catch let error as O5AuthError { DispatchQueue.main.async { completion(.failure(error)) } @@ -50,25 +88,29 @@ class O5AppAttestService { // MARK: - Async flow - private func performFetchFlow() async throws -> O5RegistrationData { + private func performFetchFlow(progress: (O5KeyFetchProgress) -> Void) async throws -> O5RegistrationData { + progress(.checkingDeviceSupport) let attestService = DCAppAttestService.shared - guard attestService.isSupported else { throw O5AuthError(message: "App Attest is not supported on this device.") } - // Step 1: Generate a fresh App Attest key + // Resolve app identity early so failures (e.g. missing team ID) surface before + // we burn an App Attest key generation, and so the user sees which step failed. + progress(.resolvingAppIdentity) + let appId = try getAppId() + + progress(.generatingAttestKey) let keyId = try await generateKey(attestService) - // Step 2: Get challenge from server + progress(.requestingChallenge) let challenge = try await getChallenge() - // Step 3: Attest the key with Apple + progress(.attestingWithApple) let challengeHash = Data(SHA256.hash(data: Data(challenge.utf8))) let attestation = try await attestKey(attestService, keyId: keyId, clientDataHash: challengeHash) - // Step 4: Exchange attestation for a keypair in a single request. - let appId = try getAppId() + progress(.downloadingCertificate) return try await claimKeypair( attestation: attestation, keyId: keyId, From 8ca74ac17075a321fd222057b48677b5d564d0c8 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Mon, 11 May 2026 12:18:01 -0400 Subject: [PATCH 13/27] also import LoopConfigOverride --- Config.xcconfig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index cf27a8b..0eac5ab 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -1,3 +1,9 @@ // OmnipodKit build configuration -// Inherits overrides from the parent project's ConfigOverride.xcconfig + +// Inherits overrides from Trio's ConfigOverride.xcconfig (if present) #include? "../ConfigOverride.xcconfig" +#include? "../../ConfigOverride.xcconfig" + +// Inherits overrides from Loop's LoopConfigOverride.xcconfig (if present) +#include? "../LoopConfigOverride.xcconfig" +#include? "../../LoopConfigOverride.xcconfig" From b84cbad2df7e4a9b828032496b9229781d3f60b9 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Tue, 12 May 2026 00:17:15 -0400 Subject: [PATCH 14/27] Hide privateKey, intermediateCA, and tlsCertificate --- OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift index 3f8831e..9eb81c6 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift @@ -188,10 +188,10 @@ struct PodCertificateDetailView: View { lines.append("## O5RegistrationData") lines.append("* source: \(label(for: source))") lines.append(String(format: "* controllerId: %u (0x%08X)", data.controllerId, data.controllerId)) - lines.append("* privateKey: \(data.privateKeyHex)") + //lines.append("* privateKey: \(data.privateKeyHex)") lines.append("* publicKey: \(data.publicKeyHex)") - lines.append("* intermediateCA: \(data.intermediateCABase64)") - lines.append("* tlsCertificate: \(data.tlsCertificateBase64)") + //lines.append("* intermediateCA: \(data.intermediateCABase64)") + //lines.append("* tlsCertificate: \(data.tlsCertificateBase64)") return lines.joined(separator: "\n") } From a7075ae68f296e9cbf9475119e7363109b63f097 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Tue, 19 May 2026 19:50:47 -0400 Subject: [PATCH 15/27] Update PodState with O5 controller ID/pod ID when new cert is added --- OmnipodKit/PumpManager/OmniPumpManager.swift | 9 +++++++++ .../ViewControllers/OmniUICoordinator.swift | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 12857ac..386d2c0 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -973,6 +973,15 @@ extension OmniPumpManager { // MARK: - Pod comms + /// Refresh the cached O5 controllerId / podId from the cert store after the + /// user has fetched or imported a new certificate. Only valid before a pod + /// session exists; rotating these mid-session would orphan a live pod since + /// the values are baked into the session keys derived at pairing time. + func refreshO5IdsFromCertStore() { + guard state.podType.isO5, state.podState == nil else { return } + prepForNewPod() + } + private func prepForNewPod() { let podType = state.podType diff --git a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift index 2968507..0d8a947 100644 --- a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift +++ b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift @@ -203,7 +203,10 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi case .o5KeySetup: let view = O5KeySetupView( o5KeypairsNotAvailable: O5CertificateStore.isEmpty, - didContinue: { [weak self] in self?.stepFinished() }, + didContinue: { [weak self] in + self?.pumpManager.refreshO5IdsFromCertStore() + self?.stepFinished() + }, didCancel: { [weak self] in self?.setupCanceled() } ) let hostedView = hostingController(rootView: view) From bdd771848f40eb8d50e7f559abab0f110e8281ba Mon Sep 17 00:00:00 2001 From: James Woglom Date: Tue, 19 May 2026 20:44:25 -0400 Subject: [PATCH 16/27] Remove o5keypair file reference on pod cert page --- .../PumpManagerUI/Views/O5KeySetupView.swift | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift index 01f32fd..24ac898 100644 --- a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift +++ b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import UniformTypeIdentifiers import LoopKit import LoopKitUI @@ -16,8 +15,6 @@ struct O5KeySetupView: View { @State private var o5KeypairsNotAvailable: Bool @State private var showingFetchSheet = false - @State private var showingFileImporter = false - @State private var fileImportError: String? private var didContinue: () -> Void private var didCancel: () -> Void @@ -44,20 +41,6 @@ struct O5KeySetupView: View { .padding(.vertical, 4) } } - - if o5KeypairsNotAvailable { - Section { - Button(action: { showingFileImporter = true }) { - Text(LocalizedString("Have an '.o5keypair' file to use instead?", comment: "Link to import an o5keypair file")) - } - - if let fileImportError = fileImportError { - Text(fileImportError) - .foregroundColor(.red) - .font(.subheadline) - } - } - } } .insetGroupedListStyle() @@ -80,27 +63,6 @@ struct O5KeySetupView: View { }) } } - .fileImporter(isPresented: $showingFileImporter, allowedContentTypes: [.json, .item]) { result in - fileImportError = nil - switch result { - case .success(let url): - let accessed = url.startAccessingSecurityScopedResource() - defer { if accessed { url.stopAccessingSecurityScopedResource() } } - - guard let data = try? Data(contentsOf: url), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let registrationData = O5RegistrationData.fromJSON(json) - else { - fileImportError = LocalizedString("The selected file is not a valid .o5keypair file.", comment: "Error when o5keypair file import fails") - return - } - O5RegistrationData.install(registrationData, source: .imported) - try? O5CertificateKeychain.save(registrationData, source: .imported) - o5KeypairsNotAvailable = false - case .failure(let error): - fileImportError = error.localizedDescription - } - } .sheet(isPresented: $showingFetchSheet) { NavigationView { O5KeyFetchView( From 165d3de56e63f258bf41cfee1b7097caf17b7f5d Mon Sep 17 00:00:00 2001 From: James Woglom Date: Tue, 19 May 2026 21:10:24 -0400 Subject: [PATCH 17/27] Update certificate download message --- Localization/Localizable.xcstrings | 5 +---- OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 8c7e1b2..02d840a 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -24495,9 +24495,6 @@ } } }, - "Have an '.o5keypair' file to use instead?" : { - "comment" : "Link to import an o5keypair file" - }, "hour" : { "comment" : "Unit for singular hour in pod life remaining", "extractionState" : "manual", @@ -69084,7 +69081,7 @@ } } }, - "We need to briefly connect to the internet to download a certificate in order to pair Omnipod 5 pods. An internet connection won't be required after you complete this one-time step." : { + "When you hit 'Continue,' a certificate will be downloaded and stored in order to pair with Omnipod 5 Pods." : { "comment" : "Description when O5 keypairs are not available" }, "Yes" : { diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift index 24ac898..9a89e61 100644 --- a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift +++ b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift @@ -29,7 +29,7 @@ struct O5KeySetupView: View { List { Section { if o5KeypairsNotAvailable { - Text(LocalizedString("We need to briefly connect to the internet to download a certificate in order to pair Omnipod 5 pods. An internet connection won't be required after you complete this one-time step.", comment: "Description when O5 keypairs are not available")) + Text(LocalizedString("When you hit 'Continue,' a certificate will be downloaded and stored in order to pair with Omnipod 5 Pods.", comment: "Description when O5 keypairs are not available")) .padding(.vertical, 4) } else { HStack(spacing: 12) { From 40424969f8de0db979f0ce2ca3cd0d93e70a91af Mon Sep 17 00:00:00 2001 From: James Woglom Date: Tue, 19 May 2026 21:13:27 -0400 Subject: [PATCH 18/27] check internet connection and server status --- Localization/Localizable.xcstrings | 12 +++ OmnipodKit/Services/O5AppAttestService.swift | 86 ++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 02d840a..8ba3196 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -12322,6 +12322,18 @@ "Checking device support…" : { "comment" : "O5 fetch progress: device support" }, + "Checking Internet connection…" : { + "comment" : "O5 fetch progress: internet pre-check" + }, + "Checking server status…" : { + "comment" : "O5 fetch progress: server status" + }, + "Could not connect to the internet to fetch an Omnipod 5 Pod Certificate. Please connect to Wi-Fi or Cellular Data and try again." : { + "comment" : "O5 fetch failure: offline at pre-flight" + }, + "The required server is temporarily unavailable. Please try again later." : { + "comment" : "O5 fetch: keymanager unavailable or malformed status response" + }, "Checking Insertion" : { "comment" : "Insert cannula action button accessibility label checking insertion", "extractionState" : "manual", diff --git a/OmnipodKit/Services/O5AppAttestService.swift b/OmnipodKit/Services/O5AppAttestService.swift index 83617cc..3e29572 100644 --- a/OmnipodKit/Services/O5AppAttestService.swift +++ b/OmnipodKit/Services/O5AppAttestService.swift @@ -8,8 +8,10 @@ import Foundation import CryptoKit import DeviceCheck +import Network let o5KeyManagerBaseURL = "https://api.osaid-keymanager.org" +private let omnipodkitApiVersion = "1.0.0" struct O5AuthError: Error { let message: String @@ -26,6 +28,8 @@ struct O5AuthError: Error { /// Ordered phases of the keypair fetch flow. UI consumers can use `index` / /// `totalSteps` to drive a determinate progress bar. enum O5KeyFetchProgress: Int, CaseIterable { + case checkingInternetConnection + case checkingKeymanagerServiceStatus case checkingDeviceSupport case resolvingAppIdentity case generatingAttestKey @@ -38,6 +42,10 @@ enum O5KeyFetchProgress: Int, CaseIterable { var localizedDescription: String { switch self { + case .checkingInternetConnection: + return LocalizedString("Checking Internet connection…", comment: "O5 fetch progress: internet pre-check") + case .checkingKeymanagerServiceStatus: + return LocalizedString("Checking server status…", comment: "O5 fetch progress: server status") case .checkingDeviceSupport: return LocalizedString("Checking device support…", comment: "O5 fetch progress: device support") case .resolvingAppIdentity: @@ -89,6 +97,12 @@ class O5AppAttestService { // MARK: - Async flow private func performFetchFlow(progress: (O5KeyFetchProgress) -> Void) async throws -> O5RegistrationData { + progress(.checkingInternetConnection) + try await checkInternetConnection() + + progress(.checkingKeymanagerServiceStatus) + try await checkServerStatus() + progress(.checkingDeviceSupport) let attestService = DCAppAttestService.shared guard attestService.isSupported else { @@ -119,6 +133,78 @@ class O5AppAttestService { ) } + // MARK: - Pre-flight checks + + /// One-shot link-layer reachability check. Throws an offline-flavored + /// `O5AuthError` when the device has no usable path within `timeout`. + /// Cheap optimistic check — cannot detect captive portals or DNS + /// failures; those still surface as URLErrors from later HTTP requests. + private func checkInternetConnection(timeout: TimeInterval = 2.0) async throws { + let monitor = NWPathMonitor() + defer { monitor.cancel() } + + let satisfied: Bool = await withCheckedContinuation { continuation in + let queue = DispatchQueue(label: "org.nightscout.o5-reachability") + var resumed = false + let resume: (Bool) -> Void = { value in + queue.async { + guard !resumed else { return } + resumed = true + continuation.resume(returning: value) + } + } + monitor.pathUpdateHandler = { path in + resume(path.status == .satisfied) + } + monitor.start(queue: queue) + queue.asyncAfter(deadline: .now() + timeout) { + resume(false) + } + } + + if !satisfied { + throw O5AuthError(message: LocalizedString( + "Could not connect to the internet to fetch an Omnipod 5 Pod Certificate. Please connect to Wi-Fi or Cellular Data and try again.", + comment: "O5 fetch failure: offline at pre-flight")) + } + } + + /// Calls the OSAID Keymanager service-status endpoint. If the server + /// reports `available: false`, the user-facing `message` is surfaced + /// verbatim and the flow aborts. Non-2xx HTTP and transport failures + /// flow through the existing `performRequest` → `authError` path. + /// Unparseable 2xx responses are treated as failures (fail-closed), + /// not as implicit availability. + private func checkServerStatus() async throws { + var request = URLRequest(url: URL(string: "\(o5KeyManagerBaseURL)/api/status/ios")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "omnipodkit_api_version": omnipodkitApiVersion, + ]) + + let (data, response) = try await performRequest(request) + + let unavailable = LocalizedString( + "The required server is temporarily unavailable. Please try again later.", + comment: "O5 fetch: keymanager unavailable or malformed status response") + + guard let json = parseJSON(data) else { + throw O5AuthError(message: unavailable, httpStatusCode: response.statusCode) + } + guard let available = json["available"] as? Bool else { + throw O5AuthError(message: unavailable, httpStatusCode: response.statusCode) + } + + if available { return } + + let trimmed = (json["message"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let displayed = (trimmed?.isEmpty == false) ? trimmed! : unavailable + throw O5AuthError(message: displayed, httpStatusCode: response.statusCode) + } + // MARK: - App Attest private func generateKey(_ service: DCAppAttestService) async throws -> String { From 0e2f37ca39b561777ab464eea3f51f60965521b8 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Tue, 19 May 2026 22:33:55 -0400 Subject: [PATCH 19/27] Move 'Pod Certificate Details' > O5 specific 'Pod Diagnostics', collapse single certificate, update text strings --- Localization/Localizable.xcstrings | 9 +- .../Views/OmniSettingsView.swift | 9 ++ .../Views/PodCertificatesView.swift | 144 ++++++++++++------ .../Views/PodDiagnosticsView.swift | 4 - 4 files changed, 112 insertions(+), 54 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 8ba3196..ce226bd 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -33586,7 +33586,7 @@ } }, "No certificates loaded." : { - "comment" : "Empty state for the Pod Certificate Details view" + "comment" : "Empty state for the Pod Certificate view" }, "No confidence reminders are used." : { "comment" : "Description for BeepPreference.silent", @@ -43310,8 +43310,8 @@ } } }, - "Pod Certificate Details" : { - "comment" : "Text for pod certificate details navigation link\nnavigation title for pod certificate details" + "Pod Certificate" : { + "comment" : "Text for pod certificate navigation link in OmniSettingsView\nnavigation title for pod certificate view" }, "Pod deactivated successfully. Continue." : { "comment" : "Deactivate pod action button accessibility label when deactivation complete", @@ -70395,6 +70395,9 @@ "You will be unable to pair to an Omnipod 5 pod until you reconnect to the internet to download a new certificate." : { "comment" : "Confirmation message when forgetting a saved O5 certificate" }, + "Your current Omnipod 5 pod session will not be affected, but you will be unable to pair to a new Omnipod 5 pod until you reconnect to the internet to download a new certificate." : { + "comment" : "Confirmation message when forgetting a saved O5 certificate while a pod session is active" + }, "You will now begin the process of configuring your reminders, selecting your insulin type, selecting the Omnipod pod type you will be using, filling your Pod with insulin, pairing to your device and placing it on your body." : { "comment" : "bodyText for PodSetupView", "extractionState" : "manual", diff --git a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift index 7af03c5..ee3e00e 100644 --- a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift @@ -582,6 +582,15 @@ struct OmniSettingsView: View { } } + if self.viewModel.podType.isO5 { + Section() { + NavigationLink(destination: PodCertificatesView(hasActivePod: !viewModel.noPod)) { + FrameworkLocalText("Pod Certificate", comment: "Text for pod certificate navigation link in OmniSettingsView") + .foregroundColor(Color.primary) + } + } + } + Section() { let localizedPodDiagnosticsStr = LocalizedString("Pod Diagnostics", comment: "Title for the Pod Diagnostic row and page") NavigationLink(destination: PodDiagnosticsView( diff --git a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift index 9eb81c6..0cda434 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift @@ -2,9 +2,9 @@ // PodCertificatesView.swift // OmnipodKit // -// Lists the loaded O5 certificates one row per controller; tapping a row -// navigates to a per-certificate detail view that holds the destructive -// "Forget Saved Certificate" action. A "+" toolbar button imports a +// Shows the loaded O5 certificate(s). For the common single-cert case the +// detail content is rendered inline; with multiple certs we fall back to a +// list-of-rows with per-cert navigation. A "+" toolbar button imports a // .o5keypair file directly into the registry and Keychain. // // Built-in certificates (compiled into the binary) are listed read-only — @@ -26,24 +26,34 @@ struct PodCertificatesView: View { var id: UInt32 { data.controllerId } } + let hasActivePod: Bool + @State private var rows: [Row] = [] @State private var showingFileImporter = false @State private var importError: String? - private let title = LocalizedString("Pod Certificate Details", comment: "navigation title for pod certificate details") + private let title = LocalizedString("Pod Certificate", comment: "navigation title for pod certificate view") var body: some View { List { if rows.isEmpty { Section { - Text(LocalizedString("No certificates loaded.", comment: "Empty state for the Pod Certificate Details view")) + Text(LocalizedString("No certificates loaded.", comment: "Empty state for the Pod Certificate view")) .foregroundColor(.secondary) } + } else if rows.count == 1 { + certificateContent( + data: rows[0].data, + source: rows[0].source, + hasActivePod: hasActivePod, + onForgotten: { reload() } + ) } else { ForEach(rows) { row in NavigationLink(destination: PodCertificateDetailView( data: row.data, source: row.source, + hasActivePod: hasActivePod, onForgotten: { reload() } )) { VStack(alignment: .leading, spacing: 4) { @@ -87,8 +97,8 @@ struct PodCertificatesView: View { private func reload() { // Make sure both built-in (dlsym) and Keychain-persisted certs are populated - // before we read the registry — opening this view in the diagnostics screen - // shouldn't depend on the pairing flow having run first. + // before we read the registry — opening this view shouldn't depend on the + // pairing flow having run first. _ = O5CertificateStore.isEmpty rows = O5RegistrationData.allValues @@ -133,37 +143,81 @@ struct PodCertificateDetailView: View { let data: O5RegistrationData let source: O5RegistrationSource + let hasActivePod: Bool let onForgotten: () -> Void @Environment(\.presentationMode) private var presentationMode - @State private var pendingForget = false - - private let confirmMessage = LocalizedString( - "You will be unable to pair to an Omnipod 5 pod until you reconnect to the internet to download a new certificate.", - comment: "Confirmation message when forgetting a saved O5 certificate" - ) var body: some View { List { - Section { - Text(dump()) - .font(Font.system(size: 12).monospaced()) - .textSelection(.enabled) - } - - if source != .builtIn { - Section { - Button(role: .destructive) { - pendingForget = true - } label: { - Text(LocalizedString("Forget Saved Certificate", comment: "Destructive button to remove a saved O5 certificate")) - } + certificateContent( + data: data, + source: source, + hasActivePod: hasActivePod, + onForgotten: { + onForgotten() + presentationMode.wrappedValue.dismiss() } - } + ) } .insetGroupedListStyle() .navigationTitle(String(format: "Controller 0x%08X", data.controllerId)) .navigationBarTitleDisplayMode(.inline) + } +} + +@ViewBuilder +fileprivate func certificateContent( + data: O5RegistrationData, + source: O5RegistrationSource, + hasActivePod: Bool, + onForgotten: @escaping () -> Void +) -> some View { + Section { + Text(dump(data: data, source: source)) + .font(Font.system(size: 12).monospaced()) + .textSelection(.enabled) + } + + if source != .builtIn { + Section { + ForgetCertificateButton( + controllerId: data.controllerId, + hasActivePod: hasActivePod, + onForgotten: onForgotten + ) + } + } +} + +private struct ForgetCertificateButton: View { + + let controllerId: UInt32 + let hasActivePod: Bool + let onForgotten: () -> Void + + @State private var pendingForget = false + + private var confirmMessage: String { + if hasActivePod { + return LocalizedString( + "Your current Omnipod 5 pod session will not be affected, but you will be unable to pair to a new Omnipod 5 pod until you reconnect to the internet to download a new certificate.", + comment: "Confirmation message when forgetting a saved O5 certificate while a pod session is active" + ) + } else { + return LocalizedString( + "You will be unable to pair to an Omnipod 5 pod until you reconnect to the internet to download a new certificate.", + comment: "Confirmation message when forgetting a saved O5 certificate" + ) + } + } + + var body: some View { + Button(role: .destructive) { + pendingForget = true + } label: { + Text(LocalizedString("Forget Saved Certificate", comment: "Destructive button to remove a saved O5 certificate")) + } .confirmationDialog( confirmMessage, isPresented: $pendingForget, @@ -177,29 +231,25 @@ struct PodCertificateDetailView: View { } private func forget() { - try? O5CertificateKeychain.delete(controllerId: data.controllerId) - O5RegistrationData.remove(controllerId: data.controllerId) + try? O5CertificateKeychain.delete(controllerId: controllerId) + O5RegistrationData.remove(controllerId: controllerId) onForgotten() - presentationMode.wrappedValue.dismiss() } +} - private func dump() -> String { - var lines: [String] = [] - lines.append("## O5RegistrationData") - lines.append("* source: \(label(for: source))") - lines.append(String(format: "* controllerId: %u (0x%08X)", data.controllerId, data.controllerId)) - //lines.append("* privateKey: \(data.privateKeyHex)") - lines.append("* publicKey: \(data.publicKeyHex)") - //lines.append("* intermediateCA: \(data.intermediateCABase64)") - //lines.append("* tlsCertificate: \(data.tlsCertificateBase64)") - return lines.joined(separator: "\n") - } +fileprivate func dump(data: O5RegistrationData, source: O5RegistrationSource) -> String { + var lines: [String] = [] + lines.append("## O5RegistrationData") + lines.append("* source: \(sourceLabel(source))") + lines.append(String(format: "* controllerId: %u (0x%08X)", data.controllerId, data.controllerId)) + lines.append("* publicKey: \(data.publicKeyHex)") + return lines.joined(separator: "\n") +} - private func label(for source: O5RegistrationSource) -> String { - switch source { - case .builtIn: return "Built-in (compiled into app)" - case .imported: return "Imported (.o5keypair file)" - case .fetched: return "Fetched (downloaded from API)" - } +fileprivate func sourceLabel(_ source: O5RegistrationSource) -> String { + switch source { + case .builtIn: return "Built-in (compiled into app)" + case .imported: return "Imported (.o5keypair file)" + case .fetched: return "Fetched (downloaded from API)" } } diff --git a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift index df24f31..97a9e71 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift @@ -97,10 +97,6 @@ struct PodDiagnosticsView: View { .foregroundColor(Color.primary) } - NavigationLink(destination: PodCertificatesView()) { - FrameworkLocalText("Pod Certificate Details", comment: "Text for pod certificate details navigation link") - .foregroundColor(Color.primary) - } } .insetGroupedListStyle() .navigationTitle(title) From 3e3452eba97b8fc17ce55eadd8e6fb361ff6da7a Mon Sep 17 00:00:00 2001 From: James Woglom Date: Tue, 19 May 2026 22:51:36 -0400 Subject: [PATCH 20/27] change invalid import to popup --- Localization/Localizable.xcstrings | 3 +++ .../Views/PodCertificatesView.swift | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index ce226bd..98eb631 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -24996,6 +24996,9 @@ "Import .o5keypair file" : { "comment" : "Toolbar action to import an o5keypair file" }, + "Import Failed" : { + "comment" : "Title of o5keypair import-failed alert" + }, "Imported (.o5keypair file)" : { "comment" : "O5 cert source: imported" }, diff --git a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift index 0cda434..212bfdc 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift @@ -67,13 +67,6 @@ struct PodCertificatesView: View { } } - if let importError { - Section { - Text(importError) - .foregroundColor(.red) - .font(.subheadline) - } - } } .insetGroupedListStyle() .navigationTitle(title) @@ -92,6 +85,20 @@ struct PodCertificatesView: View { .fileImporter(isPresented: $showingFileImporter, allowedContentTypes: [.json, .item]) { result in handleImport(result) } + .alert( + LocalizedString("Import Failed", comment: "Title of o5keypair import-failed alert"), + isPresented: Binding( + get: { importError != nil }, + set: { if !$0 { importError = nil } } + ), + presenting: importError + ) { _ in + Button(LocalizedString("OK", comment: "OK button for import-failed alert")) { + importError = nil + } + } message: { message in + Text(message) + } .task { reload() } } From 45fab927946e9f3f2c31955dff19edaeab57055a Mon Sep 17 00:00:00 2001 From: James Woglom Date: Tue, 19 May 2026 23:19:36 -0400 Subject: [PATCH 21/27] update recoverySuggestions --- Localization/Localizable.xcstrings | 31 +++++++--------- .../PumpManagerUI/Views/O5KeyFetchView.swift | 23 ++++++------ .../PumpManagerUI/Views/O5KeySetupView.swift | 2 +- OmnipodKit/Services/O5AppAttestService.swift | 36 ++++++++++++++----- 4 files changed, 53 insertions(+), 39 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 98eb631..f83fa0a 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -12328,11 +12328,20 @@ "Checking server status…" : { "comment" : "O5 fetch progress: server status" }, - "Could not connect to the internet to fetch an Omnipod 5 Pod Certificate. Please connect to Wi-Fi or Cellular Data and try again." : { - "comment" : "O5 fetch failure: offline at pre-flight" + "Could not connect to the internet to fetch an Omnipod 5 Pod Certificate." : { + "comment" : "O5 fetch failure: offline at pre-flight, primary line" }, - "The required server is temporarily unavailable. Please try again later." : { - "comment" : "O5 fetch: keymanager unavailable or malformed status response" + "Please connect to Wi-Fi or Cellular Data and try again." : { + "comment" : "O5 fetch failure: offline at pre-flight, recovery suggestion" + }, + "Please try again later." : { + "comment" : "O5 fetch: malformed status response, recovery suggestion" + }, + "The key-management server is temporarily unavailable." : { + "comment" : "O5 fetch: keymanager-reported unavailable, no message" + }, + "The key-management server is temporarily unavailable: received unexpected response." : { + "comment" : "O5 fetch: malformed status response, primary line" }, "Checking Insertion" : { "comment" : "Insert cannula action button accessibility label checking insertion", @@ -20275,17 +20284,6 @@ } } }, - "Failed at step %d of %d: %@" : { - "comment" : "Format for fetch failure with step number", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Failed at step %1$d of %2$d: %3$@" - } - } - } - }, "Failed to Cancel Manual Basal" : { "comment" : "Alert title for failing to cancel manual basal error", "extractionState" : "manual", @@ -37639,9 +37637,6 @@ } } }, - "Omnipod 5 Keys" : { - "comment" : "Title for O5 key fetch view" - }, "Omnipod 5 Pods have a clear needle tab with a 12-character LOT number typically starting with 'PH1'. The Pod's \"SmartAdjust\" technology will not be used for closed loop control." : { "comment" : "Description for Omnipod 5 pods", "extractionState" : "manual", diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift index fb8c311..de9f1c5 100644 --- a/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift +++ b/OmnipodKit/PumpManagerUI/Views/O5KeyFetchView.swift @@ -12,6 +12,7 @@ import LoopKitUI struct O5KeyFetchView: View { @State private var errorMessage: String? + @State private var errorDetail: String? @State private var currentStep: O5KeyFetchProgress? let onKeypairReceived: (O5RegistrationData) -> Void @@ -26,23 +27,21 @@ struct O5KeyFetchView: View { Image(systemName: "exclamationmark.triangle") .font(.largeTitle) .foregroundColor(.red) - if let step = currentStep { - Text(String(format: LocalizedString("Failed at step %d of %d: %@", - comment: "Format for fetch failure with step number"), - step.index, - O5KeyFetchProgress.totalSteps, - step.localizedDescription)) - .foregroundColor(.secondary) - .font(.footnote) - .multilineTextAlignment(.center) - .padding(.horizontal) - } Text("\(errorMessage)") .foregroundColor(.red) .font(.subheadline) .multilineTextAlignment(.center) .padding(.horizontal) + if let errorDetail = errorDetail { + Text(errorDetail) + .foregroundColor(.red) + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.top, 8) + } + Button(action: { performFetch() }) { Text(LocalizedString("Retry", comment: "O5 key fetch retry button")) .actionButtonStyle(.primary) @@ -86,6 +85,7 @@ struct O5KeyFetchView: View { private func performFetch() { errorMessage = nil + errorDetail = nil currentStep = nil O5AppAttestService().fetchKeypair( @@ -98,6 +98,7 @@ struct O5KeyFetchView: View { self.onKeypairReceived(registrationData) case .failure(let error): self.errorMessage = error.message + self.errorDetail = error.recoverySuggestion } } ) diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift index 9a89e61..df77c24 100644 --- a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift +++ b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift @@ -76,7 +76,7 @@ struct O5KeySetupView: View { showingFetchSheet = false } ) - .navigationTitle(LocalizedString("Omnipod 5 Keys", comment: "Title for O5 key fetch view")) + .navigationTitle(LocalizedString("Omnipod 5 Setup", comment: "Title for O5 key fetch view")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/OmnipodKit/Services/O5AppAttestService.swift b/OmnipodKit/Services/O5AppAttestService.swift index 3e29572..9e6e6f2 100644 --- a/OmnipodKit/Services/O5AppAttestService.swift +++ b/OmnipodKit/Services/O5AppAttestService.swift @@ -15,11 +15,13 @@ private let omnipodkitApiVersion = "1.0.0" struct O5AuthError: Error { let message: String + let recoverySuggestion: String? let httpStatusCode: Int? let underlyingError: Error? - init(message: String, httpStatusCode: Int? = nil, underlyingError: Error? = nil) { + init(message: String, recoverySuggestion: String? = nil, httpStatusCode: Int? = nil, underlyingError: Error? = nil) { self.message = message + self.recoverySuggestion = recoverySuggestion self.httpStatusCode = httpStatusCode self.underlyingError = underlyingError } @@ -163,9 +165,13 @@ class O5AppAttestService { } if !satisfied { - throw O5AuthError(message: LocalizedString( - "Could not connect to the internet to fetch an Omnipod 5 Pod Certificate. Please connect to Wi-Fi or Cellular Data and try again.", - comment: "O5 fetch failure: offline at pre-flight")) + throw O5AuthError( + message: LocalizedString( + "Could not connect to the internet to fetch an Omnipod 5 Pod Certificate.", + comment: "O5 fetch failure: offline at pre-flight, primary line"), + recoverySuggestion: LocalizedString( + "Please connect to Wi-Fi or Cellular Data and try again.", + comment: "O5 fetch failure: offline at pre-flight, recovery suggestion")) } } @@ -186,21 +192,33 @@ class O5AppAttestService { let (data, response) = try await performRequest(request) - let unavailable = LocalizedString( - "The required server is temporarily unavailable. Please try again later.", - comment: "O5 fetch: keymanager unavailable or malformed status response") + let malformedMessage = LocalizedString( + "The key-management server is temporarily unavailable: received unexpected response.", + comment: "O5 fetch: malformed status response, primary line") + let malformedRecovery = LocalizedString( + "Please try again later.", + comment: "O5 fetch: malformed status response, recovery suggestion") guard let json = parseJSON(data) else { - throw O5AuthError(message: unavailable, httpStatusCode: response.statusCode) + throw O5AuthError( + message: malformedMessage, + recoverySuggestion: malformedRecovery, + httpStatusCode: response.statusCode) } guard let available = json["available"] as? Bool else { - throw O5AuthError(message: unavailable, httpStatusCode: response.statusCode) + throw O5AuthError( + message: malformedMessage, + recoverySuggestion: malformedRecovery, + httpStatusCode: response.statusCode) } if available { return } let trimmed = (json["message"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) + let unavailable = LocalizedString( + "The key-management server is temporarily unavailable.", + comment: "O5 fetch: keymanager-reported unavailable, no message") let displayed = (trimmed?.isEmpty == false) ? trimmed! : unavailable throw O5AuthError(message: displayed, httpStatusCode: response.statusCode) } From 0216aecc0c0d9d379286e3720984e3acaed7a943 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Wed, 20 May 2026 00:47:03 -0400 Subject: [PATCH 22/27] Handle no certificate found --- Localization/Localizable.xcstrings | 166 +----------------- OmnipodKit/PumpManager/PodCommsSession.swift | 2 +- .../ViewControllers/OmniUICoordinator.swift | 3 + .../ViewModels/PairPodViewModel.swift | 14 ++ .../PumpManagerUI/Views/PairPodView.swift | 12 ++ 5 files changed, 36 insertions(+), 161 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index f83fa0a..ccde99d 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -51414,167 +51414,13 @@ "Ready to connect to an Omnipod 5 pod." : { "comment" : "Description when O5 keypairs are available" }, - "Rebuild app with needed certificate data" : { + "Retrieve an Omnipod 5 Pod Certificate to continue." : { "comment" : "Recovery suggestion with missing certificate", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "إعادة إنشاء التطبيق ببيانات الشهادة المطلوبة" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obnovení aplikace s potřebnými daty certifikátu" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Genopbyg appen med de nødvendige certifikatdata" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "App mit benötigten Zertifikatsdaten neu erstellen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rebuild app with needed certificate data" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reconstruir la aplicación con los datos del certificado necesarios" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rakenna sovellus uudelleen tarvittavilla varmenteen tiedoilla" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reconstruire l'application avec les données de certificat nécessaires" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בנה מחדש את האפליקציה עם נתוני האישור הדרושים" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Az alkalmazás újrakészítése a szükséges tanúsítványadatokkal" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ricostruire l'applicazione con i dati del certificato necessari" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "必要な証明書データでアプリを再構築する" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "필요한 인증서 데이터로 앱 다시 빌드" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gjenoppbygg appen med nødvendige sertifikatdata" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Herbouw app met benodigde certificaatgegevens" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odbuduj aplikację z wymaganymi danymi certyfikatu" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reconstruir a aplicação com os dados de certificado necessários" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reconstruiți aplicația cu datele de certificat necesare" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Пересоздайте приложение с необходимыми данными сертификата" - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obnovenie aplikácie s potrebnými údajmi certifikátu" - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ponovna vzpostavitev aplikacije s potrebnimi podatki o certifikatu" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Återskapa appen med nödvändiga certifikatdata" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uygulamayı gerekli sertifika verileriyle yeniden oluşturun" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перезберіть додаток з потрібними даними сертифіката" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Xây dựng lại ứng dụng với dữ liệu chứng chỉ cần thiết" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用所需证书数据重建应用程序" - } - } - } + "extractionState" : "manual" + }, + "Retrieve Pod Certificate" : { + "comment" : "Button text to navigate to O5 certificate download from a pair pod failure", + "extractionState" : "manual" }, "Ref: " : { "comment" : "PDM Ref string line", diff --git a/OmnipodKit/PumpManager/PodCommsSession.swift b/OmnipodKit/PumpManager/PodCommsSession.swift index 290d2b8..f89a314 100644 --- a/OmnipodKit/PumpManager/PodCommsSession.swift +++ b/OmnipodKit/PumpManager/PodCommsSession.swift @@ -186,7 +186,7 @@ extension PodCommsError: LocalizedError { case .setupNotComplete: return nil case .noCertificateFound: - return LocalizedString("Rebuild app with needed certificate data", comment: "Recovery suggestion with missing certificate") + return LocalizedString("Retrieve an Omnipod 5 Pod Certificate to continue.", comment: "Recovery suggestion with missing certificate") } } diff --git a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift index 0d8a947..f0d9781 100644 --- a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift +++ b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift @@ -287,6 +287,9 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi viewModel.didRequestDeactivation = { [weak self] in self?.navigateTo(.deactivate) } + viewModel.didRequestO5KeySetup = { [weak self] in + self?.navigateTo(.o5KeySetup) + } let view = hostingController(rootView: PairPodView(viewModel: viewModel).onAppear(perform: {UIApplication.shared.isIdleTimerDisabled = true}), onDisappear: {UIApplication.shared.isIdleTimerDisabled = false}) view.navigationItem.title = String(format: LocalizedString("Pair %1$@ Pod", comment: "Title for pod pairing screen (1: pod type brief name)"), self.podType.briefName) diff --git a/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift b/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift index ce56f84..726ab2a 100644 --- a/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift +++ b/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift @@ -178,6 +178,8 @@ class PairPodViewModel: ObservableObject, Identifiable { var didCancelSetup: (() -> Void)? + var didRequestO5KeySetup: (() -> Void)? + var podPairer: PodPairer var autoRetryAttempted: Bool @@ -283,6 +285,18 @@ enum OmniPairingError : LocalizedError { } */ + var isMissingO5Certificate: Bool { + switch self { + case .pumpManagerError(let error): + if case .configuration(let inner) = error, + let podCommsError = inner as? PodCommsError, + case .noCertificateFound = podCommsError { + return true + } + return false + } + } + var recoverable: Bool { switch self { case .pumpManagerError(let error): diff --git a/OmnipodKit/PumpManagerUI/Views/PairPodView.swift b/OmnipodKit/PumpManagerUI/Views/PairPodView.swift index aa52ba9..914e518 100644 --- a/OmnipodKit/PumpManagerUI/Views/PairPodView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PairPodView.swift @@ -89,6 +89,18 @@ struct PairPodView: View { .zIndex(1) } + if self.viewModel.error?.isMissingO5Certificate == true { + Button(action: { + self.viewModel.didRequestO5KeySetup?() + }) { + Text(LocalizedString("Retrieve Pod Certificate", comment: "Button text to navigate to O5 certificate download from a pair pod failure")) + .actionButtonStyle(.primary) + } + .accessibility(identifier: "button_retrieve_pod_certificate") + .disabled(self.viewModel.state.isProcessing) + .zIndex(1) + } + if (self.viewModel.error?.recoverable == false) { Button(action: { self.viewModel.continueButtonTapped() From 8364c15461e70479d69deb307dee5cb53db5fad9 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Wed, 20 May 2026 00:50:52 -0400 Subject: [PATCH 23/27] Route to selectPod when O5 pod type selected with no active pod and no certificates --- .../ViewControllers/OmniUICoordinator.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift index f0d9781..6db8bd0 100644 --- a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift +++ b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift @@ -516,6 +516,16 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi if self.podType == unknownOmnipodType { return .selectPodType // need to first select a pod type } + // O5 selected, no cert, no active pod: route through pod-type + // reselection so the user can either switch pod type or proceed + // into the O5 cert download (via resolveRoutingStep's + // .podTypeSelected → .o5KeySetup interception) instead of + // landing in Settings where Pair Pod would fail. + if self.podType == omnipod5Type + && O5CertificateStore.isEmpty + && pumpManager.podCommState == .noPod { + return .selectPodType + } return .settings } } From ec0ccaad0036172b6ea79e9b4839268f9b94a43a Mon Sep 17 00:00:00 2001 From: James Woglom Date: Wed, 20 May 2026 13:24:32 -0400 Subject: [PATCH 24/27] update copy --- Localization/Localizable.xcstrings | 8 ++++---- OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift | 2 +- OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index ccde99d..c8b0e20 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -33586,7 +33586,7 @@ } } }, - "No certificates loaded." : { + "No certificates loaded" : { "comment" : "Empty state for the Pod Certificate view" }, "No confidence reminders are used." : { @@ -51411,7 +51411,7 @@ } } }, - "Ready to connect to an Omnipod 5 pod." : { + "Ready to pair with an Omnipod 5 Pod." : { "comment" : "Description when O5 keypairs are available" }, "Retrieve an Omnipod 5 Pod Certificate to continue." : { @@ -70236,10 +70236,10 @@ } } }, - "You will be unable to pair to an Omnipod 5 pod until you reconnect to the internet to download a new certificate." : { + "You will be unable to pair with an Omnipod 5 Pod until you reconnect to the Internet to download a new certificate." : { "comment" : "Confirmation message when forgetting a saved O5 certificate" }, - "Your current Omnipod 5 pod session will not be affected, but you will be unable to pair to a new Omnipod 5 pod until you reconnect to the internet to download a new certificate." : { + "Your current Omnipod 5 pod session will not be affected, but you will be unable to pair with a new Omnipod 5 Pod until you reconnect to the Internet to download a new certificate." : { "comment" : "Confirmation message when forgetting a saved O5 certificate while a pod session is active" }, "You will now begin the process of configuring your reminders, selecting your insulin type, selecting the Omnipod pod type you will be using, filling your Pod with insulin, pairing to your device and placing it on your body." : { diff --git a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift index df77c24..c4f660a 100644 --- a/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift +++ b/OmnipodKit/PumpManagerUI/Views/O5KeySetupView.swift @@ -36,7 +36,7 @@ struct O5KeySetupView: View { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) .font(.title2) - Text(LocalizedString("Ready to connect to an Omnipod 5 pod.", comment: "Description when O5 keypairs are available")) + Text(LocalizedString("Ready to pair with an Omnipod 5 Pod.", comment: "Description when O5 keypairs are available")) } .padding(.vertical, 4) } diff --git a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift index 212bfdc..86002ac 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift @@ -38,7 +38,7 @@ struct PodCertificatesView: View { List { if rows.isEmpty { Section { - Text(LocalizedString("No certificates loaded.", comment: "Empty state for the Pod Certificate view")) + Text(LocalizedString("No certificates loaded", comment: "Empty state for the Pod Certificate view")) .foregroundColor(.secondary) } } else if rows.count == 1 { @@ -208,12 +208,12 @@ private struct ForgetCertificateButton: View { private var confirmMessage: String { if hasActivePod { return LocalizedString( - "Your current Omnipod 5 pod session will not be affected, but you will be unable to pair to a new Omnipod 5 pod until you reconnect to the internet to download a new certificate.", + "Your current Omnipod 5 pod session will not be affected, but you will be unable to pair with a new Omnipod 5 Pod until you reconnect to the Internet to download a new certificate.", comment: "Confirmation message when forgetting a saved O5 certificate while a pod session is active" ) } else { return LocalizedString( - "You will be unable to pair to an Omnipod 5 pod until you reconnect to the internet to download a new certificate.", + "You will be unable to pair with an Omnipod 5 Pod until you reconnect to the Internet to download a new certificate.", comment: "Confirmation message when forgetting a saved O5 certificate" ) } From 89d02a3c6762f5520ec928fc98938df3b371608a Mon Sep 17 00:00:00 2001 From: James Woglom Date: Wed, 20 May 2026 13:53:03 -0400 Subject: [PATCH 25/27] replace lowercase pod -> Pod --- Localization/Localizable.xcstrings | 2 +- OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index c8b0e20..b4410b7 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -70239,7 +70239,7 @@ "You will be unable to pair with an Omnipod 5 Pod until you reconnect to the Internet to download a new certificate." : { "comment" : "Confirmation message when forgetting a saved O5 certificate" }, - "Your current Omnipod 5 pod session will not be affected, but you will be unable to pair with a new Omnipod 5 Pod until you reconnect to the Internet to download a new certificate." : { + "Your current Omnipod 5 Pod session will not be affected, but you will be unable to pair with a new Omnipod 5 Pod until you reconnect to the Internet to download a new certificate." : { "comment" : "Confirmation message when forgetting a saved O5 certificate while a pod session is active" }, "You will now begin the process of configuring your reminders, selecting your insulin type, selecting the Omnipod pod type you will be using, filling your Pod with insulin, pairing to your device and placing it on your body." : { diff --git a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift index 86002ac..6f4a657 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodCertificatesView.swift @@ -208,7 +208,7 @@ private struct ForgetCertificateButton: View { private var confirmMessage: String { if hasActivePod { return LocalizedString( - "Your current Omnipod 5 pod session will not be affected, but you will be unable to pair with a new Omnipod 5 Pod until you reconnect to the Internet to download a new certificate.", + "Your current Omnipod 5 Pod session will not be affected, but you will be unable to pair with a new Omnipod 5 Pod until you reconnect to the Internet to download a new certificate.", comment: "Confirmation message when forgetting a saved O5 certificate while a pod session is active" ) } else { From 0c9411663c9f5e367a4decf3ca9530714e2b38fe Mon Sep 17 00:00:00 2001 From: James Woglom Date: Wed, 20 May 2026 13:54:02 -0400 Subject: [PATCH 26/27] capitalize Internet --- Localization/Localizable.xcstrings | 2 +- OmnipodKit/Services/O5AppAttestService.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index b4410b7..71cdc29 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -12328,7 +12328,7 @@ "Checking server status…" : { "comment" : "O5 fetch progress: server status" }, - "Could not connect to the internet to fetch an Omnipod 5 Pod Certificate." : { + "Could not connect to the Internet to fetch an Omnipod 5 Pod Certificate." : { "comment" : "O5 fetch failure: offline at pre-flight, primary line" }, "Please connect to Wi-Fi or Cellular Data and try again." : { diff --git a/OmnipodKit/Services/O5AppAttestService.swift b/OmnipodKit/Services/O5AppAttestService.swift index 9e6e6f2..b02c792 100644 --- a/OmnipodKit/Services/O5AppAttestService.swift +++ b/OmnipodKit/Services/O5AppAttestService.swift @@ -167,7 +167,7 @@ class O5AppAttestService { if !satisfied { throw O5AuthError( message: LocalizedString( - "Could not connect to the internet to fetch an Omnipod 5 Pod Certificate.", + "Could not connect to the Internet to fetch an Omnipod 5 Pod Certificate.", comment: "O5 fetch failure: offline at pre-flight, primary line"), recoverySuggestion: LocalizedString( "Please connect to Wi-Fi or Cellular Data and try again.", From acb6d59298af3905fd2ff4327d05c156a6195cb2 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Wed, 20 May 2026 13:59:48 -0400 Subject: [PATCH 27/27] Bump localization --- Localization/Localizable.xcstrings | 70 +++++++++++++++--------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 71cdc29..a2147be 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -12322,27 +12322,6 @@ "Checking device support…" : { "comment" : "O5 fetch progress: device support" }, - "Checking Internet connection…" : { - "comment" : "O5 fetch progress: internet pre-check" - }, - "Checking server status…" : { - "comment" : "O5 fetch progress: server status" - }, - "Could not connect to the Internet to fetch an Omnipod 5 Pod Certificate." : { - "comment" : "O5 fetch failure: offline at pre-flight, primary line" - }, - "Please connect to Wi-Fi or Cellular Data and try again." : { - "comment" : "O5 fetch failure: offline at pre-flight, recovery suggestion" - }, - "Please try again later." : { - "comment" : "O5 fetch: malformed status response, recovery suggestion" - }, - "The key-management server is temporarily unavailable." : { - "comment" : "O5 fetch: keymanager-reported unavailable, no message" - }, - "The key-management server is temporarily unavailable: received unexpected response." : { - "comment" : "O5 fetch: malformed status response, primary line" - }, "Checking Insertion" : { "comment" : "Insert cannula action button accessibility label checking insertion", "extractionState" : "manual", @@ -12505,6 +12484,12 @@ } } }, + "Checking Internet connection…" : { + "comment" : "O5 fetch progress: internet pre-check" + }, + "Checking server status…" : { + "comment" : "O5 fetch progress: server status" + }, "Checking..." : { "comment" : "Cannula insertion button text while checking insertion", "extractionState" : "manual", @@ -14935,6 +14920,9 @@ } } }, + "Could not connect to the Internet to fetch an Omnipod 5 Pod Certificate." : { + "comment" : "O5 fetch failure: offline at pre-flight, primary line" + }, "Critical Alerts" : { "comment" : "Title for critical alerts description", "extractionState" : "manual", @@ -37800,7 +37788,7 @@ } }, "Omnipod 5 Setup" : { - "comment" : "Title for the Omnipod 5 key setup screen" + "comment" : "Title for O5 key fetch view\nTitle for the Omnipod 5 key setup screen" }, "Omnipod Classic" : { "comment" : "Title string for Omnipod Classic", @@ -41202,6 +41190,9 @@ } } }, + "Please connect to Wi-Fi or Cellular Data and try again." : { + "comment" : "O5 fetch failure: offline at pre-flight, recovery suggestion" + }, "Please deactivate the pod. When deactivation is complete, you may pair a new pod." : { "comment" : "Instructions for deactivate pod when pod not on body", "extractionState" : "manual", @@ -42498,6 +42489,9 @@ } } }, + "Please try again later." : { + "comment" : "O5 fetch: malformed status response, recovery suggestion" + }, "Please try repositioning the pod or the RileyLink and try again" : { "comment" : "Recovery suggestion when no response is received from pod", "extractionState" : "manual", @@ -51414,14 +51408,6 @@ "Ready to pair with an Omnipod 5 Pod." : { "comment" : "Description when O5 keypairs are available" }, - "Retrieve an Omnipod 5 Pod Certificate to continue." : { - "comment" : "Recovery suggestion with missing certificate", - "extractionState" : "manual" - }, - "Retrieve Pod Certificate" : { - "comment" : "Button text to navigate to O5 certificate download from a pair pod failure", - "extractionState" : "manual" - }, "Ref: " : { "comment" : "PDM Ref string line", "extractionState" : "manual", @@ -53372,6 +53358,14 @@ } } }, + "Retrieve an Omnipod 5 Pod Certificate to continue." : { + "comment" : "Recovery suggestion with missing certificate", + "extractionState" : "manual" + }, + "Retrieve Pod Certificate" : { + "comment" : "Button text to navigate to O5 certificate download from a pair pod failure", + "extractionState" : "manual" + }, "Retrieving Pump Manager Details..." : { "comment" : "button title when retrieving pump manager details", "extractionState" : "manual", @@ -61970,6 +61964,12 @@ } } }, + "The key-management server is temporarily unavailable: received unexpected response." : { + "comment" : "O5 fetch: malformed status response, primary line" + }, + "The key-management server is temporarily unavailable." : { + "comment" : "O5 fetch: keymanager-reported unavailable, no message" + }, "The reminders above will not sound on your device when it is in Silent or Do Not Disturb mode. There are other critical Pod alerts that will sound on your device even when set to Silent or Do Not Disturb mode.\n\nThe Pod will also use audible beeps for all Pod reminders and alerts except when the Pod is Silenced." : { "comment" : "Description text for critical alerts", "extractionState" : "manual", @@ -70239,9 +70239,6 @@ "You will be unable to pair with an Omnipod 5 Pod until you reconnect to the Internet to download a new certificate." : { "comment" : "Confirmation message when forgetting a saved O5 certificate" }, - "Your current Omnipod 5 Pod session will not be affected, but you will be unable to pair with a new Omnipod 5 Pod until you reconnect to the Internet to download a new certificate." : { - "comment" : "Confirmation message when forgetting a saved O5 certificate while a pod session is active" - }, "You will now begin the process of configuring your reminders, selecting your insulin type, selecting the Omnipod pod type you will be using, filling your Pod with insulin, pairing to your device and placing it on your body." : { "comment" : "bodyText for PodSetupView", "extractionState" : "manual", @@ -70404,6 +70401,9 @@ } } }, + "Your current Omnipod 5 Pod session will not be affected, but you will be unable to pair with a new Omnipod 5 Pod until you reconnect to the Internet to download a new certificate." : { + "comment" : "Confirmation message when forgetting a saved O5 certificate while a pod session is active" + }, "Your insulin delivery will not be automatically adjusted until the temporary basal rate finishes or is canceled." : { "comment" : "Description text on manual temp basal action sheet", "extractionState" : "manual", @@ -71053,5 +71053,5 @@ } } }, - "version" : "1.0" -} + "version" : "1.1" +} \ No newline at end of file