Major refactor of data model

Moved all password data except ID and secret value to SwiftData table as preparation for further data collection
This commit is contained in:
Markus Thielker 2025-01-17 21:49:31 +01:00
parent c3f8103482
commit 61ee809df5
No known key found for this signature in database
10 changed files with 315 additions and 135 deletions

View file

@ -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
}
}

View file

@ -6,16 +6,17 @@
// //
import Foundation import Foundation
import SwiftData
struct Password: Identifiable, Codable { @Model
let id: UUID class Password: Identifiable {
let name: String
let value: String @Attribute(.unique) var id: UUID
@Attribute(.unique) var name: String
var createdAt: Date = Date() var createdAt: Date = Date()
init(id: UUID = UUID(), name: String, value: String) { init(id: UUID = UUID(), name: String) {
self.id = id self.id = id
self.name = name self.name = name
self.value = value
} }
} }

View file

@ -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
}
}

View file

@ -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
}
}
}
}

View file

@ -2,120 +2,74 @@
// PasswordRepository.swift // PasswordRepository.swift
// password // password
// //
// Created by Markus Thielker on 16.01.25. // Created by Markus Thielker on 17.01.25.
// //
import Foundation import Foundation
import Security import SwiftUICore
import SwiftData
enum KeychainError: Error { enum PasswordRepositoryError: Error {
case unknown case notFound
case duplicateItem
case itemNotFound
case unexpectedPasswordData
} }
protocol PasswordRepository { class PasswordRepository {
func getAllPasswords() -> [Password]
func getPassword(withID id: UUID) -> Password? private let context: ModelContext
func savePassword(_ password: Password) throws
func deletePassword(withID id: UUID) throws init(_ context: ModelContext) {
self.context = context
} }
class KeychainPasswordRepository: PasswordRepository { @MainActor
private let serviceName = "dev.thielker.password"
func getAllPasswords() -> [Password] { func getAllPasswords() -> [Password] {
// TODO: fix query to work with 'kSecMatchLimit as String: kSecMatchLimitAll' print("fetching passwords")
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecMatchLimit as String: 100,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
]
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] = [] var passwords: [Password] = []
for item in items { do {
if let data = item[kSecValueData as String] as? Data,
let password = try? JSONDecoder().decode(Password.self, from: data) { let request = FetchDescriptor<Password>()
passwords.append(password) passwords = try context.fetch(request)
}
print("found \(passwords.count) passwords")
} catch {
print("fetching password failed: \(error)")
} }
return passwords return passwords
} }
@MainActor
func getPassword(withID id: UUID) -> Password? { func getPassword(withID id: UUID) -> Password? {
let query: [String: Any] = [ print("fetching password with id \(id)")
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true
]
var result: AnyObject? var password: Password?
SecItemCopyMatching(query as CFDictionary, &result) do {
guard let data = result as? Data, let request = FetchDescriptor<Password>(predicate: #Predicate { $0.id == id })
let password = try? JSONDecoder().decode(Password.self, from: data) else { password = try context.fetch(request).first
return nil
print("found password: \(password == nil)")
} catch {
print("fetching password failed: \(error)")
} }
return password return password
} }
func savePassword(_ password: Password) throws { @MainActor
func createPassword(_ password: Password) {
let query: [String: Any] = [ context.insert(password)
kSecClass as String: kSecClassGenericPassword, // Generic password item print("inserted password \(password.name)")
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 { @MainActor
func deletePassword(_ password: Password) {
let query: [String: Any] = [ context.delete(password)
kSecClass as String: kSecClassGenericPassword, print("deleted password \(password.name)")
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
}
}
} }
} }

View file

@ -49,7 +49,7 @@ struct passwordApp: App {
WindowGroup { WindowGroup {
VStack { VStack {
if isAuthenticated { if isAuthenticated {
ListView(viewModel: ListViewModel()) ContextWrapper()
} else { } else {
Button("Authenticate") { Button("Authenticate") {
authenticate() authenticate()
@ -60,5 +60,15 @@ struct passwordApp: App {
authenticate() authenticate()
} }
} }
.modelContainer(for: [Password.self])
}
}
struct ContextWrapper: View {
@Environment(\.modelContext) var context
var body: some View {
ListView(viewModel: ListViewModel(context: context))
} }
} }

View file

@ -31,7 +31,7 @@ struct AddPasswordView: View {
.font(.footnote) .font(.footnote)
HStack { HStack {
Button("Save") { Button("Save") {
viewModel.savePassword(name: name, value: value) viewModel.createPassword(name: name, value: value)
name = "" name = ""
value = "" value = ""
dismiss() dismiss()
@ -47,5 +47,9 @@ struct AddPasswordView: View {
} }
#Preview { #Preview {
AddPasswordView(viewModel: .init())
@Previewable
@Environment(\.modelContext) var context
AddPasswordView(viewModel: .init(context: context))
} }

View file

@ -10,10 +10,12 @@ import SwiftUI
struct DetailView: View { struct DetailView: View {
let password: Password let password: Password
let passwordKC: PasswordKC
@State var value: String = "" @State var value: String = ""
func validateInput(input: String, password: Password) -> Bool { func validateInput(input: String, password: Password) -> Bool {
return input == password.value return input == passwordKC.value
} }
var body: some View { var body: some View {
@ -47,6 +49,7 @@ struct DetailView: View {
} }
#Preview { #Preview {
let password = Password(name: "macbook", value: "password") let password = Password(name: "macbook")
DetailView(password: password) let passwordKC = PasswordKC(id: password.id, value: "admin")
DetailView(password: password, passwordKC: passwordKC)
} }

View file

@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import _SwiftData_SwiftUI
struct ListView: View { struct ListView: View {
@ -16,7 +17,10 @@ struct ListView: View {
NavigationView { NavigationView {
List { List {
ForEach(viewModel.passwords) { password in ForEach(viewModel.passwords) { password in
NavigationLink(destination: DetailView(password: password)) { NavigationLink(destination: DetailView(
password: password,
passwordKC: viewModel.getPasswordKeychain(withID: password.id)!
)) {
Text(password.name) Text(password.name)
} }
} }
@ -28,7 +32,7 @@ struct ListView: View {
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
} }
Button(action: { Button(action: {
viewModel.loadAllPasswords() viewModel.getAllPasswords()
}) { }) {
Image(systemName: "arrow.trianglehead.clockwise") Image(systemName: "arrow.trianglehead.clockwise")
.imageScale(.medium) .imageScale(.medium)
@ -41,11 +45,15 @@ struct ListView: View {
AddPasswordView(viewModel: viewModel) AddPasswordView(viewModel: viewModel)
} }
.onAppear { .onAppear {
viewModel.loadAllPasswords() viewModel.getAllPasswords()
} }
} }
} }
#Preview { #Preview {
ListView(viewModel: .init())
@Previewable
@Environment(\.modelContext) var context
ListView(viewModel: .init(context: context))
} }

View file

@ -6,48 +6,57 @@
// //
import Foundation import Foundation
import SwiftData
class ListViewModel: ObservableObject { class ListViewModel: ObservableObject {
@Published var passwords: [Password] = [] @Published var passwords: [Password] = []
private let passwordRepository: PasswordRepository private let context: ModelContext
private let passwordManager: PasswordManager
init(passwordRepository: PasswordRepository = KeychainPasswordRepository()) { @MainActor
self.passwordRepository = passwordRepository init(context: ModelContext) {
loadAllPasswords() self.context = context
let passwordRepository = PasswordRepository(context)
let passwordKeychainRepository = PasswordKeychainRepository()
self.passwordManager = PasswordManager(
context: context,
passwordRepository: passwordRepository,
passwordKeychainRepository: passwordKeychainRepository
)
passwords = getAllPasswords()
} }
func loadAllPasswords() { @MainActor
passwords = passwordRepository.getAllPasswords() func getAllPasswords() -> [Password] {
return passwordManager.getAllPasswords()
} }
func loadPassword(withID id: UUID) -> Password? { @MainActor
return passwordRepository.getPassword(withID: id) func getPassword(withID id: UUID) -> Password? {
return passwordManager.getPassword(withID: id)
} }
func savePassword(name: String, value: String) { @MainActor
let newPassword = Password(name: name, value: value) func getPasswordKeychain(withID id: UUID) -> PasswordKC? {
return passwordManager.getPasswordKeychain(withID: id)
do {
try passwordRepository.savePassword(newPassword)
print ("Saved password successfully")
loadAllPasswords()
} catch {
print("Error saving password: \(error)")
// TODO: display error to user
}
} }
@MainActor
func createPassword(name: String, value: String) {
passwordManager.createPassword(name: name, value: value)
passwords = getAllPasswords()
}
@MainActor
func deletePassword(_ password: Password) { func deletePassword(_ password: Password) {
do { let success = passwordManager.deletePassword(password)
try passwordRepository.deletePassword(withID: password.id) if success {
loadAllPasswords() passwords = getAllPasswords()
} catch {
print("Error deleting password: \(error)")
// TODO: display error to user
} }
} }
} }