9fd04b0ce4
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.
293 lines
11 KiB
Swift
293 lines
11 KiB
Swift
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 ?? ""
|
||
)
|
||
}
|
||
}
|