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: