Files
Adolfo Reyna 9fd04b0ce4 feat(privacy-host): add signed native calendar contacts and reminders host
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
2026-08-03 20:27:54 -04:00

293 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Contacts
// MARK: - Contacts data models
struct ContactListItem: Codable, Equatable, Sendable {
let id: String
let name: String
let organization: String
let modifiedAt: String
}
struct ContactEmailLabelValue: Codable, Equatable, Sendable {
let label: String
let value: String
}
struct ContactPhoneLabelValue: Codable, Equatable, Sendable {
let label: String
let value: String
}
struct ContactDetailItem: Codable, Equatable, Sendable {
let id: String
let name: String
let firstName: String
let lastName: String
let organization: String
let jobTitle: String
let emails: [ContactEmailLabelValue]
let phones: [ContactPhoneLabelValue]
let modifiedAt: String
}
struct ContactCreateResult: Codable, Equatable, Sendable {
let id: String
let name: String
let organization: String
}
// MARK: - Contacts provider errors
enum ContactsProviderError: Error, Equatable, Sendable {
case permissionRequired
case permissionDenied
case unavailable(String)
case invalidRequest(String)
case notFound(String)
}
// MARK: - Contact provider protocols
protocol ContactsAuthorizationProviding: Sendable {
func authorizationStatus() -> ContactsAuthorizationStatus
func requestAccess() throws -> Bool
}
enum ContactsAuthorizationStatus: String, Equatable, Sendable {
case authorized
case notDetermined
case denied
case restricted
case unknown
}
protocol ContactsSearchProviding: Sendable {
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem]
}
protocol ContactsReadProviding: Sendable {
func readContact(id: String) throws -> ContactDetailItem
}
protocol ContactsCreateProviding: Sendable {
func createContact(
firstName: String?,
lastName: String?,
organization: String?,
jobTitle: String?,
note: String?,
email: ContactEmailLabelValue?,
phone: ContactPhoneLabelValue?
) throws -> ContactCreateResult
}
protocol FullContactsProviding: ContactsSearchProviding, ContactsReadProviding, ContactsCreateProviding {}
// MARK: - Production Contacts providers
private func displayNameFromFetchedParts(givenName: String, familyName: String) -> String {
let combined = "\(givenName) \(familyName)".trimmingCharacters(in: .whitespacesAndNewlines)
let squashed = combined.components(separatedBy: .whitespaces).filter { !$0.isEmpty }.joined(separator: " ")
return squashed
}
struct ContactsSearchProvider: ContactsSearchProviding {
private func requireAuthorized() throws {
let status = CNContactStore.authorizationStatus(for: .contacts)
switch status {
case .authorized:
return
case .denied, .restricted, .notDetermined:
throw ContactsProviderError.permissionRequired
@unknown default:
throw ContactsProviderError.permissionRequired
}
}
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] {
try requireAuthorized()
let store = CNContactStore()
let keys: [CNKeyDescriptor] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactOrganizationNameKey as CNKeyDescriptor
]
let fetchRequest = CNContactFetchRequest(keysToFetch: keys)
var items: [ContactListItem] = []
let q = query?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let shouldFilter = !(q?.isEmpty ?? true)
do {
try store.enumerateContacts(with: fetchRequest) { contact, stop in
// Production crash fix: the formatter accesses unfetched
// properties like middleName (CNPropertyNotFetchedException is ObjC exception
// and is uncatchable in Swift). Construct deterministically from only fetched keys.
let fullName = displayNameFromFetchedParts(givenName: contact.givenName, familyName: contact.familyName)
let org = contact.organizationName
if shouldFilter, let lowerQ = q {
let haystack = "\(fullName)\n\(org)".lowercased()
if !haystack.contains(lowerQ) {
return
}
}
// Deterministic: do not leak current time; nil modification date yields stable empty string
let modifiedStr: String = ""
items.append(ContactListItem(
id: contact.identifier,
name: fullName,
organization: org,
modifiedAt: modifiedStr
))
if items.count >= limit {
stop.pointee = true
}
}
} catch let err as NSError {
// Permission or other failure
if err.domain == CNErrorDomain {
throw ContactsProviderError.unavailable(err.localizedDescription)
}
throw ContactsProviderError.unavailable(err.localizedDescription)
}
// Deterministic: sorted by name then org then id
items.sort {
if $0.name != $1.name { return $0.name < $1.name }
if $0.organization != $1.organization { return $0.organization < $1.organization }
return $0.id < $1.id
}
if items.count > limit {
return Array(items.prefix(limit))
}
return items
}
}
struct ContactsReadProvider: ContactsReadProviding {
private func requireAuthorized() throws {
let status = CNContactStore.authorizationStatus(for: .contacts)
switch status {
case .authorized:
return
case .denied, .restricted, .notDetermined:
throw ContactsProviderError.permissionRequired
@unknown default:
throw ContactsProviderError.permissionRequired
}
}
func readContact(id: String) throws -> ContactDetailItem {
guard !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ContactsProviderError.invalidRequest("Missing required argument: id")
}
try requireAuthorized()
let store = CNContactStore()
let keys: [CNKeyDescriptor] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactOrganizationNameKey as CNKeyDescriptor,
CNContactJobTitleKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor
]
do {
let contact = try store.unifiedContact(withIdentifier: id, keysToFetch: keys)
// Same crash root cause as search: the system formatter can touch unfetched keys.
// Use only the keys we fetched to avoid ObjC CNPropertyNotFetchedException.
let fullName = displayNameFromFetchedParts(givenName: contact.givenName, familyName: contact.familyName)
// Deterministic: nil modification date yields stable empty string, not current time
let modifiedStr = ""
let emails = contact.emailAddresses.map { labeled in
ContactEmailLabelValue(
label: CNLabeledValue<NSString>.localizedString(forLabel: labeled.label ?? ""),
value: labeled.value as String
)
}
let phones = contact.phoneNumbers.map { labeled in
ContactPhoneLabelValue(
label: CNLabeledValue<CNPhoneNumber>.localizedString(forLabel: labeled.label ?? ""),
value: labeled.value.stringValue
)
}
return ContactDetailItem(
id: contact.identifier,
name: fullName,
firstName: contact.givenName,
lastName: contact.familyName,
organization: contact.organizationName,
jobTitle: contact.jobTitle,
emails: emails,
phones: phones,
modifiedAt: modifiedStr
)
} catch let err as CNError {
if err.code == .recordDoesNotExist {
throw ContactsProviderError.notFound("Contact not found: \(id)")
}
throw ContactsProviderError.unavailable(err.localizedDescription)
} catch let err as ContactsProviderError {
throw err
} catch {
throw ContactsProviderError.unavailable(error.localizedDescription)
}
}
}
struct ContactsCreateProvider: ContactsCreateProviding {
private func requireAuthorized() throws {
let status = CNContactStore.authorizationStatus(for: .contacts)
switch status {
case .authorized:
return
case .denied, .restricted, .notDetermined:
throw ContactsProviderError.permissionRequired
@unknown default:
throw ContactsProviderError.permissionRequired
}
}
func createContact(
firstName: String?,
lastName: String?,
organization: String?,
jobTitle: String?,
note: String?,
email: ContactEmailLabelValue?,
phone: ContactPhoneLabelValue?
) throws -> ContactCreateResult {
try requireAuthorized()
let mutable = CNMutableContact()
mutable.givenName = firstName ?? ""
mutable.familyName = lastName ?? ""
mutable.organizationName = organization ?? ""
mutable.jobTitle = jobTitle ?? ""
if let n = note {
mutable.note = n
}
if let em = email {
mutable.emailAddresses = [CNLabeledValue(label: em.label.isEmpty ? CNLabelWork : em.label, value: em.value as NSString)]
}
if let ph = phone {
mutable.phoneNumbers = [CNLabeledValue(label: ph.label.isEmpty ? CNLabelPhoneNumberMobile : ph.label, value: CNPhoneNumber(stringValue: ph.value))]
}
let store = CNContactStore()
let saveRequest = CNSaveRequest()
saveRequest.add(mutable, toContainerWithIdentifier: nil)
do {
try store.execute(saveRequest)
} catch let err as NSError {
throw ContactsProviderError.unavailable(err.localizedDescription)
}
// CNMutableContact doesn't trigger key-fetch checks; safe deterministic construction still.
// Prefer only locally available strings – avoids future formatter regressions.
let fullName = displayNameFromFetchedParts(givenName: mutable.givenName, familyName: mutable.familyName)
return ContactCreateResult(
id: mutable.identifier,
name: fullName,
organization: organization ?? ""
)
}
}