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