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

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
// 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 PasswordRepository {
class KeychainPasswordRepository: PasswordRepository {
private let context: ModelContext
private let serviceName = "dev.thielker.password"
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,
]
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 []
}
print("fetching passwords")
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<Password>()
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
]
print("fetching password with id \(id)")
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
var password: Password?
do {
guard let data = result as? Data,
let password = try? JSONDecoder().decode(Password.self, from: data) else {
return nil
let request = FetchDescriptor<Password>(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)")
}
}

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@
//
import SwiftUI
import _SwiftData_SwiftUI
struct ListView: View {
@ -16,7 +17,10 @@ struct ListView: 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))
}

View file

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