From 6c8d632fa088cc9e6ff3954b17f47b2f74201897 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:25:00 +0000 Subject: [PATCH 01/14] Initial plan From e8af5575a1b76869db58e229d0ecdc460869c1a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:29:13 +0000 Subject: [PATCH 02/14] fix: store importer credentials in one keychain entry --- .../Tabs/Importer/ImportManager.swift | 90 ++++++++++++++++--- 1 file changed, 79 insertions(+), 11 deletions(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index f67b4b3..1e1c9d2 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -24,6 +24,10 @@ private enum ImportPhase { class ImportManager: ObservableObject { + private enum CredentialStorage { + static let keychainKey = "importer-credentials" + } + @Published var resultLedger = Ledger() @Published var showLoadingIndicator = true @Published var loadingMessage: String? = "Organizing imports" @@ -47,6 +51,7 @@ class ImportManager: ObservableObject { private var inputRequestCompletion: ((String) -> Bool)? private var errorAlertCompletion: (() -> Void)? private let keychain = SimpleKeychain(accessibility: .whenUnlocked) + private var credentialCache: [String: String]? private var errors = [String]() @@ -311,25 +316,31 @@ extension ImportManager: ImporterDelegate { } func saveCredential(_ value: String, for key: String) { + var credentials = readStoredCredentials() + // seems the keychain does not allow saving empty strings // it will not save but just keep the old value if value.isEmpty { - do { - try keychain.deleteItem(forKey: key) - } catch { - Logger.importer.error("Error deleting credential: \(error)") - } + credentials.removeValue(forKey: key) } else { - do { - try keychain.set(value, forKey: key) - } catch { - Logger.importer.error("Error saving credential: \(error)") - } + credentials[key] = value } + + persistStoredCredentials(credentials) + deleteLegacyCredential(for: key) } func readCredential(_ key: String) -> String? { - try? keychain.string(forKey: key) + let credentials = readStoredCredentials() + if let credential = credentials[key] { + return credential + } + + guard let legacyCredential = try? keychain.string(forKey: key) else { + return nil + } + saveCredential(legacyCredential, for: key) + return legacyCredential } func error(_ error: Error, completion: @escaping () -> Void) { @@ -341,3 +352,60 @@ extension ImportManager: ImporterDelegate { } } + +private extension ImportManager { + + func readStoredCredentials() -> [String: String] { + if let credentialCache { + return credentialCache + } + + guard let storedCredentials = try? keychain.string(forKey: CredentialStorage.keychainKey) else { + credentialCache = [:] + return [:] + } + + guard let data = storedCredentials.data(using: .utf8) else { + Logger.importer.error("Unable to decode stored credentials") + return [:] + } + + do { + let decodedCredentials = try JSONDecoder().decode([String: String].self, from: data) + credentialCache = decodedCredentials + return decodedCredentials + } catch { + Logger.importer.error("Error reading credentials: \(error)") + return [:] + } + } + + func persistStoredCredentials(_ credentials: [String: String]) { + credentialCache = credentials + + if credentials.isEmpty { + do { + try keychain.deleteItem(forKey: CredentialStorage.keychainKey) + } catch { + Logger.importer.error("Error deleting credentials: \(error)") + } + return + } + + do { + let data = try JSONEncoder().encode(credentials) + guard let storedCredentials = String(data: data, encoding: .utf8) else { + Logger.importer.error("Unable to encode credentials") + return + } + try keychain.set(storedCredentials, forKey: CredentialStorage.keychainKey) + } catch { + Logger.importer.error("Error saving credentials: \(error)") + } + } + + func deleteLegacyCredential(for key: String) { + try? keychain.deleteItem(forKey: key) + } + +} From 498b1b609fc26e0c04d9a494c9e37c03a52812d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:30:36 +0000 Subject: [PATCH 03/14] fix: simplify keychain migration handling --- .../Tabs/Importer/ImportManager.swift | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index 1e1c9d2..d2d5a48 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -51,7 +51,6 @@ class ImportManager: ObservableObject { private var inputRequestCompletion: ((String) -> Bool)? private var errorAlertCompletion: (() -> Void)? private let keychain = SimpleKeychain(accessibility: .whenUnlocked) - private var credentialCache: [String: String]? private var errors = [String]() @@ -356,24 +355,17 @@ extension ImportManager: ImporterDelegate { private extension ImportManager { func readStoredCredentials() -> [String: String] { - if let credentialCache { - return credentialCache - } - guard let storedCredentials = try? keychain.string(forKey: CredentialStorage.keychainKey) else { - credentialCache = [:] return [:] } guard let data = storedCredentials.data(using: .utf8) else { - Logger.importer.error("Unable to decode stored credentials") + Logger.importer.error("Unable to convert stored credentials to data") return [:] } do { - let decodedCredentials = try JSONDecoder().decode([String: String].self, from: data) - credentialCache = decodedCredentials - return decodedCredentials + return try JSONDecoder().decode([String: String].self, from: data) } catch { Logger.importer.error("Error reading credentials: \(error)") return [:] @@ -381,13 +373,14 @@ private extension ImportManager { } func persistStoredCredentials(_ credentials: [String: String]) { - credentialCache = credentials - if credentials.isEmpty { do { try keychain.deleteItem(forKey: CredentialStorage.keychainKey) } catch { - Logger.importer.error("Error deleting credentials: \(error)") + guard case SimpleKeychainError.itemNotFound = error else { + Logger.importer.error("Error deleting credentials: \(error)") + return + } } return } @@ -405,7 +398,14 @@ private extension ImportManager { } func deleteLegacyCredential(for key: String) { - try? keychain.deleteItem(forKey: key) + do { + try keychain.deleteItem(forKey: key) + } catch { + guard case SimpleKeychainError.itemNotFound = error else { + Logger.importer.error("Error deleting legacy credential: \(error)") + return + } + } } } From 0b924044495de026385b4feb174e8619acea08a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:31:12 +0000 Subject: [PATCH 04/14] fix: synchronize importer credential migration --- SwiftBeanCountApp/Tabs/Importer/ImportManager.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index d2d5a48..c60d0c3 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -50,6 +50,7 @@ class ImportManager: ObservableObject { private var transaction: ImportedTransaction? private var inputRequestCompletion: ((String) -> Bool)? private var errorAlertCompletion: (() -> Void)? + private let credentialLock = NSRecursiveLock() private let keychain = SimpleKeychain(accessibility: .whenUnlocked) private var errors = [String]() @@ -315,6 +316,9 @@ extension ImportManager: ImporterDelegate { } func saveCredential(_ value: String, for key: String) { + credentialLock.lock() + defer { credentialLock.unlock() } + var credentials = readStoredCredentials() // seems the keychain does not allow saving empty strings @@ -330,6 +334,9 @@ extension ImportManager: ImporterDelegate { } func readCredential(_ key: String) -> String? { + credentialLock.lock() + defer { credentialLock.unlock() } + let credentials = readStoredCredentials() if let credential = credentials[key] { return credential @@ -360,7 +367,7 @@ private extension ImportManager { } guard let data = storedCredentials.data(using: .utf8) else { - Logger.importer.error("Unable to convert stored credentials to data") + Logger.importer.error("Failed to convert credentials string to UTF-8 data") return [:] } @@ -388,7 +395,7 @@ private extension ImportManager { do { let data = try JSONEncoder().encode(credentials) guard let storedCredentials = String(data: data, encoding: .utf8) else { - Logger.importer.error("Unable to encode credentials") + Logger.importer.error("Failed to convert encoded credentials to UTF-8 string") return } try keychain.set(storedCredentials, forKey: CredentialStorage.keychainKey) From 2f8d4ec7d8a4179d4d95bdfc0487af8024d47f50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:31:59 +0000 Subject: [PATCH 05/14] refactor: make keychain lock boundaries explicit --- .../Tabs/Importer/ImportManager.swift | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index c60d0c3..2107b4c 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -319,25 +319,14 @@ extension ImportManager: ImporterDelegate { credentialLock.lock() defer { credentialLock.unlock() } - var credentials = readStoredCredentials() - - // seems the keychain does not allow saving empty strings - // it will not save but just keep the old value - if value.isEmpty { - credentials.removeValue(forKey: key) - } else { - credentials[key] = value - } - - persistStoredCredentials(credentials) - deleteLegacyCredential(for: key) + saveCredentialLocked(value, for: key) } func readCredential(_ key: String) -> String? { credentialLock.lock() defer { credentialLock.unlock() } - let credentials = readStoredCredentials() + let credentials = readStoredCredentialsLocked() if let credential = credentials[key] { return credential } @@ -345,7 +334,7 @@ extension ImportManager: ImporterDelegate { guard let legacyCredential = try? keychain.string(forKey: key) else { return nil } - saveCredential(legacyCredential, for: key) + saveCredentialLocked(legacyCredential, for: key) return legacyCredential } @@ -361,7 +350,24 @@ extension ImportManager: ImporterDelegate { private extension ImportManager { - func readStoredCredentials() -> [String: String] { + // Call only while holding credentialLock. + func saveCredentialLocked(_ value: String, for key: String) { + var credentials = readStoredCredentialsLocked() + + // seems the keychain does not allow saving empty strings + // it will not save but just keep the old value + if value.isEmpty { + credentials.removeValue(forKey: key) + } else { + credentials[key] = value + } + + persistStoredCredentialsLocked(credentials) + deleteLegacyCredentialLocked(for: key) + } + + // Call only while holding credentialLock. + func readStoredCredentialsLocked() -> [String: String] { guard let storedCredentials = try? keychain.string(forKey: CredentialStorage.keychainKey) else { return [:] } @@ -379,7 +385,8 @@ private extension ImportManager { } } - func persistStoredCredentials(_ credentials: [String: String]) { + // Call only while holding credentialLock. + func persistStoredCredentialsLocked(_ credentials: [String: String]) { if credentials.isEmpty { do { try keychain.deleteItem(forKey: CredentialStorage.keychainKey) @@ -404,7 +411,8 @@ private extension ImportManager { } } - func deleteLegacyCredential(for key: String) { + // Call only while holding credentialLock. + func deleteLegacyCredentialLocked(for key: String) { do { try keychain.deleteItem(forKey: key) } catch { From 1dbf5dd6896a2ea83f99388eae6ccd2df1b95261 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:32:32 +0000 Subject: [PATCH 06/14] chore: refine keychain migration logging --- SwiftBeanCountApp/Tabs/Importer/ImportManager.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index 2107b4c..9b7662e 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -50,7 +50,7 @@ class ImportManager: ObservableObject { private var transaction: ImportedTransaction? private var inputRequestCompletion: ((String) -> Bool)? private var errorAlertCompletion: (() -> Void)? - private let credentialLock = NSRecursiveLock() + private let credentialLock = NSLock() private let keychain = SimpleKeychain(accessibility: .whenUnlocked) private var errors = [String]() @@ -334,6 +334,7 @@ extension ImportManager: ImporterDelegate { guard let legacyCredential = try? keychain.string(forKey: key) else { return nil } + Logger.importer.debug("Migrating legacy credential for key: \(key, privacy: .private)") saveCredentialLocked(legacyCredential, for: key) return legacyCredential } @@ -395,6 +396,7 @@ private extension ImportManager { Logger.importer.error("Error deleting credentials: \(error)") return } + Logger.importer.debug("No shared importer credentials found to delete") } return } @@ -420,6 +422,7 @@ private extension ImportManager { Logger.importer.error("Error deleting legacy credential: \(error)") return } + Logger.importer.debug("Legacy credential already absent for key: \(key, privacy: .private)") } } From f5e11c4878aa6d62d0fd03c774181fe3a8e0fe58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:33:03 +0000 Subject: [PATCH 07/14] chore: improve keychain migration error handling --- .../Tabs/Importer/ImportManager.swift | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index 9b7662e..ebc56bd 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -331,7 +331,14 @@ extension ImportManager: ImporterDelegate { return credential } - guard let legacyCredential = try? keychain.string(forKey: key) else { + let legacyCredential: String + do { + legacyCredential = try keychain.string(forKey: key) + } catch { + guard case SimpleKeychainError.itemNotFound = error else { + Logger.importer.error("Error reading legacy credential: \(error)") + return nil + } return nil } Logger.importer.debug("Migrating legacy credential for key: \(key, privacy: .private)") @@ -392,11 +399,9 @@ private extension ImportManager { do { try keychain.deleteItem(forKey: CredentialStorage.keychainKey) } catch { - guard case SimpleKeychainError.itemNotFound = error else { - Logger.importer.error("Error deleting credentials: \(error)") - return - } - Logger.importer.debug("No shared importer credentials found to delete") + logDeletionError(error, + failureMessage: "Error deleting credentials", + notFoundMessage: "No shared importer credentials found to delete") } return } @@ -418,12 +423,18 @@ private extension ImportManager { do { try keychain.deleteItem(forKey: key) } catch { - guard case SimpleKeychainError.itemNotFound = error else { - Logger.importer.error("Error deleting legacy credential: \(error)") - return - } - Logger.importer.debug("Legacy credential already absent for key: \(key, privacy: .private)") + logDeletionError(error, + failureMessage: "Error deleting legacy credential", + notFoundMessage: "Legacy credential already absent for key: \(key, privacy: .private)") + } + } + + func logDeletionError(_ error: Error, failureMessage: String, notFoundMessage: String) { + guard case SimpleKeychainError.itemNotFound = error else { + Logger.importer.error("\(failureMessage): \(error)") + return } + Logger.importer.debug("\(notFoundMessage)") } } From 690924834eaaa3e6de51123e6f83c1a2cb43bc72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:33:30 +0000 Subject: [PATCH 08/14] chore: clarify keychain migration control flow --- SwiftBeanCountApp/Tabs/Importer/ImportManager.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index ebc56bd..55ce328 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -331,9 +331,11 @@ extension ImportManager: ImporterDelegate { return credential } - let legacyCredential: String do { - legacyCredential = try keychain.string(forKey: key) + let legacyCredential = try keychain.string(forKey: key) + Logger.importer.debug("Migrating legacy credential for key: \(key, privacy: .private)") + saveCredentialLocked(legacyCredential, for: key) + return legacyCredential } catch { guard case SimpleKeychainError.itemNotFound = error else { Logger.importer.error("Error reading legacy credential: \(error)") @@ -341,9 +343,6 @@ extension ImportManager: ImporterDelegate { } return nil } - Logger.importer.debug("Migrating legacy credential for key: \(key, privacy: .private)") - saveCredentialLocked(legacyCredential, for: key) - return legacyCredential } func error(_ error: Error, completion: @escaping () -> Void) { @@ -429,6 +428,7 @@ private extension ImportManager { } } + // Does not require credentialLock. func logDeletionError(_ error: Error, failureMessage: String, notFoundMessage: String) { guard case SimpleKeychainError.itemNotFound = error else { Logger.importer.error("\(failureMessage): \(error)") From c853db868f6981befd5c6bbe7f2b2ef6930520dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:33:53 +0000 Subject: [PATCH 09/14] chore: remove key details from migration logs --- SwiftBeanCountApp/Tabs/Importer/ImportManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index 55ce328..6268ffc 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -333,7 +333,7 @@ extension ImportManager: ImporterDelegate { do { let legacyCredential = try keychain.string(forKey: key) - Logger.importer.debug("Migrating legacy credential for key: \(key, privacy: .private)") + Logger.importer.debug("Migrating legacy credential into shared storage") saveCredentialLocked(legacyCredential, for: key) return legacyCredential } catch { @@ -424,7 +424,7 @@ private extension ImportManager { } catch { logDeletionError(error, failureMessage: "Error deleting legacy credential", - notFoundMessage: "Legacy credential already absent for key: \(key, privacy: .private)") + notFoundMessage: "Legacy credential already absent during cleanup") } } From cdfe3fe3e47abdc96fdfbd222d03497acfbfd005 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:34:16 +0000 Subject: [PATCH 10/14] chore: clarify credential storage comments --- SwiftBeanCountApp/Tabs/Importer/ImportManager.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index 6268ffc..09e0460 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -361,8 +361,8 @@ private extension ImportManager { func saveCredentialLocked(_ value: String, for key: String) { var credentials = readStoredCredentialsLocked() - // seems the keychain does not allow saving empty strings - // it will not save but just keep the old value + // the keychain does not allow saving empty strings, + // so empty values remove the stored credential instead if value.isEmpty { credentials.removeValue(forKey: key) } else { @@ -387,7 +387,7 @@ private extension ImportManager { do { return try JSONDecoder().decode([String: String].self, from: data) } catch { - Logger.importer.error("Error reading credentials: \(error)") + Logger.importer.error("Error decoding credentials from JSON: \(error)") return [:] } } From 8b05e1a931f1e80e7cd8b0f7de8401b0083ee0ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:34:53 +0000 Subject: [PATCH 11/14] fix: only delete legacy credentials after migration --- .../Tabs/Importer/ImportManager.swift | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index 09e0460..116e83b 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -319,7 +319,7 @@ extension ImportManager: ImporterDelegate { credentialLock.lock() defer { credentialLock.unlock() } - saveCredentialLocked(value, for: key) + _ = saveCredentialLocked(value, for: key) } func readCredential(_ key: String) -> String? { @@ -333,8 +333,9 @@ extension ImportManager: ImporterDelegate { do { let legacyCredential = try keychain.string(forKey: key) - Logger.importer.debug("Migrating legacy credential into shared storage") - saveCredentialLocked(legacyCredential, for: key) + if saveCredentialLocked(legacyCredential, for: key) { + Logger.importer.debug("Migrated legacy credential into shared storage") + } return legacyCredential } catch { guard case SimpleKeychainError.itemNotFound = error else { @@ -358,7 +359,7 @@ extension ImportManager: ImporterDelegate { private extension ImportManager { // Call only while holding credentialLock. - func saveCredentialLocked(_ value: String, for key: String) { + func saveCredentialLocked(_ value: String, for key: String) -> Bool { var credentials = readStoredCredentialsLocked() // the keychain does not allow saving empty strings, @@ -369,13 +370,23 @@ private extension ImportManager { credentials[key] = value } - persistStoredCredentialsLocked(credentials) + guard persistStoredCredentialsLocked(credentials) else { + return false + } deleteLegacyCredentialLocked(for: key) + return true } // Call only while holding credentialLock. func readStoredCredentialsLocked() -> [String: String] { - guard let storedCredentials = try? keychain.string(forKey: CredentialStorage.keychainKey) else { + let storedCredentials: String + do { + storedCredentials = try keychain.string(forKey: CredentialStorage.keychainKey) + } catch { + guard case SimpleKeychainError.itemNotFound = error else { + Logger.importer.error("Error reading shared credentials: \(error)") + return [:] + } return [:] } @@ -393,7 +404,7 @@ private extension ImportManager { } // Call only while holding credentialLock. - func persistStoredCredentialsLocked(_ credentials: [String: String]) { + func persistStoredCredentialsLocked(_ credentials: [String: String]) -> Bool { if credentials.isEmpty { do { try keychain.deleteItem(forKey: CredentialStorage.keychainKey) @@ -402,18 +413,20 @@ private extension ImportManager { failureMessage: "Error deleting credentials", notFoundMessage: "No shared importer credentials found to delete") } - return + return true } do { let data = try JSONEncoder().encode(credentials) guard let storedCredentials = String(data: data, encoding: .utf8) else { Logger.importer.error("Failed to convert encoded credentials to UTF-8 string") - return + return false } try keychain.set(storedCredentials, forKey: CredentialStorage.keychainKey) + return true } catch { Logger.importer.error("Error saving credentials: \(error)") + return false } } From b09988d915f1a6e1830a62e55542e9635ee297eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:35:18 +0000 Subject: [PATCH 12/14] chore: log failed legacy credential migrations --- SwiftBeanCountApp/Tabs/Importer/ImportManager.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index 116e83b..3491fac 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -335,6 +335,8 @@ extension ImportManager: ImporterDelegate { let legacyCredential = try keychain.string(forKey: key) if saveCredentialLocked(legacyCredential, for: key) { Logger.importer.debug("Migrated legacy credential into shared storage") + } else { + Logger.importer.error("Failed to migrate legacy credential into shared storage") } return legacyCredential } catch { From e8ec0497e17036fb4bfb8ff70cc03920d07026e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 08:35:48 +0000 Subject: [PATCH 13/14] chore: log shared credential save failures --- SwiftBeanCountApp/Tabs/Importer/ImportManager.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index 3491fac..4c9af8e 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -319,7 +319,9 @@ extension ImportManager: ImporterDelegate { credentialLock.lock() defer { credentialLock.unlock() } - _ = saveCredentialLocked(value, for: key) + if !saveCredentialLocked(value, for: key) { + Logger.importer.error("Failed to save credential into shared storage") + } } func readCredential(_ key: String) -> String? { @@ -412,8 +414,8 @@ private extension ImportManager { try keychain.deleteItem(forKey: CredentialStorage.keychainKey) } catch { logDeletionError(error, - failureMessage: "Error deleting credentials", - notFoundMessage: "No shared importer credentials found to delete") + failureMessage: "Error deleting credentials", + notFoundMessage: "No shared importer credentials found to delete") } return true } @@ -438,8 +440,8 @@ private extension ImportManager { try keychain.deleteItem(forKey: key) } catch { logDeletionError(error, - failureMessage: "Error deleting legacy credential", - notFoundMessage: "Legacy credential already absent during cleanup") + failureMessage: "Error deleting legacy credential", + notFoundMessage: "Legacy credential already absent during cleanup") } } From e06b0e96d9bf3383a7df732e011d8791c6ac348b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steffen=20K=C3=B6tte?= Date: Sun, 7 Jun 2026 20:36:17 -0700 Subject: [PATCH 14/14] remove migration --- .../Tabs/Importer/ImportManager.swift | 118 +++++------------- 1 file changed, 30 insertions(+), 88 deletions(-) diff --git a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift index 4c9af8e..503f343 100644 --- a/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift +++ b/SwiftBeanCountApp/Tabs/Importer/ImportManager.swift @@ -319,9 +319,16 @@ extension ImportManager: ImporterDelegate { credentialLock.lock() defer { credentialLock.unlock() } - if !saveCredentialLocked(value, for: key) { - Logger.importer.error("Failed to save credential into shared storage") + var credentials = readStoredCredentialsLocked() + + // the keychain does not allow saving empty strings, + // so empty values remove the stored credential instead + if value.isEmpty { + credentials.removeValue(forKey: key) + } else { + credentials[key] = value } + persistStoredCredentialsLocked(credentials) } func readCredential(_ key: String) -> String? { @@ -329,25 +336,7 @@ extension ImportManager: ImporterDelegate { defer { credentialLock.unlock() } let credentials = readStoredCredentialsLocked() - if let credential = credentials[key] { - return credential - } - - do { - let legacyCredential = try keychain.string(forKey: key) - if saveCredentialLocked(legacyCredential, for: key) { - Logger.importer.debug("Migrated legacy credential into shared storage") - } else { - Logger.importer.error("Failed to migrate legacy credential into shared storage") - } - return legacyCredential - } catch { - guard case SimpleKeychainError.itemNotFound = error else { - Logger.importer.error("Error reading legacy credential: \(error)") - return nil - } - return nil - } + return credentials[key] } func error(_ error: Error, completion: @escaping () -> Void) { @@ -358,34 +347,21 @@ extension ImportManager: ImporterDelegate { } } -} - -private extension ImportManager { - // Call only while holding credentialLock. - func saveCredentialLocked(_ value: String, for key: String) -> Bool { - var credentials = readStoredCredentialsLocked() - - // the keychain does not allow saving empty strings, - // so empty values remove the stored credential instead - if value.isEmpty { - credentials.removeValue(forKey: key) - } else { - credentials[key] = value - } - - guard persistStoredCredentialsLocked(credentials) else { - return false - } - deleteLegacyCredentialLocked(for: key) - return true - } - - // Call only while holding credentialLock. - func readStoredCredentialsLocked() -> [String: String] { - let storedCredentials: String + private func readStoredCredentialsLocked() -> [String: String] { do { - storedCredentials = try keychain.string(forKey: CredentialStorage.keychainKey) + let storedCredentials = try keychain.string(forKey: CredentialStorage.keychainKey) + guard let data = storedCredentials.data(using: .utf8) else { + Logger.importer.error("Failed to convert credentials string to UTF-8 data") + return [:] + } + + do { + return try JSONDecoder().decode([String: String].self, from: data) + } catch { + Logger.importer.error("Error decoding credentials from JSON: \(error)") + return [:] + } } catch { guard case SimpleKeychainError.itemNotFound = error else { Logger.importer.error("Error reading shared credentials: \(error)") @@ -393,65 +369,31 @@ private extension ImportManager { } return [:] } - - guard let data = storedCredentials.data(using: .utf8) else { - Logger.importer.error("Failed to convert credentials string to UTF-8 data") - return [:] - } - - do { - return try JSONDecoder().decode([String: String].self, from: data) - } catch { - Logger.importer.error("Error decoding credentials from JSON: \(error)") - return [:] - } } // Call only while holding credentialLock. - func persistStoredCredentialsLocked(_ credentials: [String: String]) -> Bool { + private func persistStoredCredentialsLocked(_ credentials: [String: String]) { if credentials.isEmpty { do { try keychain.deleteItem(forKey: CredentialStorage.keychainKey) } catch { - logDeletionError(error, - failureMessage: "Error deleting credentials", - notFoundMessage: "No shared importer credentials found to delete") + guard case SimpleKeychainError.itemNotFound = error else { + Logger.importer.error("Error deleting credentials: \(error)") + return + } + Logger.importer.debug("No shared importer credentials found to delete") } - return true } do { let data = try JSONEncoder().encode(credentials) guard let storedCredentials = String(data: data, encoding: .utf8) else { Logger.importer.error("Failed to convert encoded credentials to UTF-8 string") - return false + return } try keychain.set(storedCredentials, forKey: CredentialStorage.keychainKey) - return true } catch { Logger.importer.error("Error saving credentials: \(error)") - return false } } - - // Call only while holding credentialLock. - func deleteLegacyCredentialLocked(for key: String) { - do { - try keychain.deleteItem(forKey: key) - } catch { - logDeletionError(error, - failureMessage: "Error deleting legacy credential", - notFoundMessage: "Legacy credential already absent during cleanup") - } - } - - // Does not require credentialLock. - func logDeletionError(_ error: Error, failureMessage: String, notFoundMessage: String) { - guard case SimpleKeychainError.itemNotFound = error else { - Logger.importer.error("\(failureMessage): \(error)") - return - } - Logger.importer.debug("\(notFoundMessage)") - } - }