Files
iPod-export/Sources/IPodSyncKit/ContactsReader.swift
T
thatguygriffandClaude Opus 5 e515277015
Tests / test (push) Failing after 41s
Run tests in CI on Linux
Adds a Gitea Actions workflow running swift test in a swift:6.2 container.

The kit did not build without Apple frameworks, so the two files that need
them are wrapped in #if canImport, and the SwiftUI app target is added to
Package.swift only on macOS. Neither can exist in a Linux container.

This costs no coverage: all 43 tests are about file format and none touch
the Contacts or EventKit readers. The integration those readers represent
remains verifiable only on a Mac.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C5X1tYo9oxAfvoiFMh1QQr
2026-08-08 20:26:29 -03:00

141 lines
4.9 KiB
Swift

// Contacts has no Linux equivalent. Guarding the whole file lets the kit
// build in a Linux container so the format logic can be tested there;
// nothing in this file is exercised by the test suite.
#if canImport(Contacts)
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: ""
}
}
}
#endif