diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index bef5f224b..c2109296e 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -52,7 +52,7 @@ final class AppState { ) loadPersistedData() - guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } + guard !TestRuntime.isActive else { return } secureStore.cleanOrphanedCredentials(validConnectionIds: Set(connections.map(\.id))) Task { diff --git a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift index eb7e58111..7126e37b7 100644 --- a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift +++ b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift @@ -1,24 +1,24 @@ import Foundation +import os import Security import TableProDatabase final class KeychainSecureStore: SecureStore { + private static let logger = Logger(subsystem: "com.TablePro", category: "KeychainSecureStore") + private let serviceName = "com.TablePro" - private let accessGroup: String + private let accessGroup: String? private static var cachedAccessGroup: String? - private static func resolveAccessGroup() -> String { + private static func resolveAccessGroup() -> String? { if let cached = cachedAccessGroup { return cached } guard let prefix = Bundle.main.infoDictionary?["AppIdentifierPrefix"] as? String, !prefix.isEmpty, !prefix.hasPrefix("$(") else { - preconditionFailure( - "AppIdentifierPrefix missing from Info.plist. Add `AppIdentifierPrefix" - + "$(AppIdentifierPrefix)` so Xcode substitutes the team ID prefix " - + "at build time. Without it, keychain access fails with errSecMissingEntitlement (-34018)." - ) + logger.warning("AppIdentifierPrefix unavailable; using the app-local keychain without a shared access group (expected for unsigned or test builds; in a signed build, widget keychain sharing is off).") + return nil } let group = "\(prefix)com.TablePro.shared" @@ -30,6 +30,13 @@ final class KeychainSecureStore: SecureStore { self.accessGroup = Self.resolveAccessGroup() } + private func applyingAccessGroup(_ query: [String: Any]) -> [String: Any] { + guard let accessGroup else { return query } + var query = query + query[kSecAttrAccessGroup as String] = accessGroup + return query + } + func store(_ value: String, forKey key: String) throws { guard let data = value.data(using: .utf8) else { return } @@ -37,23 +44,21 @@ final class KeychainSecureStore: SecureStore { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, - kSecAttrAccessGroup as String: accessGroup, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, kSecUseDataProtectionKeychain as String: true, ] - SecItemDelete(deleteQuery as CFDictionary) + SecItemDelete(applyingAccessGroup(deleteQuery) as CFDictionary) let addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, - kSecAttrAccessGroup as String: accessGroup, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, kSecAttrSynchronizable as String: true, kSecUseDataProtectionKeychain as String: true, ] - let status = SecItemAdd(addQuery as CFDictionary, nil) + let status = SecItemAdd(applyingAccessGroup(addQuery) as CFDictionary, nil) if status != errSecSuccess { throw KeychainError.storeFailed(status) } @@ -64,7 +69,6 @@ final class KeychainSecureStore: SecureStore { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, - kSecAttrAccessGroup as String: accessGroup, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, @@ -72,7 +76,7 @@ final class KeychainSecureStore: SecureStore { ] var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = SecItemCopyMatching(applyingAccessGroup(query) as CFDictionary, &result) if status == errSecItemNotFound { return nil } if status != errSecSuccess { @@ -88,11 +92,10 @@ final class KeychainSecureStore: SecureStore { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, - kSecAttrAccessGroup as String: accessGroup, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, kSecUseDataProtectionKeychain as String: true, ] - let status = SecItemDelete(query as CFDictionary) + let status = SecItemDelete(applyingAccessGroup(query) as CFDictionary) if status != errSecSuccess && status != errSecItemNotFound { throw KeychainError.deleteFailed(status) } @@ -105,14 +108,13 @@ final class KeychainSecureStore: SecureStore { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, - kSecAttrAccessGroup as String: accessGroup, kSecReturnAttributes as String: true, kSecMatchLimit as String: kSecMatchLimitAll, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, kSecUseDataProtectionKeychain as String: true, ] var result: AnyObject? - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + guard SecItemCopyMatching(applyingAccessGroup(query) as CFDictionary, &result) == errSecSuccess, let items = result as? [[String: Any]] else { return } for item in items { diff --git a/TableProMobile/TableProMobile/Platform/TestRuntime.swift b/TableProMobile/TableProMobile/Platform/TestRuntime.swift new file mode 100644 index 000000000..bedaff6a9 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/TestRuntime.swift @@ -0,0 +1,10 @@ +import Foundation + +enum TestRuntime { + static var isActive: Bool { + let environment = ProcessInfo.processInfo.environment + if environment["XCTestConfigurationFilePath"] != nil { return true } + if environment["XCTestBundlePath"] != nil { return true } + return NSClassFromString("XCTestCase") != nil + } +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 484a40f78..84e39dec9 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -65,9 +65,9 @@ struct TableProMobileApp: App { } } .onChange(of: scenePhase) { _, phase in - // Skip lifecycle side-effects under XCTest so unit tests do not + // Skip lifecycle side-effects under tests so unit tests do not // boot CloudKit sync, analytics, or biometric checks. - guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } + guard !TestRuntime.isActive else { return } lockState.handleScenePhase(phase) switch phase { case .active: