From 1b8f3b6665844bee5c58047f6dbca0e8e89041cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Fri, 13 Mar 2026 21:25:49 +0100 Subject: [PATCH] add keychain credential storage, persist account config Co-Authored-By: Claude Opus 4.6 --- Apps/MagnumOpus/ContentView.swift | 20 ++++- .../MagnumOpus/Services/KeychainService.swift | 76 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 Apps/MagnumOpus/Services/KeychainService.swift diff --git a/Apps/MagnumOpus/ContentView.swift b/Apps/MagnumOpus/ContentView.swift index 676928b..3017cbc 100644 --- a/Apps/MagnumOpus/ContentView.swift +++ b/Apps/MagnumOpus/ContentView.swift @@ -45,6 +45,10 @@ struct ContentView: View { guard let (config, credentials) = accountSetup.buildConfig() else { return } do { try viewModel.setup(config: config, credentials: credentials) + try KeychainService.saveCredentials(credentials, for: config.id) + if let data = try? JSONEncoder().encode(config) { + UserDefaults.standard.set(data, forKey: "accountConfig") + } Task { await viewModel.syncNow() await viewModel.loadMailboxes(accountId: config.id) @@ -57,6 +61,20 @@ struct ContentView: View { } private func loadExistingAccount() { - // Keychain loading added in Task 15 + guard let data = UserDefaults.standard.data(forKey: "accountConfig"), + let config = try? JSONDecoder().decode(AccountConfig.self, from: data), + let credentials = try? KeychainService.loadCredentials(for: config.id) + else { return } + do { + try viewModel.setup(config: config, credentials: credentials) + Task { + await viewModel.loadMailboxes(accountId: config.id) + viewModel.startObservingThreads(accountId: config.id) + await viewModel.syncNow() + viewModel.startPeriodicSync() + } + } catch { + // Account config exists but setup failed — show account setup + } } } diff --git a/Apps/MagnumOpus/Services/KeychainService.swift b/Apps/MagnumOpus/Services/KeychainService.swift new file mode 100644 index 0000000..1e90992 --- /dev/null +++ b/Apps/MagnumOpus/Services/KeychainService.swift @@ -0,0 +1,76 @@ +import Foundation +import Security +import Models + +enum KeychainService { + private static let service = "de.felixfoertsch.MagnumOpus" + + static func saveCredentials(_ credentials: Credentials, for accountId: String) throws { + let passwordData = Data(credentials.password.utf8) + + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: accountId, + ] + SecItemDelete(deleteQuery as CFDictionary) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: accountId, + kSecAttrLabel as String: credentials.username, + kSecValueData as String: passwordData, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.saveFailed(status) + } + } + + static func loadCredentials(for accountId: String) throws -> Credentials? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: accountId, + kSecReturnData as String: true, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let attrs = result as? [String: Any], + let data = attrs[kSecValueData as String] as? Data, + let password = String(data: data, encoding: .utf8), + let username = attrs[kSecAttrLabel as String] as? String + else { + if status == errSecItemNotFound { return nil } + throw KeychainError.loadFailed(status) + } + + return Credentials(username: username, password: password) + } + + static func deleteCredentials(for accountId: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: accountId, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.deleteFailed(status) + } + } +} + +enum KeychainError: Error { + case saveFailed(OSStatus) + case loadFailed(OSStatus) + case deleteFailed(OSStatus) +}