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 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}