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.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
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 ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user