Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TableProMobile/TableProMobile/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 19 additions & 17 deletions TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift
Original file line number Diff line number Diff line change
@@ -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 `<key>AppIdentifierPrefix</key>"
+ "<string>$(AppIdentifierPrefix)</string>` 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"
Expand All @@ -30,30 +30,35 @@ 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 }

let deleteQuery: [String: Any] = [
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)
}
Expand All @@ -64,15 +69,14 @@ 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,
kSecUseDataProtectionKeychain as String: true,
]

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 {
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions TableProMobile/TableProMobile/Platform/TestRuntime.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 2 additions & 2 deletions TableProMobile/TableProMobile/TableProMobileApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading