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:
parent
c3f8103482
commit
61ee809df5
10 changed files with 315 additions and 135 deletions
92
password/data/manager/PasswordManager.swift
Normal file
92
password/data/manager/PasswordManager.swift
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
19
password/data/model/PasswordKC.swift
Normal file
19
password/data/model/PasswordKC.swift
Normal 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
|
||||
}
|
||||
}
|
80
password/data/repository/PasswordKeychainRepository.swift
Normal file
80
password/data/repository/PasswordKeychainRepository.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<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
|
||||
]
|
||||
|
||||
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<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)")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue