car_integrations/contacts/
mod.rs1use 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 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 pub total: usize,
44}
45
46pub fn list_containers() -> Result<ContainerListing, IntegrationError> {
47 backend::list_containers()
48}
49
50pub 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 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 #[cfg(target_os = "windows")]
390 if let Ok(listing) = win_read::list_containers() {
391 return Ok(listing);
392 }
393 if crate::msgraph::is_configured() {
395 return Ok(ContainerListing {
396 availability: Availability::available("msgraph"),
397 containers: vec![Container {
398 id: "msgraph".into(),
399 name: "Microsoft 365".into(),
400 source: Some("microsoft".into()),
401 }],
402 });
403 }
404 Ok(ContainerListing {
405 availability: current_backend_pending(),
406 containers: vec![],
407 })
408 }
409
410 pub fn list_contacts(
411 query: &str,
412 _container_ids: &[String],
413 limit: usize,
414 ) -> Result<ContactListing, IntegrationError> {
415 #[cfg(target_os = "windows")]
421 if let Some(listing) = crate::local_first::LocalRead::classify(
422 win_read::list_contacts(query, _container_ids, limit),
423 |listing| !listing.contacts.is_empty(),
424 )
425 .final_answer(crate::msgraph::is_configured())
426 {
427 return Ok(listing);
428 }
429 if crate::msgraph::is_configured() {
431 match crate::msgraph::contacts(query, if limit == 0 { 200 } else { limit }) {
432 Ok(contacts) => {
433 let total = contacts.len();
434 return Ok(ContactListing {
435 availability: Availability::available("msgraph"),
436 contacts,
437 total,
438 });
439 }
440 Err(e) => {
443 return Ok(ContactListing {
444 availability: Availability::pending("msgraph", e.to_string()),
445 contacts: vec![],
446 total: 0,
447 })
448 }
449 }
450 }
451 Ok(ContactListing {
452 availability: current_backend_pending(),
453 contacts: vec![],
454 total: 0,
455 })
456 }
457
458 #[cfg(target_os = "windows")]
463 mod win_read {
464 use super::*;
465 use windows::ApplicationModel::Contacts::{
466 ContactManager, ContactStore, ContactStoreAccessType,
467 };
468
469 fn open_store() -> Result<ContactStore, IntegrationError> {
470 ContactManager::RequestStoreAsyncWithAccessType(
471 ContactStoreAccessType::AllContactsReadOnly,
472 )
473 .and_then(|o| o.get())
474 .map_err(|e| IntegrationError::Backend(format!("windows contacts store: {e}")))
475 }
476
477 pub fn list_containers() -> Result<ContainerListing, IntegrationError> {
478 let store = open_store()?;
479 let lists = store
480 .FindContactListsAsync()
481 .and_then(|o| o.get())
482 .map_err(|e| IntegrationError::Backend(format!("contact lists: {e}")))?;
483 let mut containers = Vec::new();
484 for list in lists {
485 let id = list.Id().map(|s| s.to_string_lossy()).unwrap_or_default();
486 let name = list
487 .DisplayName()
488 .map(|s| s.to_string_lossy())
489 .unwrap_or_default();
490 containers.push(Container {
491 id,
492 name,
493 source: Some("windows".into()),
494 });
495 }
496 Ok(ContainerListing {
497 availability: Availability::available("windows_contacts"),
498 containers,
499 })
500 }
501
502 pub fn list_contacts(
503 query: &str,
504 container_ids: &[String],
505 limit: usize,
506 ) -> Result<ContactListing, IntegrationError> {
507 let store = open_store()?;
508 let contacts = store
509 .FindContactsAsync()
510 .and_then(|o| o.get())
511 .map_err(|e| IntegrationError::Backend(format!("find contacts: {e}")))?;
512 let q = query.to_lowercase();
513 let cap = if limit == 0 { usize::MAX } else { limit };
514 let want: std::collections::HashSet<&str> =
515 container_ids.iter().map(String::as_str).collect();
516 let mut out = Vec::new();
517 let mut total = 0usize;
518 for c in contacts {
519 let display_name = c
520 .DisplayName()
521 .map(|s| s.to_string_lossy())
522 .unwrap_or_default();
523 let container_id = c
524 .ContactListId()
525 .map(|s| s.to_string_lossy())
526 .ok()
527 .filter(|s| !s.is_empty());
528 if !want.is_empty()
529 && !container_id
530 .as_deref()
531 .map(|c| want.contains(c))
532 .unwrap_or(false)
533 {
534 continue;
535 }
536 let emails: Vec<String> = c
537 .Emails()
538 .map(|v| {
539 v.into_iter()
540 .filter_map(|e| e.Address().ok().map(|s| s.to_string_lossy()))
541 .filter(|s| !s.is_empty())
542 .collect()
543 })
544 .unwrap_or_default();
545 let phone_numbers: Vec<String> = c
546 .Phones()
547 .map(|v| {
548 v.into_iter()
549 .filter_map(|p| p.Number().ok().map(|s| s.to_string_lossy()))
550 .filter(|s| !s.is_empty())
551 .collect()
552 })
553 .unwrap_or_default();
554 let organization = c.JobInfo().ok().and_then(|v| {
555 v.into_iter().find_map(|j| {
556 j.CompanyName()
557 .ok()
558 .map(|s| s.to_string_lossy())
559 .filter(|s| !s.is_empty())
560 })
561 });
562 if !q.is_empty() {
563 let hay = format!(
564 "{display_name} {} {} {}",
565 emails.join(" "),
566 phone_numbers.join(" "),
567 organization.clone().unwrap_or_default()
568 )
569 .to_lowercase();
570 if !hay.contains(&q) {
571 continue;
572 }
573 }
574 total += 1;
575 if out.len() < cap {
576 let id = c.Id().map(|s| s.to_string_lossy()).unwrap_or_default();
577 out.push(Contact {
578 id,
579 container_id,
580 display_name,
581 emails,
582 phone_numbers,
583 organization,
584 });
585 }
586 }
587 Ok(ContactListing {
588 availability: Availability::available("windows_contacts"),
589 contacts: out,
590 total,
591 })
592 }
593 }
594
595 fn current_backend_pending() -> Availability {
596 #[cfg(target_os = "windows")]
597 {
598 Availability::pending(
599 "windows_contacts",
600 "Windows contacts store unreachable; set CAR_MSGRAPH_CLIENT_ID \
601 (Azure AD app) for the Microsoft Graph backend (car#520).",
602 )
603 }
604 #[cfg(target_os = "linux")]
605 {
606 Availability::pending(
607 "eds",
608 "Evolution Data Server + CardDAV backends not yet wired. \
609 API shape is stable; downstream apps can code against it now.",
610 )
611 }
612 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
613 {
614 Availability::pending("none", "Unsupported OS — no contacts backend modeled.")
615 }
616 }
617}