import Contacts import Foundation /// Reads the system address book into `IPodContact` values. /// /// An actor rather than a plain type so the non-`Sendable` `CNContactStore` /// stays isolated, and so enumerating a large address book happens off the main /// thread. public actor ContactsReader { private let store = CNContactStore() public init() {} public nonisolated static var authorizationStatus: CNAuthorizationStatus { CNContactStore.authorizationStatus(for: .contacts) } /// Prompts for Contacts access, returning whether it was granted. public func requestAccess() async -> Bool { (try? await store.requestAccess(for: .contacts)) ?? false } /// The keys fetched for every contact. /// /// `CNContactNoteKey` is absent on purpose: since macOS 11 it requires the /// restricted `com.apple.developer.contacts.notes` entitlement, and asking /// for it without one makes the whole fetch throw. /// /// Computed rather than stored because `CNKeyDescriptor` is not `Sendable`, /// so a static constant would count as shared mutable state. private static var keysToFetch: [CNKeyDescriptor] { [ CNContactIdentifierKey, CNContactNamePrefixKey, CNContactGivenNameKey, CNContactMiddleNameKey, CNContactFamilyNameKey, CNContactNameSuffixKey, CNContactOrganizationNameKey, CNContactJobTitleKey, CNContactPhoneNumbersKey, CNContactEmailAddressesKey, CNContactPostalAddressesKey, CNContactBirthdayKey, ] as [CNKeyDescriptor] } /// Every contact in every account, in the user's preferred sort order. public func fetchContacts() throws -> [IPodContact] { let request = CNContactFetchRequest(keysToFetch: Self.keysToFetch) request.sortOrder = .userDefault request.unifyResults = true var contacts: [IPodContact] = [] try store.enumerateContacts(with: request) { contact, _ in contacts.append(Self.convert(contact)) } return contacts } static func convert(_ contact: CNContact) -> IPodContact { IPodContact( id: contact.identifier, namePrefix: contact.namePrefix, givenName: contact.givenName, middleName: contact.middleName, familyName: contact.familyName, nameSuffix: contact.nameSuffix, organization: contact.organizationName, jobTitle: contact.jobTitle, phoneNumbers: contact.phoneNumbers.map { LabeledValue( label: phoneType(for: $0.label), value: $0.value.stringValue ) }, emailAddresses: contact.emailAddresses.map { LabeledValue( label: emailType(for: $0.label), value: $0.value as String ) }, postalAddresses: contact.postalAddresses.map { PostalAddress( label: postalType(for: $0.label), street: $0.value.street, city: $0.value.city, state: $0.value.state, postalCode: $0.value.postalCode, country: $0.value.country ) }, birthday: contact.birthday ) } /// Maps a Contacts label to vCard 3.0 `TYPE` tokens. /// /// Only tokens vCard 3.0 actually defines are emitted — Contacts labels /// such as "Other" or a user's custom label have no equivalent, and an /// invented `TYPE` is another thing for the device's parser to trip on, so /// those fall back to a bare `VOICE`. static func phoneType(for label: String?) -> String { switch label { case CNLabelPhoneNumberMobile, CNLabelPhoneNumberiPhone: "CELL" case CNLabelHome: "HOME,VOICE" case CNLabelWork: "WORK,VOICE" case CNLabelPhoneNumberMain: "PREF,VOICE" case CNLabelPhoneNumberHomeFax: "HOME,FAX" case CNLabelPhoneNumberWorkFax, CNLabelPhoneNumberOtherFax: "WORK,FAX" case CNLabelPhoneNumberPager: "PAGER" default: "VOICE" } } /// Returns an empty label when there is no standard equivalent; the writer /// then emits `TYPE=INTERNET` alone. static func emailType(for label: String?) -> String { switch label { case CNLabelHome: "HOME" case CNLabelWork: "WORK" default: "" } } static func postalType(for label: String?) -> String { switch label { case CNLabelHome: "HOME" case CNLabelWork: "WORK" default: "" } } }