Skip to main content

car_integrations/contacts/
mod.rs

1//! Contacts capability — enumerate containers, query contacts.
2
3use serde::{Deserialize, Serialize};
4#[cfg(target_os = "macos")]
5use std::process::Command;
6
7use super::{Availability, IntegrationError};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Container {
11    pub id: String,
12    pub name: String,
13    /// Source label (account / provider) when available.
14    pub source: Option<String>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Contact {
19    pub id: String,
20    pub container_id: Option<String>,
21    pub display_name: String,
22    #[serde(default)]
23    pub emails: Vec<String>,
24    #[serde(default)]
25    pub phone_numbers: Vec<String>,
26    pub organization: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ContainerListing {
31    #[serde(flatten)]
32    pub availability: Availability,
33    pub containers: Vec<Container>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ContactListing {
38    #[serde(flatten)]
39    pub availability: Availability,
40    pub contacts: Vec<Contact>,
41    /// Total match count, which may be larger than `contacts.len()` when
42    /// the backend paginates.
43    pub total: usize,
44}
45
46pub fn list_containers() -> Result<ContainerListing, IntegrationError> {
47    backend::list_containers()
48}
49
50/// Query contacts with a free-text `query` (name, email, phone substring).
51/// When `container_ids` is empty, searches all accessible containers.
52pub fn list_contacts(
53    query: &str,
54    container_ids: &[String],
55    limit: usize,
56) -> Result<ContactListing, IntegrationError> {
57    backend::list_contacts(query, container_ids, limit)
58}
59
60#[cfg(target_os = "macos")]
61mod backend {
62    use super::*;
63
64    // Bump on any SCRIPT edit. ensure_helper()'s on-disk cache is keyed on
65    // this string so stale binaries are superseded automatically — without
66    // it, the helper compiled on first use is reused forever even after
67    // bug fixes ship in the Rust source.
68    const HELPER_VERSION: &str = "v4";
69
70    const SCRIPT: &str = r#"
71import Contacts
72import Foundation
73import Dispatch
74
75struct ContainerOut: Codable {
76    let id: String
77    let name: String
78    let source: String?
79}
80
81struct ContactOut: Codable {
82    let id: String
83    let container_id: String?
84    let display_name: String
85    let emails: [String]
86    let phone_numbers: [String]
87    let organization: String?
88}
89
90struct ContainerListing: Codable {
91    let available: Bool
92    let backend: String
93    let reason: String?
94    let containers: [ContainerOut]
95}
96
97struct ContactListing: Codable {
98    let available: Bool
99    let backend: String
100    let reason: String?
101    let contacts: [ContactOut]
102    let total: Int
103}
104
105func emit<T: Encodable>(_ value: T) {
106    let data = try! JSONEncoder().encode(value)
107    FileHandle.standardOutput.write(data)
108}
109
110func ensureAccess(_ store: CNContactStore) -> String? {
111    let status = CNContactStore.authorizationStatus(for: .contacts)
112    switch status {
113    case .authorized:
114        return nil
115    case .notDetermined:
116        let semaphore = DispatchSemaphore(value: 0)
117        var granted = false
118        store.requestAccess(for: .contacts) { ok, _ in
119            granted = ok
120            semaphore.signal()
121        }
122        _ = semaphore.wait(timeout: .now() + 60)
123        return granted ? nil : "Contacts permission was not granted"
124    case .restricted:
125        return "Contacts permission is restricted by system policy"
126    case .denied:
127        return "Contacts permission is denied"
128    case .limited:
129        return nil
130    @unknown default:
131        return "Contacts permission is unavailable"
132    }
133}
134
135func displayName(_ contact: CNContact) -> String {
136    if let formatted = CNContactFormatter.string(from: contact, style: .fullName), !formatted.isEmpty {
137        return formatted
138    }
139    if !contact.nickname.isEmpty { return contact.nickname }
140    if !contact.organizationName.isEmpty { return contact.organizationName }
141    if let first = contact.emailAddresses.first { return first.value as String }
142    return contact.identifier
143}
144
145let args = CommandLine.arguments
146let mode = args.count > 1 ? args[1] : "containers"
147let store = CNContactStore()
148if let reason = ensureAccess(store) {
149    if mode == "contacts" {
150        emit(ContactListing(available: false, backend: "contacts_framework", reason: reason, contacts: [], total: 0))
151    } else {
152        emit(ContainerListing(available: false, backend: "contacts_framework", reason: reason, containers: []))
153    }
154    exit(0)
155}
156
157do {
158    if mode == "contacts" {
159        let query = args.count > 2 ? args[2].lowercased() : ""
160        let limit = args.count > 3 ? (Int(args[3]) ?? 50) : 50
161        let requested = Set(args.dropFirst(4))
162        let containers = try store.containers(matching: nil).filter { requested.isEmpty || requested.contains($0.identifier) }
163        // CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
164        // covers given/family/middle/prefix/suffix and anything else the
165        // formatter reads internally. Hand-rolling the key list previously
166        // omitted CNContactMiddleNameKey, which crashed displayName() with
167        // EXC_CRASH (SIGABRT) via -[CNContact middleName] → NSException
168        // when the formatter touched a contact with a middle name set.
169        let keys: [CNKeyDescriptor] = [
170            CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
171            CNContactIdentifierKey as CNKeyDescriptor,
172            CNContactNicknameKey as CNKeyDescriptor,
173            CNContactOrganizationNameKey as CNKeyDescriptor,
174            CNContactEmailAddressesKey as CNKeyDescriptor,
175            CNContactPhoneNumbersKey as CNKeyDescriptor
176        ]
177        var contacts: [ContactOut] = []
178        var total = 0
179        for container in containers {
180            let predicate = CNContact.predicateForContactsInContainer(withIdentifier: container.identifier)
181            for contact in try store.unifiedContacts(matching: predicate, keysToFetch: keys) {
182                let name = displayName(contact)
183                let emails = contact.emailAddresses.map { $0.value as String }
184                let phones = contact.phoneNumbers.map { $0.value.stringValue }
185                let haystack = ([name, contact.organizationName] + emails + phones).joined(separator: " ").lowercased()
186                if !query.isEmpty && !haystack.contains(query) { continue }
187                total += 1
188                if contacts.count < limit {
189                    contacts.append(ContactOut(
190                        id: contact.identifier,
191                        container_id: container.identifier,
192                        display_name: name,
193                        emails: emails,
194                        phone_numbers: phones,
195                        organization: contact.organizationName.isEmpty ? nil : contact.organizationName
196                    ))
197                }
198            }
199        }
200        emit(ContactListing(available: true, backend: "contacts_framework", reason: nil, contacts: contacts, total: total))
201    } else {
202        let containers = try store.containers(matching: nil).map { container in
203            ContainerOut(id: container.identifier, name: container.name, source: nil)
204        }
205        emit(ContainerListing(available: true, backend: "contacts_framework", reason: nil, containers: containers))
206    }
207} catch {
208    if mode == "contacts" {
209        emit(ContactListing(available: false, backend: "contacts_framework", reason: String(describing: error), contacts: [], total: 0))
210    } else {
211        emit(ContainerListing(available: false, backend: "contacts_framework", reason: String(describing: error), containers: []))
212    }
213}
214"#;
215
216    pub fn list_containers() -> Result<ContainerListing, IntegrationError> {
217        run_swift(&["containers"])
218    }
219
220    pub fn list_contacts(
221        query: &str,
222        container_ids: &[String],
223        limit: usize,
224    ) -> Result<ContactListing, IntegrationError> {
225        let limit = limit.to_string();
226        let mut args = vec!["contacts", query, limit.as_str()];
227        args.extend(container_ids.iter().map(String::as_str));
228        run_swift(&args)
229    }
230
231    fn run_swift<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
232        let helper = ensure_helper()?;
233        let output = Command::new(helper)
234            .env(
235                "SWIFT_MODULE_CACHE_PATH",
236                std::env::temp_dir().join("car-swift-module-cache"),
237            )
238            .env(
239                "CLANG_MODULE_CACHE_PATH",
240                std::env::temp_dir().join("car-clang-module-cache"),
241            )
242            .args(args)
243            .output()
244            .map_err(|e| IntegrationError::Backend(format!("swift: {e}")))?;
245
246        if !output.status.success() {
247            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
248            return Err(IntegrationError::Backend(format!(
249                "contacts swift failed: {stderr}"
250            )));
251        }
252
253        serde_json::from_slice(&output.stdout)
254            .map_err(|e| IntegrationError::Backend(format!("contacts json: {e}")))
255    }
256
257    fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
258        let dir = helper_cache_dir();
259        let app = dir.join(format!("CAR Contacts Helper {HELPER_VERSION}.app"));
260        let contents = app.join("Contents");
261        let macos = contents.join("MacOS");
262        let helper = macos.join("CAR Contacts Helper");
263        if helper.exists() {
264            return Ok(helper);
265        }
266
267        std::fs::create_dir_all(&macos)
268            .map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
269        let source = dir.join(format!("car-contacts-helper-{HELPER_VERSION}.swift"));
270        let plist = contents.join("Info.plist");
271        let entitlements = dir.join(format!("car-contacts-helper-{HELPER_VERSION}.entitlements"));
272        std::fs::write(&source, SCRIPT)
273            .map_err(|e| IntegrationError::Backend(format!("contacts helper source: {e}")))?;
274        std::fs::write(
275            &plist,
276            r#"<?xml version="1.0" encoding="UTF-8"?>
277<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
278<plist version="1.0">
279<dict>
280  <key>CFBundleIdentifier</key>
281  <string>ai.parslee.car.contacts-helper</string>
282  <key>CFBundleExecutable</key>
283  <string>CAR Contacts Helper</string>
284  <key>CFBundleName</key>
285  <string>CAR Contacts Helper</string>
286  <key>CFBundlePackageType</key>
287  <string>APPL</string>
288  <key>CFBundleShortVersionString</key>
289  <string>0.9.0</string>
290  <key>CFBundleVersion</key>
291  <string>1</string>
292  <key>NSContactsUsageDescription</key>
293  <string>CAR reads contacts when an agent uses the contacts capability.</string>
294</dict>
295</plist>
296"#,
297        )
298        .map_err(|e| IntegrationError::Backend(format!("contacts helper plist: {e}")))?;
299        std::fs::write(
300            &entitlements,
301            r#"<?xml version="1.0" encoding="UTF-8"?>
302<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
303<plist version="1.0">
304<dict>
305  <key>com.apple.security.personal-information.addressbook</key>
306  <true/>
307</dict>
308</plist>
309"#,
310        )
311        .map_err(|e| IntegrationError::Backend(format!("contacts helper entitlements: {e}")))?;
312
313        let status = Command::new("/usr/bin/swiftc")
314            .env(
315                "SWIFT_MODULE_CACHE_PATH",
316                std::env::temp_dir().join("car-swift-module-cache"),
317            )
318            .env(
319                "CLANG_MODULE_CACHE_PATH",
320                std::env::temp_dir().join("car-clang-module-cache"),
321            )
322            .arg(&source)
323            .arg("-o")
324            .arg(&helper)
325            .arg("-Xlinker")
326            .arg("-sectcreate")
327            .arg("-Xlinker")
328            .arg("__TEXT")
329            .arg("-Xlinker")
330            .arg("__info_plist")
331            .arg("-Xlinker")
332            .arg(&plist)
333            .status()
334            .map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
335
336        if !status.success() {
337            return Err(IntegrationError::Backend(format!(
338                "contacts helper compile failed with status {status}"
339            )));
340        }
341
342        sign_helper(&app, &entitlements)?;
343        Ok(helper)
344    }
345
346    fn helper_cache_dir() -> std::path::PathBuf {
347        if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
348            return std::path::PathBuf::from(path);
349        }
350        if let Some(home) = std::env::var_os("HOME") {
351            return std::path::PathBuf::from(home)
352                .join("Library")
353                .join("Application Support")
354                .join("CAR")
355                .join("NativeHelpers");
356        }
357        std::env::temp_dir().join("car-native-helpers")
358    }
359
360    fn sign_helper(
361        app: &std::path::Path,
362        entitlements: &std::path::Path,
363    ) -> Result<(), IntegrationError> {
364        let status = Command::new("/usr/bin/codesign")
365            .arg("--force")
366            .arg("--sign")
367            .arg("-")
368            .arg("--entitlements")
369            .arg(entitlements)
370            .arg(app)
371            .status()
372            .map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
373        if !status.success() {
374            return Err(IntegrationError::Backend(format!(
375                "contacts helper codesign failed with status {status}"
376            )));
377        }
378        Ok(())
379    }
380}
381
382#[cfg(not(target_os = "macos"))]
383mod backend {
384    use super::*;
385
386    pub fn list_containers() -> Result<ContainerListing, IntegrationError> {
387        Ok(ContainerListing {
388            availability: current_backend_pending(),
389            containers: vec![],
390        })
391    }
392
393    pub fn list_contacts(
394        _query: &str,
395        _container_ids: &[String],
396        _limit: usize,
397    ) -> Result<ContactListing, IntegrationError> {
398        Ok(ContactListing {
399            availability: current_backend_pending(),
400            contacts: vec![],
401            total: 0,
402        })
403    }
404
405    fn current_backend_pending() -> Availability {
406        #[cfg(target_os = "windows")]
407        {
408            Availability::pending(
409                "windows_contacts",
410                "Windows Contacts API + MS Graph backends not yet wired. \
411             API shape is stable; downstream apps can code against it now.",
412            )
413        }
414        #[cfg(target_os = "linux")]
415        {
416            Availability::pending(
417                "eds",
418                "Evolution Data Server + CardDAV backends not yet wired. \
419             API shape is stable; downstream apps can code against it now.",
420            )
421        }
422        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
423        {
424            Availability::pending("none", "Unsupported OS — no contacts backend modeled.")
425        }
426    }
427}