From 88f58440571d66da6424a4d0070cc8eae44b536f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20D=2E=20Moreira?= Date: Wed, 6 Aug 2025 11:49:28 +0200 Subject: [PATCH 1/4] Adds timeouts to ConnectionFullStackTests --- .../Tests/ConnectionFullStackTests.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/FullStackTests/Tests/ConnectionFullStackTests.swift b/FullStackTests/Tests/ConnectionFullStackTests.swift index b2e18085b..0cc83c0ca 100644 --- a/FullStackTests/Tests/ConnectionFullStackTests.swift +++ b/FullStackTests/Tests/ConnectionFullStackTests.swift @@ -23,14 +23,14 @@ struct ConnectionFullStackTests { typealias Connection = USBSmartCardConnection - @Test("Single Connection") + @Test("Single Connection", .timeLimit(.minutes(1))) func singleConnection() async throws { let connection = try await Connection.connection() #expect(true, "✅ Got connection \(connection)") await connection.close(error: nil) } - @Test("Serial Connections") + @Test("Serial Connections", .timeLimit(.minutes(1))) func serialConnections() async throws { let firstConnection = try await Connection.connection() #expect(true, "✅ Got first connection \(firstConnection)") @@ -59,7 +59,7 @@ struct ConnectionFullStackTests { await secondConnection.close(error: nil) } - @Test("Connection Cancellation") + @Test("Connection Cancellation", .timeLimit(.minutes(1))) func connectionCancellation() async { let task1 = Task { try await Connection.connection() @@ -90,7 +90,7 @@ struct ConnectionFullStackTests { await connections.first?.close(error: nil) } - @Test("Send Manually") + @Test("Send Manually", .timeLimit(.minutes(1))) func sendManually() async throws { let connection = try await Connection.connection() // Select Management application @@ -142,7 +142,7 @@ struct ConnectionFullStackTests { @Suite("NFC Full Stack Tests", .serialized) struct NFCFullStackTests { - @Test("NFC Alert Message") + @Test("NFC Alert Message", .timeLimit(.minutes(1))) func nfcAlertMessage() async throws { let connection = try await TestableConnections.create(with: .nfc(alertMessage: "Test Alert Message")) await connection.nfcConnection?.setAlertMessage("Updated Alert Message") @@ -150,7 +150,7 @@ struct NFCFullStackTests { await connection.nfcConnection?.close(message: "Closing Alert Message") } - @Test("NFC Closing Error Message") + @Test("NFC Closing Error Message", .timeLimit(.minutes(1))) func nfcClosingErrorMessage() async throws { let connection = try await TestableConnections.create(with: .nfc(alertMessage: "Test Alert Message")) await connection.close(error: nil) @@ -162,7 +162,7 @@ struct NFCFullStackTests { @Suite("SmartCard Connection Full Stack Tests", .serialized) struct SmartCardConnectionFullStackTests { - @Test("SmartCard Connection With Slot") + @Test("SmartCard Connection With Slot", .timeLimit(.minutes(1))) func smartCardConnectionWithSlot() async throws { let allSlots = try await USBSmartCardConnection.availableSlots allSlots.enumerated().forEach { index, slot in From f86cd91e22950c2b713d5b75ee1dd470a74ef5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20D=2E=20Moreira?= Date: Tue, 12 Aug 2025 10:53:12 +0200 Subject: [PATCH 2/4] Refactor OATHSample --- .../OATHSample.xcodeproj/project.pbxproj | 16 +- .../OATHSample/ConnectionManager.swift | 106 +++++++++++ Samples/OATHSample/OATHSample/Model.swift | 71 ++++++++ .../OATHSample/OATHSample/OATHListModel.swift | 118 ------------- .../OATHSample/OATHSample/OATHListView.swift | 166 ++++++++++++++---- .../OATHSample/OATHSample/OATHSampleApp.swift | 2 +- .../OATHSample/OATHSample/SettingsModel.swift | 53 ------ .../OATHSample/OATHSample/SettingsView.swift | 84 ++++++--- .../YubiKit.docc/Resources/OATHSampleCode.md | 2 +- 9 files changed, 371 insertions(+), 247 deletions(-) create mode 100644 Samples/OATHSample/OATHSample/ConnectionManager.swift create mode 100644 Samples/OATHSample/OATHSample/Model.swift delete mode 100644 Samples/OATHSample/OATHSample/OATHListModel.swift delete mode 100644 Samples/OATHSample/OATHSample/SettingsModel.swift diff --git a/Samples/OATHSample/OATHSample.xcodeproj/project.pbxproj b/Samples/OATHSample/OATHSample.xcodeproj/project.pbxproj index 036531ffc..ef5ab3c0b 100644 --- a/Samples/OATHSample/OATHSample.xcodeproj/project.pbxproj +++ b/Samples/OATHSample/OATHSample.xcodeproj/project.pbxproj @@ -11,11 +11,11 @@ 51A1AC10273D537500F999A4 /* OATHListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A1AC0F273D537500F999A4 /* OATHListView.swift */; }; 51A1AC12273D537800F999A4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 51A1AC11273D537800F999A4 /* Assets.xcassets */; }; 51A1AC15273D537800F999A4 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 51A1AC14273D537800F999A4 /* Preview Assets.xcassets */; }; + 6C54A5412E40BA1E00409BD5 /* ConnectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C54A5402E40BA1E00409BD5 /* ConnectionManager.swift */; }; 6C8465302DADB11200788FB7 /* YubiKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C84652F2DADB11200788FB7 /* YubiKit */; }; 6C8465332DADB13300788FB7 /* YubiKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C8465322DADB13300788FB7 /* YubiKit */; }; - B41B61882743FE18004C37BF /* OATHListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B41B61872743FE18004C37BF /* OATHListModel.swift */; }; B456E217274E750D004471DE /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B456E216274E750D004471DE /* SettingsView.swift */; }; - B456E219274FC967004471DE /* SettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B456E218274FC967004471DE /* SettingsModel.swift */; }; + B456E219274FC967004471DE /* Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = B456E218274FC967004471DE /* Model.swift */; }; B4F937582B5150D60007D394 /* YubiKit in Frameworks */ = {isa = PBXBuildFile; productRef = B4F937572B5150D60007D394 /* YubiKit */; }; /* End PBXBuildFile section */ @@ -26,12 +26,12 @@ 51A1AC11273D537800F999A4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 51A1AC14273D537800F999A4 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 51A1AC29273D5D0A00F999A4 /* YubiKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = YubiKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - B41B61872743FE18004C37BF /* OATHListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OATHListModel.swift; sourceTree = ""; }; + 6C54A5402E40BA1E00409BD5 /* ConnectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionManager.swift; sourceTree = ""; }; B4451EFB275F924D002690BB /* OATHSample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OATHSample.entitlements; sourceTree = ""; }; B4451EFC275F924D002690BB /* ExternalAccessory.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ExternalAccessory.framework; path = System/Library/Frameworks/ExternalAccessory.framework; sourceTree = SDKROOT; }; B4451EFE275F940E002690BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; B456E216274E750D004471DE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - B456E218274FC967004471DE /* SettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModel.swift; sourceTree = ""; }; + B456E218274FC967004471DE /* Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Model.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -72,9 +72,9 @@ B4451EFB275F924D002690BB /* OATHSample.entitlements */, 51A1AC0D273D537500F999A4 /* OATHSampleApp.swift */, 51A1AC0F273D537500F999A4 /* OATHListView.swift */, - B41B61872743FE18004C37BF /* OATHListModel.swift */, B456E216274E750D004471DE /* SettingsView.swift */, - B456E218274FC967004471DE /* SettingsModel.swift */, + B456E218274FC967004471DE /* Model.swift */, + 6C54A5402E40BA1E00409BD5 /* ConnectionManager.swift */, 51A1AC11273D537800F999A4 /* Assets.xcassets */, 51A1AC13273D537800F999A4 /* Preview Content */, ); @@ -176,9 +176,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B456E219274FC967004471DE /* SettingsModel.swift in Sources */, + B456E219274FC967004471DE /* Model.swift in Sources */, + 6C54A5412E40BA1E00409BD5 /* ConnectionManager.swift in Sources */, B456E217274E750D004471DE /* SettingsView.swift in Sources */, - B41B61882743FE18004C37BF /* OATHListModel.swift in Sources */, 51A1AC10273D537500F999A4 /* OATHListView.swift in Sources */, 51A1AC0E273D537500F999A4 /* OATHSampleApp.swift in Sources */, ); diff --git a/Samples/OATHSample/OATHSample/ConnectionManager.swift b/Samples/OATHSample/OATHSample/ConnectionManager.swift new file mode 100644 index 000000000..2aac82d3b --- /dev/null +++ b/Samples/OATHSample/OATHSample/ConnectionManager.swift @@ -0,0 +1,106 @@ +// Copyright Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import SwiftUI +import YubiKit + +@MainActor +final class ConnectionManager: ObservableObject { + + static let shared = ConnectionManager() + + @Published private(set) var wiredConnection: SmartCardConnection? + #if os(iOS) + @Published private(set) var nfcConnection: NFCSmartCardConnection? + #endif + + @Published var error: Error? + + private var wiredConnectionTask: Task? + + private init() { + startWiredConnection() + } + + private func startWiredConnection() { + wiredConnectionTask = Task { @MainActor in + while !Task.isCancelled { + do { + error = nil + guard !Task.isCancelled else { return } + + let newConnection = try await WiredSmartCardConnection.connection() + guard !Task.isCancelled else { return } + + wiredConnection = newConnection + + let closeError = await newConnection.connectionDidClose() + + wiredConnection = nil + + if let closeError = closeError { + error = closeError + } + } catch { + if error is CancellationError { return } + self.error = error + } + } + } + } + + #if os(iOS) + func requestNFCConnection() async { + error = nil + + do { + nfcConnection = try await NFCSmartCardConnection.connection() as? NFCSmartCardConnection + } catch { + self.error = error + } + } + + func closeNFCConnection(message: String? = nil) async { + error = nil + + await nfcConnection?.close(message: message) + } + #endif +} + +extension SmartCardConnection { + var connectionType: String { + switch self { + case _ as NFCSmartCardConnection: + return "NFC" + case _ as LightningSmartCardConnection: + return "Lightning" + case _ as SmartCardConnection: + return "USB" + default: + return "Unknown" + } + } +} + +extension Optional where Wrapped == SmartCardConnection { + var connectionType: String { + guard let connection = self else { + return "No Connection" + } + + return connection.connectionType + } +} diff --git a/Samples/OATHSample/OATHSample/Model.swift b/Samples/OATHSample/OATHSample/Model.swift new file mode 100644 index 000000000..5d3232f1b --- /dev/null +++ b/Samples/OATHSample/OATHSample/Model.swift @@ -0,0 +1,71 @@ +// Copyright Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import YubiKit + +struct Account: Identifiable { + var id = UUID() + let label: String + let code: String? + let issuer: String? + let type: OATHSession.CredentialType +} + +@MainActor +class Model: ObservableObject { + + @Published private(set) var accounts = [Account]() + @Published private(set) var keyVersion: String? + @Published private(set) var connectionType: String? + @Published var error: Error? + + func update(using connection: SmartCardConnection) async { + await calculateCodes(using: connection) + await getKeyVersion(using: connection) + connectionType = connection.connectionType + } + + func clear() { + accounts = [] + keyVersion = nil + connectionType = nil + } + + private func getKeyVersion(using connection: SmartCardConnection) async { + do { + let session = try await ManagementSession.session(withConnection: connection) + self.keyVersion = session.version.description + } catch { + self.error = error + } + } + + private func calculateCodes(using connection: SmartCardConnection) async { + do { + let session = try await OATHSession.session(withConnection: connection) + let result = try await session.calculateCodes() + accounts = result.map { credential, code in + Account( + label: credential.label, + code: code?.code, + issuer: credential.issuer, + type: credential.type + ) + } + } catch { + self.error = error + } + } +} diff --git a/Samples/OATHSample/OATHSample/OATHListModel.swift b/Samples/OATHSample/OATHSample/OATHListModel.swift deleted file mode 100644 index 2ccc56ea9..000000000 --- a/Samples/OATHSample/OATHSample/OATHListModel.swift +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright Yubico AB -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import Foundation -import YubiKit - -protocol OATHListModelProtocol: ObservableObject { - var accounts: [Account] { get } - var source: String { get } - var error: Error? { get } - func stopWiredConnection() - func startWiredConnection() - func calculateNFCCodes() -} - -class OATHListModel: OATHListModelProtocol { - @Published private(set) var accounts = [Account]() - @Published private(set) var source = "no connection" - @Published var error: Error? - - private var wiredConnectionTask: Task? - - func stopWiredConnection() { - wiredConnectionTask?.cancel() - wiredConnectionTask = nil - } - - func startWiredConnection() { - wiredConnectionTask?.cancel() - wiredConnectionTask = Task { @MainActor in - while true { - do { - error = nil - guard !Task.isCancelled else { return } - // Wait for a suitable wired connection for the current device. - let connection = try await WiredSmartCardConnection.connection() - guard !Task.isCancelled else { return } - try await calculateCodes(connection: connection) - // Wait for the connection to close, i.e the YubiKey to be unplugged from the device. - // If the YubiKey was simply unplugged it will return nil, otherwise the error - // causing the disconnect will be returned. - guard !Task.isCancelled else { return } - error = await connection.connectionDidClose() - accounts.removeAll() - source = "no connection" - continue - } catch (let e) { - error = e - continue - } - } - } - } - - #if os(iOS) - func calculateNFCCodes() { - Task { @MainActor in - do { - self.error = nil - let connection = try await NFCSmartCardConnection.connection() - try await calculateCodes(connection: connection) - await connection.nfcConnection?.close(message: "Code calculated") - } catch { - self.error = error - } - } - } - #else - func calculateNFCCodes() {} // do nothing on macOS - #endif - - @MainActor private func calculateCodes(connection: SmartCardConnection) async throws { - self.error = nil - let session = try await OATHSession.session(withConnection: connection) - let result = try await session.calculateCodes() - self.accounts = result.map { Account(label: $0.0.label, code: $0.1?.code ?? "****") } - self.source = connection.connectionType - } -} - -struct Account: Identifiable { - var id = UUID() - let label: String - let code: String -} - -extension SmartCardConnection { - var connectionType: String { - #if os(iOS) - if self as? NFCSmartCardConnection != nil { - return "NFC" - } else if self as? LightningSmartCardConnection != nil { - return "Lightning" - } else if self as? USBSmartCardConnection != nil { - return "SmartCard" - } else { - return "Unknown" - } - #else - if self as? USBSmartCardConnection != nil { - return "SmartCard" - } else { - return "Unknown" - } - #endif - } -} diff --git a/Samples/OATHSample/OATHSample/OATHListView.swift b/Samples/OATHSample/OATHSample/OATHListView.swift index e244df6a7..36f2f7994 100644 --- a/Samples/OATHSample/OATHSample/OATHListView.swift +++ b/Samples/OATHSample/OATHSample/OATHListView.swift @@ -13,52 +13,139 @@ // limitations under the License. import SwiftUI +import YubiKit -struct OATHListView: View where T: OATHListModelProtocol { +struct OATHListView: View { - @StateObject var model: T + @StateObject var model = Model() + @StateObject private var connectionManager = ConnectionManager.shared @State private var isPresentingSettings = false + var title: String { + guard let connectionType = model.connectionType else { + #if os(iOS) + return "Plug in or scan" + #else + return "Connect YubiKey" + #endif + } + + return "\(connectionType) Codes" + } + var body: some View { + #if os(iOS) + let connectionTitle: String = "Tap to scan with NFC or connect via USB/Lightning" + #else + let connectionTitle: String = "Connect via USB" + #endif + NavigationStack { - List(model.accounts) { - AccountRowView(account: $0) + List { + if model.accounts.isEmpty { + VStack(spacing: 20) { + Image(systemName: model.connectionType == nil ? "key.icloud" : "key.slash") + .font(.system(size: 60)) + .foregroundColor(.secondary) + + Text(model.connectionType == nil ? "Connect Your YubiKey" : "No Accounts Found") + .font(.title2) + .fontWeight(.semibold) + + Text( + model.connectionType == nil + ? connectionTitle : "Add accounts to your YubiKey to see them here" + ) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + + #if os(iOS) + if model.connectionType == nil { + Button(action: { + Task { + await connectionManager.requestNFCConnection() + } + }) { + Label("Scan with NFC", systemImage: "wave.3.right") + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + .padding(.top, 10) + } + #endif + } + .frame(maxWidth: .infinity, minHeight: 400) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } else { + ForEach(model.accounts) { account in + AccountRowView(account: account) + } + } } - .navigationTitle("Codes (\(model.source))") + .navigationTitle(title) .toolbar(content: { ToolbarItem { Button(action: { - model.stopWiredConnection() isPresentingSettings.toggle() }) { - Image(systemName: "ellipsis.circle") + Image(systemName: "info.circle") } + .disabled(model.connectionType == nil) .sheet( isPresented: $isPresentingSettings, - onDismiss: { - // Restart wired connection once the SettingsView has been dismissed - model.startWiredConnection() - }, - content: { - SettingsView(model: SettingsModel()) - } + onDismiss: {}, + content: { SettingsView(model: model) } ) } }) #if os(iOS) .refreshable { - model.calculateNFCCodes() + await connectionManager.requestNFCConnection() } #endif } - .onAppear { - model.startWiredConnection() + #if os(iOS) + .onReceive(connectionManager.$nfcConnection) { newConnection in + guard let connection = newConnection else { + return + } + + Task { + await model.update(using: connection) + await connection.close(message: "Codes calculated") + } + } + #endif + .onReceive(connectionManager.$wiredConnection) { newConnection in + guard let connection = newConnection else { + model.clear() + return + } + + Task { await model.update(using: connection) } + } + .onReceive(connectionManager.$error) { error in + switch error { + case .some(ConnectionError.cancelledByUser): + return + default: + model.error = error + } } .alert( "Something went wrong", - isPresented: .constant(model.error != nil), + isPresented: Binding( + get: { model.error != nil }, + set: { _ in model.error = nil } + ), actions: { - Button("Ok", role: .cancel) { model.startWiredConnection() } + Button("Ok", role: .cancel) {} }, message: { if let error = model.error { @@ -71,29 +158,30 @@ struct OATHListView: View where T: OATHListModelProtocol { struct AccountRowView: View { let account: Account + var body: some View { HStack { - Text(account.label) + VStack(alignment: .leading) { + Text(account.label) + .font(.body) + if let issuer = account.issuer, !issuer.isEmpty { + Text(issuer) + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() - Text(account.code) + + if let code = account.code { + Text(code) + .font(.system(.title3, design: .monospaced)) + } else { + Image(systemName: "lock.fill") + .font(.body) + .foregroundColor(.secondary) + } } + .padding(.vertical, 4) } } - -#Preview { - OATHListView(model: OATHListModelPreview()) -} - -class OATHListModelPreview: OATHListModelProtocol { - @Published private(set) var accounts = [ - Account(label: "Label 1", code: "123456"), - Account(label: "Label 2", code: "123456"), - Account(label: "Label 3", code: "123456"), - ] - @Published private(set) var source = "no connection" - @Published var error: Error? - - func stopWiredConnection() {} - func startWiredConnection() {} - func calculateNFCCodes() {} -} diff --git a/Samples/OATHSample/OATHSample/OATHSampleApp.swift b/Samples/OATHSample/OATHSample/OATHSampleApp.swift index 519d6ef67..43c458f65 100644 --- a/Samples/OATHSample/OATHSample/OATHSampleApp.swift +++ b/Samples/OATHSample/OATHSample/OATHSampleApp.swift @@ -18,7 +18,7 @@ import SwiftUI struct OATHSampleApp: App { var body: some Scene { WindowGroup { - OATHListView(model: OATHListModel()) + OATHListView() } } } diff --git a/Samples/OATHSample/OATHSample/SettingsModel.swift b/Samples/OATHSample/OATHSample/SettingsModel.swift deleted file mode 100644 index 36b5016ee..000000000 --- a/Samples/OATHSample/OATHSample/SettingsModel.swift +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright Yubico AB -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import Foundation -import YubiKit - -protocol SettingsModelProtocol: ObservableObject { - var keyVersion: String? { get } - var connection: String? { get } - var error: Error? { get } - func getKeyVersion() -} - -class SettingsModel: SettingsModelProtocol { - - @Published private(set) var keyVersion: String? - @Published private(set) var connection: String? - @Published private(set) var error: Error? - - func getKeyVersion() { - Task { @MainActor in - self.error = nil - do { - let connection = try await AnySmartCardConnection.connection() - let session = try await ManagementSession.session(withConnection: connection) - self.keyVersion = session.version.description - #if os(iOS) - if let nfcConnection = connection.nfcConnection { - self.connection = "NFC" - await nfcConnection.close(message: "YubiKey version read") - } else { - self.connection = connection as? USBSmartCardConnection != nil ? "SmartCard" : "Lightning" - } - #else - self.connection = "SmartCard" - #endif - } catch { - self.error = error - } - } - } -} diff --git a/Samples/OATHSample/OATHSample/SettingsView.swift b/Samples/OATHSample/OATHSample/SettingsView.swift index aa7c589ce..2e1251199 100644 --- a/Samples/OATHSample/OATHSample/SettingsView.swift +++ b/Samples/OATHSample/OATHSample/SettingsView.swift @@ -13,27 +13,72 @@ // limitations under the License. import SwiftUI +import YubiKit -struct SettingsView: View where T: SettingsModelProtocol { +struct SettingsView: View { @Environment(\.dismiss) var dismiss - @StateObject var model: T + @StateObject var model: Model + @StateObject private var connectionManager = ConnectionManager.shared var body: some View { - Text("\(model.connection ?? "Unknown") YubiKey, \(model.keyVersion ?? "Unknown version")") - .frame(width: 300) - .padding() - Button { - dismiss() - } label: { - Text("Dismiss") + VStack(spacing: 20) { + Capsule() + .fill(Color.secondary.opacity(0.4)) + .frame(width: 40, height: 5) + .padding(.top, 10) + + Text("YubiKey Information") + .font(.headline) + .padding(.top, 10) + + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Connection:") + .foregroundColor(.secondary) + Spacer() + Text(model.connectionType ?? "Unknown") + .fontWeight(.medium) + } + + HStack { + Text("Version:") + .foregroundColor(.secondary) + Spacer() + Text(model.keyVersion ?? "Unknown") + .fontWeight(.medium) + } + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity) + + Spacer() + + Button(action: { dismiss() }) { + Text("Done").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.background) + .presentationDetents([.fraction(0.5)]) + .presentationDragIndicator(.hidden) + #if os(iOS) + .refreshable { + await connectionManager.requestNFCConnection() } - .padding() + #endif .alert( "Something went wrong", - isPresented: .constant(model.error != nil), + isPresented: Binding( + get: { model.error != nil }, + set: { _ in model.error = nil } + ), actions: { - Button("Ok", role: .cancel) { dismiss() } + Button("Ok", role: .cancel) {} }, message: { if let error = model.error { @@ -41,20 +86,5 @@ struct SettingsView: View where T: SettingsModelProtocol { } } ) - .onAppear { - model.getKeyVersion() - } } - -} - -#Preview { - SettingsView(model: SettingsModelPreview()) -} - -class SettingsModelPreview: SettingsModelProtocol { - @Published private(set) var keyVersion: String? = "5.4.2" - @Published private(set) var connection: String? = "SmartCard" - @Published private(set) var error: Error? - func getKeyVersion() {} } diff --git a/YubiKit/YubiKit/YubiKit.docc/Resources/OATHSampleCode.md b/YubiKit/YubiKit/YubiKit.docc/Resources/OATHSampleCode.md index 5f0b7cd24..f0a4811d9 100644 --- a/YubiKit/YubiKit/YubiKit.docc/Resources/OATHSampleCode.md +++ b/YubiKit/YubiKit/YubiKit.docc/Resources/OATHSampleCode.md @@ -72,7 +72,7 @@ To bring up the settings view we've added a `Button` to the `OATHListView`. The any wired connections and cancel the wait for new connections. It will then present the `SettingsView` as a SwiftUI sheet. -The `SettingsModel` is simpler since it will only retrieve the version number once when it appears +The `Model` is simpler since it will only retrieve the version number once when it appears and it does not handle YubiKeys being unplugged and plugged back again. In this case we can use the `AnySmartCardConnection.connection()` function that will return any wired YubiKey that might be connected or, if no wired key is present it will start scanning for a NFC key. Once connected we create From 308fef96189321babefd5985331c9dcc022183e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20D=2E=20Moreira?= Date: Wed, 13 Aug 2025 09:31:21 +0200 Subject: [PATCH 3/4] Improvements to the NFC connection --- .../OATHSample/ConnectionManager.swift | 2 + YubiKit/YubiKit/Connection.swift | 4 +- YubiKit/YubiKit/NFCSmartCardConnection.swift | 46 +++++++++---------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/Samples/OATHSample/OATHSample/ConnectionManager.swift b/Samples/OATHSample/OATHSample/ConnectionManager.swift index 2aac82d3b..825d22d97 100644 --- a/Samples/OATHSample/OATHSample/ConnectionManager.swift +++ b/Samples/OATHSample/OATHSample/ConnectionManager.swift @@ -83,10 +83,12 @@ final class ConnectionManager: ObservableObject { extension SmartCardConnection { var connectionType: String { switch self { + #if os(iOS) case _ as NFCSmartCardConnection: return "NFC" case _ as LightningSmartCardConnection: return "Lightning" + #endif case _ as SmartCardConnection: return "USB" default: diff --git a/YubiKit/YubiKit/Connection.swift b/YubiKit/YubiKit/Connection.swift index 27e6868d8..9f0f3a70a 100644 --- a/YubiKit/YubiKit/Connection.swift +++ b/YubiKit/YubiKit/Connection.swift @@ -78,8 +78,8 @@ public enum ConnectionError: Error, Sendable { case missingResult /// Awaiting call to connect() was cancelled. case cancelled - /// SmartCardConnection was closed. - case closed + /// Awaiting call to connect() was dismissed by the user. + case cancelledByUser } /// A ResponseError containing the status code. diff --git a/YubiKit/YubiKit/NFCSmartCardConnection.swift b/YubiKit/YubiKit/NFCSmartCardConnection.swift index 463453b59..5ca47698e 100644 --- a/YubiKit/YubiKit/NFCSmartCardConnection.swift +++ b/YubiKit/YubiKit/NFCSmartCardConnection.swift @@ -267,13 +267,7 @@ private final actor NFCConnectionManager: NSObject { if let message = message { currentState.session?.alertMessage = message } - } - - switch result { - case .success: - await invalidate() - case let .failure(error): - await invalidate(error: error) + currentState.session?.invalidate() } } @@ -282,7 +276,7 @@ private final actor NFCConnectionManager: NSObject { trace(message: "Manager.connected(session:tag:) - tag: \(String(describing: tag.identifier))") guard let promise = currentState.connectionPromise else { - await invalidate() + await cleanup(session: session) return } @@ -299,15 +293,21 @@ private final actor NFCConnectionManager: NSObject { await promise.fulfill(connection) } - private func invalidate(error: Error? = nil) async { - currentState.session?.invalidate() + private func cleanup(session: NFCTagReaderSession, error: Error? = nil) async { + guard currentState.session === session else { + return + } + + switch error { + case .none: + await currentState.didCloseConnection?.fulfill(nil) + await currentState.connectionPromise?.cancel(with: ConnectionError.cancelledByUser) + case let .some(error): + await currentState.didCloseConnection?.fulfill(nil) + await currentState.connectionPromise?.cancel(with: error) + } - // Workaround for the NFC session being active for an additional 4 seconds after - // invalidate() has been called on the session. - try? await Task.sleep(for: .seconds(5)) - await currentState.didCloseConnection?.fulfill(error) - await currentState.connectionPromise?.cancel(with: error ?? ConnectionError.cancelled) - currentState = .inactive + self.currentState = .inactive } } @@ -325,16 +325,16 @@ extension NFCConnectionManager: NFCTagReaderSessionDelegate { trace(message: "NFCTagReaderSessionDelegate: Session invalidated – \(error.localizedDescription)") let nfcError = error as? NFCReaderError + + let mappedError: Error? switch nfcError?.code { case .some(.readerSessionInvalidationErrorUserCanceled): - return + mappedError = nil // user cancelled, no error default: - Task { - if await currentState.session === session { - await stop(with: .failure(error)) - } - } + mappedError = error } + + Task { await cleanup(session: session, error: mappedError) } } // @TraceScope @@ -354,7 +354,7 @@ extension NFCConnectionManager: NFCTagReaderSessionDelegate { if await session === currentState.session { await connected(session: session, tag: firstTag) } else { - await invalidate(error: ConnectionError.cancelled) + await cleanup(session: session, error: ConnectionError.cancelled) } } } From 9143ad2166e772b97573d78aa680beba3a99e8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20D=2E=20Moreira?= Date: Wed, 13 Aug 2025 09:43:43 +0200 Subject: [PATCH 4/4] Fix minor issues in ConnectionManager --- Samples/OATHSample/OATHSample/ConnectionManager.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Samples/OATHSample/OATHSample/ConnectionManager.swift b/Samples/OATHSample/OATHSample/ConnectionManager.swift index 825d22d97..2ba35c65b 100644 --- a/Samples/OATHSample/OATHSample/ConnectionManager.swift +++ b/Samples/OATHSample/OATHSample/ConnectionManager.swift @@ -54,7 +54,8 @@ final class ConnectionManager: ObservableObject { error = closeError } } catch { - if error is CancellationError { return } + // Ignore cancellation errors + if let cancellationError = error as? CancellationError { return } self.error = error } } @@ -89,7 +90,7 @@ extension SmartCardConnection { case _ as LightningSmartCardConnection: return "Lightning" #endif - case _ as SmartCardConnection: + case _ as USBSmartCardConnection: return "USB" default: return "Unknown"