use serde::{Deserialize, Serialize};
#[cfg(target_os = "macos")]
use std::process::Command;
use super::{Availability, IntegrationError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Container {
pub id: String,
pub name: String,
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contact {
pub id: String,
pub container_id: Option<String>,
pub display_name: String,
#[serde(default)]
pub emails: Vec<String>,
#[serde(default)]
pub phone_numbers: Vec<String>,
pub organization: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerListing {
#[serde(flatten)]
pub availability: Availability,
pub containers: Vec<Container>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContactListing {
#[serde(flatten)]
pub availability: Availability,
pub contacts: Vec<Contact>,
pub total: usize,
}
pub fn list_containers() -> Result<ContainerListing, IntegrationError> {
backend::list_containers()
}
pub fn list_contacts(
query: &str,
container_ids: &[String],
limit: usize,
) -> Result<ContactListing, IntegrationError> {
backend::list_contacts(query, container_ids, limit)
}
#[cfg(target_os = "macos")]
mod backend {
use super::*;
const SCRIPT: &str = r#"
import Contacts
import Foundation
import Dispatch
struct ContainerOut: Codable {
let id: String
let name: String
let source: String?
}
struct ContactOut: Codable {
let id: String
let container_id: String?
let display_name: String
let emails: [String]
let phone_numbers: [String]
let organization: String?
}
struct ContainerListing: Codable {
let available: Bool
let backend: String
let reason: String?
let containers: [ContainerOut]
}
struct ContactListing: Codable {
let available: Bool
let backend: String
let reason: String?
let contacts: [ContactOut]
let total: Int
}
func emit<T: Encodable>(_ value: T) {
let data = try! JSONEncoder().encode(value)
FileHandle.standardOutput.write(data)
}
func ensureAccess(_ store: CNContactStore) -> String? {
let status = CNContactStore.authorizationStatus(for: .contacts)
switch status {
case .authorized:
return nil
case .notDetermined:
let semaphore = DispatchSemaphore(value: 0)
var granted = false
store.requestAccess(for: .contacts) { ok, _ in
granted = ok
semaphore.signal()
}
_ = semaphore.wait(timeout: .now() + 60)
return granted ? nil : "Contacts permission was not granted"
case .restricted:
return "Contacts permission is restricted by system policy"
case .denied:
return "Contacts permission is denied"
case .limited:
return nil
@unknown default:
return "Contacts permission is unavailable"
}
}
func displayName(_ contact: CNContact) -> String {
if let formatted = CNContactFormatter.string(from: contact, style: .fullName), !formatted.isEmpty {
return formatted
}
if !contact.nickname.isEmpty { return contact.nickname }
if !contact.organizationName.isEmpty { return contact.organizationName }
if let first = contact.emailAddresses.first { return first.value as String }
return contact.identifier
}
let args = CommandLine.arguments
let mode = args.count > 1 ? args[1] : "containers"
let store = CNContactStore()
if let reason = ensureAccess(store) {
if mode == "contacts" {
emit(ContactListing(available: false, backend: "contacts_framework", reason: reason, contacts: [], total: 0))
} else {
emit(ContainerListing(available: false, backend: "contacts_framework", reason: reason, containers: []))
}
exit(0)
}
do {
if mode == "contacts" {
let query = args.count > 2 ? args[2].lowercased() : ""
let limit = args.count > 3 ? (Int(args[3]) ?? 50) : 50
let requested = Set(args.dropFirst(4))
let containers = try store.containers(matching: nil).filter { requested.isEmpty || requested.contains($0.identifier) }
let keys: [CNKeyDescriptor] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactNicknameKey as CNKeyDescriptor,
CNContactOrganizationNameKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor
]
var contacts: [ContactOut] = []
var total = 0
for container in containers {
let predicate = CNContact.predicateForContactsInContainer(withIdentifier: container.identifier)
for contact in try store.unifiedContacts(matching: predicate, keysToFetch: keys) {
let name = displayName(contact)
let emails = contact.emailAddresses.map { $0.value as String }
let phones = contact.phoneNumbers.map { $0.value.stringValue }
let haystack = ([name, contact.organizationName] + emails + phones).joined(separator: " ").lowercased()
if !query.isEmpty && !haystack.contains(query) { continue }
total += 1
if contacts.count < limit {
contacts.append(ContactOut(
id: contact.identifier,
container_id: container.identifier,
display_name: name,
emails: emails,
phone_numbers: phones,
organization: contact.organizationName.isEmpty ? nil : contact.organizationName
))
}
}
}
emit(ContactListing(available: true, backend: "contacts_framework", reason: nil, contacts: contacts, total: total))
} else {
let containers = try store.containers(matching: nil).map { container in
ContainerOut(id: container.identifier, name: container.name, source: nil)
}
emit(ContainerListing(available: true, backend: "contacts_framework", reason: nil, containers: containers))
}
} catch {
if mode == "contacts" {
emit(ContactListing(available: false, backend: "contacts_framework", reason: String(describing: error), contacts: [], total: 0))
} else {
emit(ContainerListing(available: false, backend: "contacts_framework", reason: String(describing: error), containers: []))
}
}
"#;
pub fn list_containers() -> Result<ContainerListing, IntegrationError> {
run_swift(&["containers"])
}
pub fn list_contacts(
query: &str,
container_ids: &[String],
limit: usize,
) -> Result<ContactListing, IntegrationError> {
let limit = limit.to_string();
let mut args = vec!["contacts", query, limit.as_str()];
args.extend(container_ids.iter().map(String::as_str));
run_swift(&args)
}
fn run_swift<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
let helper = ensure_helper()?;
let output = Command::new(helper)
.env(
"SWIFT_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-swift-module-cache"),
)
.env(
"CLANG_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-clang-module-cache"),
)
.args(args)
.output()
.map_err(|e| IntegrationError::Backend(format!("swift: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(IntegrationError::Backend(format!(
"contacts swift failed: {stderr}"
)));
}
serde_json::from_slice(&output.stdout)
.map_err(|e| IntegrationError::Backend(format!("contacts json: {e}")))
}
fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
let dir = helper_cache_dir();
let app = dir.join("CAR Contacts Helper.app");
let contents = app.join("Contents");
let macos = contents.join("MacOS");
let helper = macos.join("CAR Contacts Helper");
if helper.exists() {
return Ok(helper);
}
std::fs::create_dir_all(&macos)
.map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
let source = dir.join("car-contacts-helper-v3.swift");
let plist = contents.join("Info.plist");
let entitlements = dir.join("car-contacts-helper-v3.entitlements");
std::fs::write(&source, SCRIPT)
.map_err(|e| IntegrationError::Backend(format!("contacts helper source: {e}")))?;
std::fs::write(
&plist,
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>ai.parslee.car.contacts-helper</string>
<key>CFBundleExecutable</key>
<string>CAR Contacts Helper</string>
<key>CFBundleName</key>
<string>CAR Contacts Helper</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.6.1</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSContactsUsageDescription</key>
<string>CAR reads contacts when an agent uses the contacts capability.</string>
</dict>
</plist>
"#,
)
.map_err(|e| IntegrationError::Backend(format!("contacts helper plist: {e}")))?;
std::fs::write(
&entitlements,
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.personal-information.addressbook</key>
<true/>
</dict>
</plist>
"#,
)
.map_err(|e| IntegrationError::Backend(format!("contacts helper entitlements: {e}")))?;
let status = Command::new("/usr/bin/swiftc")
.env(
"SWIFT_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-swift-module-cache"),
)
.env(
"CLANG_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-clang-module-cache"),
)
.arg(&source)
.arg("-o")
.arg(&helper)
.arg("-Xlinker")
.arg("-sectcreate")
.arg("-Xlinker")
.arg("__TEXT")
.arg("-Xlinker")
.arg("__info_plist")
.arg("-Xlinker")
.arg(&plist)
.status()
.map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
if !status.success() {
return Err(IntegrationError::Backend(format!(
"contacts helper compile failed with status {status}"
)));
}
sign_helper(&app, &entitlements)?;
Ok(helper)
}
fn helper_cache_dir() -> std::path::PathBuf {
if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
return std::path::PathBuf::from(path);
}
if let Some(home) = std::env::var_os("HOME") {
return std::path::PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("CAR")
.join("NativeHelpers");
}
std::env::temp_dir().join("car-native-helpers")
}
fn sign_helper(
app: &std::path::Path,
entitlements: &std::path::Path,
) -> Result<(), IntegrationError> {
let status = Command::new("/usr/bin/codesign")
.arg("--force")
.arg("--sign")
.arg("-")
.arg("--entitlements")
.arg(entitlements)
.arg(app)
.status()
.map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
if !status.success() {
return Err(IntegrationError::Backend(format!(
"contacts helper codesign failed with status {status}"
)));
}
Ok(())
}
}
#[cfg(not(target_os = "macos"))]
mod backend {
use super::*;
pub fn list_containers() -> Result<ContainerListing, IntegrationError> {
Ok(ContainerListing {
availability: current_backend_pending(),
containers: vec![],
})
}
pub fn list_contacts(
_query: &str,
_container_ids: &[String],
_limit: usize,
) -> Result<ContactListing, IntegrationError> {
Ok(ContactListing {
availability: current_backend_pending(),
contacts: vec![],
total: 0,
})
}
fn current_backend_pending() -> Availability {
#[cfg(target_os = "windows")]
{
Availability::pending(
"windows_contacts",
"Windows Contacts API + MS Graph backends not yet wired. \
API shape is stable; downstream apps can code against it now.",
)
}
#[cfg(target_os = "linux")]
{
Availability::pending(
"eds",
"Evolution Data Server + CardDAV backends not yet wired. \
API shape is stable; downstream apps can code against it now.",
)
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
Availability::pending("none", "Unsupported OS — no contacts backend modeled.")
}
}
}