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