From 61ee809df5a6541ad6b2592cbc070cb715e0c765 Mon Sep 17 00:00:00 2001 From: Markus Thielker Date: Fri, 17 Jan 2025 21:49:31 +0100 Subject: [PATCH] Major refactor of data model Moved all password data except ID and secret value to SwiftData table as preparation for further data collection --- password/data/manager/PasswordManager.swift | 92 ++++++++++++ password/data/model/Password.swift | 13 +- password/data/model/PasswordKC.swift | 19 +++ .../PasswordKeychainRepository.swift | 80 +++++++++++ .../data/repository/PasswordRepository.swift | 136 ++++++------------ password/passwordApp.swift | 12 +- password/view/add/AddView.swift | 8 +- password/view/detail/DetailView.swift | 9 +- password/view/list/ListView.swift | 18 ++- password/view/list/ListViewModel.swift | 63 ++++---- 10 files changed, 315 insertions(+), 135 deletions(-) create mode 100644 password/data/manager/PasswordManager.swift create mode 100644 password/data/model/PasswordKC.swift create mode 100644 password/data/repository/PasswordKeychainRepository.swift diff --git a/password/data/manager/PasswordManager.swift b/password/data/manager/PasswordManager.swift new file mode 100644 index 0000000..d377cf6 --- /dev/null +++ b/password/data/manager/PasswordManager.swift @@ -0,0 +1,92 @@ +// +// PasswordManager.swift +// password +// +// Created by Markus Thielker on 17.01.25. +// + +import Foundation +import SwiftUICore +import SwiftData + +class PasswordManager { + + let passwordRepository: PasswordRepository + let passwordKeychainRepository: PasswordKeychainRepository + + private let context: ModelContext + + init( + context: ModelContext, + passwordRepository: PasswordRepository, + passwordKeychainRepository: PasswordKeychainRepository + ) { + self.context = context + self.passwordRepository = passwordRepository + self.passwordKeychainRepository = passwordKeychainRepository + } + + @MainActor + func getAllPasswords() -> [Password] { + return passwordRepository.getAllPasswords() + } + + @MainActor + func getPassword(withID id: UUID) -> Password? { + return passwordRepository.getPassword(withID: id) + } + + func getPasswordKeychain(withID id: UUID) -> PasswordKC? { + return passwordKeychainRepository.getPassword(withID: id) + } + + @MainActor + func createPassword(name: String, value: String) -> Password { + + print("creating password \(name)") + + let password = Password(name: name) + + do { + + try context.transaction { + passwordRepository.createPassword(password) + let passwordKC = PasswordKC(id: password.id, value: value) + try passwordKeychainRepository.createPassword(passwordKC) + } + + print("password created successfully") + + } catch { + context.rollback() + print("password creation failed: \(error)") + } + + return password + } + + @MainActor + func deletePassword(_ password: Password) -> Bool { + + print("deleting password \(password.name)") + + var successful = false + + do { + + try context.transaction { + passwordRepository.deletePassword(password) + try passwordKeychainRepository.deletePassword(withID: password.id) + } + + successful = true + print("password deleted successfully") + + } catch { + context.rollback() + print("password deletion failed: \(error)") + } + + return successful + } +} diff --git a/password/data/model/Password.swift b/password/data/model/Password.swift index 496acf9..939fad9 100644 --- a/password/data/model/Password.swift +++ b/password/data/model/Password.swift @@ -6,16 +6,17 @@ // import Foundation +import SwiftData -struct Password: Identifiable, Codable { - let id: UUID - let name: String - let value: String +@Model +class Password: Identifiable { + + @Attribute(.unique) var id: UUID + @Attribute(.unique) var name: String var createdAt: Date = Date() - init(id: UUID = UUID(), name: String, value: String) { + init(id: UUID = UUID(), name: String) { self.id = id self.name = name - self.value = value } } diff --git a/password/data/model/PasswordKC.swift b/password/data/model/PasswordKC.swift new file mode 100644 index 0000000..4ebd08b --- /dev/null +++ b/password/data/model/PasswordKC.swift @@ -0,0 +1,19 @@ +// +// PasswordKC.swift +// password +// +// Created by Markus Thielker on 17.01.25. +// + +import Foundation + +struct PasswordKC: Identifiable, Codable { + + let id: UUID + let value: String + + init(id: UUID, value: String) { + self.id = id + self.value = value + } +} diff --git a/password/data/repository/PasswordKeychainRepository.swift b/password/data/repository/PasswordKeychainRepository.swift new file mode 100644 index 0000000..ad93b31 --- /dev/null +++ b/password/data/repository/PasswordKeychainRepository.swift @@ -0,0 +1,80 @@ +// +// PasswordRepository.swift +// password +// +// Created by Markus Thielker on 16.01.25. +// + +import Foundation +import Security + +enum KeychainError: Error { + case unknown + case duplicateItem + case itemNotFound + case unexpectedPasswordData +} + +class PasswordKeychainRepository { + + private let serviceName = "dev.thielker.password" + + func getPassword(withID id: UUID) -> PasswordKC? { + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: id.uuidString, + kSecReturnData as String: true + ] + + var result: AnyObject? + SecItemCopyMatching(query as CFDictionary, &result) + + guard let data = result as? Data, + let password = try? JSONDecoder().decode(PasswordKC.self, from: data) else { + return nil + } + + return password + } + + func createPassword(_ password: PasswordKC) throws { + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, // Generic password item + kSecAttrService as String: serviceName, // Service name for your app + kSecAttrAccount as String: password.id.uuidString, // Unique identifier + kSecValueData as String: try JSONEncoder().encode(password) // Encode password data + ] + + let status = SecItemAdd(query as CFDictionary, nil) + + guard status == errSecSuccess else { + if status == errSecDuplicateItem { + throw KeychainError.duplicateItem + } else { + throw KeychainError.unknown + } + } + } + + func deletePassword(withID id: UUID) throws { + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: id.uuidString + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess else { + if status == errSecItemNotFound { + throw KeychainError.itemNotFound + } else { + throw KeychainError.unknown + } + } + } +} diff --git a/password/data/repository/PasswordRepository.swift b/password/data/repository/PasswordRepository.swift index 4af2e63..4a512e0 100644 --- a/password/data/repository/PasswordRepository.swift +++ b/password/data/repository/PasswordRepository.swift @@ -2,120 +2,74 @@ // PasswordRepository.swift // password // -// Created by Markus Thielker on 16.01.25. +// Created by Markus Thielker on 17.01.25. // import Foundation -import Security +import SwiftUICore +import SwiftData -enum KeychainError: Error { - case unknown - case duplicateItem - case itemNotFound - case unexpectedPasswordData +enum PasswordRepositoryError: Error { + case notFound } -protocol PasswordRepository { - func getAllPasswords() -> [Password] - func getPassword(withID id: UUID) -> Password? - func savePassword(_ password: Password) throws - func deletePassword(withID id: UUID) throws -} - -class KeychainPasswordRepository: PasswordRepository { +class PasswordRepository { - private let serviceName = "dev.thielker.password" - + private let context: ModelContext + + init(_ context: ModelContext) { + self.context = context + } + + @MainActor func getAllPasswords() -> [Password] { - // TODO: fix query to work with 'kSecMatchLimit as String: kSecMatchLimitAll' - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecMatchLimit as String: 100, - kSecReturnAttributes as String: true, - kSecReturnData as String: true, - ] + print("fetching passwords") - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - if status != errSecSuccess { - print("Error retrieving passwords: \(SecCopyErrorMessageString(status, nil) ?? "Unknown error" as CFString)") - return [] - } - - guard let items = result as? [[String: Any]] else { - print("No passwords found.") - return [] - } - var passwords: [Password] = [] - for item in items { - if let data = item[kSecValueData as String] as? Data, - let password = try? JSONDecoder().decode(Password.self, from: data) { - passwords.append(password) - } + do { + + let request = FetchDescriptor() + passwords = try context.fetch(request) + + print("found \(passwords.count) passwords") + + } catch { + print("fetching password failed: \(error)") } + return passwords } + @MainActor func getPassword(withID id: UUID) -> Password? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: id.uuidString, - kSecReturnData as String: true - ] - - var result: AnyObject? - SecItemCopyMatching(query as CFDictionary, &result) - - guard let data = result as? Data, - let password = try? JSONDecoder().decode(Password.self, from: data) else { - return nil + print("fetching password with id \(id)") + + var password: Password? + do { + + let request = FetchDescriptor(predicate: #Predicate { $0.id == id }) + password = try context.fetch(request).first + + print("found password: \(password == nil)") + + } catch { + print("fetching password failed: \(error)") } return password } - func savePassword(_ password: Password) throws { - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, // Generic password item - kSecAttrService as String: serviceName, // Service name for your app - kSecAttrAccount as String: password.id.uuidString, // Unique identifier - kSecValueData as String: try JSONEncoder().encode(password) // Encode password data - ] - - let status = SecItemAdd(query as CFDictionary, nil) - - guard status == errSecSuccess else { - if status == errSecDuplicateItem { - throw KeychainError.duplicateItem - } else { - throw KeychainError.unknown - } - } + @MainActor + func createPassword(_ password: Password) { + context.insert(password) + print("inserted password \(password.name)") } - func deletePassword(withID id: UUID) throws { - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: id.uuidString - ] - - let status = SecItemDelete(query as CFDictionary) - - guard status == errSecSuccess else { - if status == errSecItemNotFound { - throw KeychainError.itemNotFound - } else { - throw KeychainError.unknown - } - } + @MainActor + func deletePassword(_ password: Password) { + context.delete(password) + print("deleted password \(password.name)") } } diff --git a/password/passwordApp.swift b/password/passwordApp.swift index 8b32159..d40bad9 100644 --- a/password/passwordApp.swift +++ b/password/passwordApp.swift @@ -49,7 +49,7 @@ struct passwordApp: App { WindowGroup { VStack { if isAuthenticated { - ListView(viewModel: ListViewModel()) + ContextWrapper() } else { Button("Authenticate") { authenticate() @@ -60,5 +60,15 @@ struct passwordApp: App { authenticate() } } + .modelContainer(for: [Password.self]) + } +} + +struct ContextWrapper: View { + + @Environment(\.modelContext) var context + + var body: some View { + ListView(viewModel: ListViewModel(context: context)) } } diff --git a/password/view/add/AddView.swift b/password/view/add/AddView.swift index f871545..0fff1ad 100644 --- a/password/view/add/AddView.swift +++ b/password/view/add/AddView.swift @@ -31,7 +31,7 @@ struct AddPasswordView: View { .font(.footnote) HStack { Button("Save") { - viewModel.savePassword(name: name, value: value) + viewModel.createPassword(name: name, value: value) name = "" value = "" dismiss() @@ -47,5 +47,9 @@ struct AddPasswordView: View { } #Preview { - AddPasswordView(viewModel: .init()) + + @Previewable + @Environment(\.modelContext) var context + + AddPasswordView(viewModel: .init(context: context)) } diff --git a/password/view/detail/DetailView.swift b/password/view/detail/DetailView.swift index 5082cee..e6f1d73 100644 --- a/password/view/detail/DetailView.swift +++ b/password/view/detail/DetailView.swift @@ -10,10 +10,12 @@ import SwiftUI struct DetailView: View { let password: Password + let passwordKC: PasswordKC + @State var value: String = "" func validateInput(input: String, password: Password) -> Bool { - return input == password.value + return input == passwordKC.value } var body: some View { @@ -47,6 +49,7 @@ struct DetailView: View { } #Preview { - let password = Password(name: "macbook", value: "password") - DetailView(password: password) + let password = Password(name: "macbook") + let passwordKC = PasswordKC(id: password.id, value: "admin") + DetailView(password: password, passwordKC: passwordKC) } diff --git a/password/view/list/ListView.swift b/password/view/list/ListView.swift index 64ee8cc..929f71d 100644 --- a/password/view/list/ListView.swift +++ b/password/view/list/ListView.swift @@ -6,17 +6,21 @@ // import SwiftUI +import _SwiftData_SwiftUI struct ListView: View { @ObservedObject var viewModel: ListViewModel @State var isAddingPassword: Bool = false - + var body: some View { NavigationView { List { ForEach(viewModel.passwords) { password in - NavigationLink(destination: DetailView(password: password)) { + NavigationLink(destination: DetailView( + password: password, + passwordKC: viewModel.getPasswordKeychain(withID: password.id)! + )) { Text(password.name) } } @@ -28,7 +32,7 @@ struct ListView: View { .frame(width: 20, height: 20) } Button(action: { - viewModel.loadAllPasswords() + viewModel.getAllPasswords() }) { Image(systemName: "arrow.trianglehead.clockwise") .imageScale(.medium) @@ -41,11 +45,15 @@ struct ListView: View { AddPasswordView(viewModel: viewModel) } .onAppear { - viewModel.loadAllPasswords() + viewModel.getAllPasswords() } } } #Preview { - ListView(viewModel: .init()) + + @Previewable + @Environment(\.modelContext) var context + + ListView(viewModel: .init(context: context)) } diff --git a/password/view/list/ListViewModel.swift b/password/view/list/ListViewModel.swift index a7f5b71..9cb32fd 100644 --- a/password/view/list/ListViewModel.swift +++ b/password/view/list/ListViewModel.swift @@ -6,48 +6,57 @@ // import Foundation +import SwiftData class ListViewModel: ObservableObject { @Published var passwords: [Password] = [] - private let passwordRepository: PasswordRepository + private let context: ModelContext + private let passwordManager: PasswordManager - init(passwordRepository: PasswordRepository = KeychainPasswordRepository()) { - self.passwordRepository = passwordRepository - loadAllPasswords() + @MainActor + init(context: ModelContext) { + self.context = context + + let passwordRepository = PasswordRepository(context) + let passwordKeychainRepository = PasswordKeychainRepository() + + self.passwordManager = PasswordManager( + context: context, + passwordRepository: passwordRepository, + passwordKeychainRepository: passwordKeychainRepository + ) + + passwords = getAllPasswords() } - func loadAllPasswords() { - passwords = passwordRepository.getAllPasswords() + @MainActor + func getAllPasswords() -> [Password] { + return passwordManager.getAllPasswords() } - func loadPassword(withID id: UUID) -> Password? { - return passwordRepository.getPassword(withID: id) + @MainActor + func getPassword(withID id: UUID) -> Password? { + return passwordManager.getPassword(withID: id) } - func savePassword(name: String, value: String) { - let newPassword = Password(name: name, value: value) - - do { - - try passwordRepository.savePassword(newPassword) - print ("Saved password successfully") - loadAllPasswords() - - } catch { - print("Error saving password: \(error)") - // TODO: display error to user - } + @MainActor + func getPasswordKeychain(withID id: UUID) -> PasswordKC? { + return passwordManager.getPasswordKeychain(withID: id) } + @MainActor + func createPassword(name: String, value: String) { + passwordManager.createPassword(name: name, value: value) + passwords = getAllPasswords() + } + + @MainActor func deletePassword(_ password: Password) { - do { - try passwordRepository.deletePassword(withID: password.id) - loadAllPasswords() - } catch { - print("Error deleting password: \(error)") - // TODO: display error to user + let success = passwordManager.deletePassword(password) + if success { + passwords = getAllPasswords() } } }