Tests / test (push) Failing after 41s
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
141 lines
4.9 KiB
Swift
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
|