Skip to main content

car_integrations/
apple.rs

1//! macOS account-backed Apple app integrations not covered by Calendar,
2//! Contacts, Mail, or Messages.
3//!
4//! These capabilities are advertised by Internet Accounts on macOS. Apple
5//! exposes them through a mix of public automation dictionaries and protected
6//! on-disk stores. Each method returns an availability envelope instead of
7//! treating TCC/Full-Disk-Access denial as an exceptional transport failure.
8
9use crate::{Availability, IntegrationError};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[non_exhaustive]
14pub struct NoteAccount {
15    pub id: String,
16    pub name: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[non_exhaustive]
21pub struct NoteSummary {
22    pub id: String,
23    pub name: String,
24    pub folder: Option<String>,
25    pub modified: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[non_exhaustive]
30pub struct NotesAccountListing {
31    #[serde(flatten)]
32    pub availability: Availability,
33    pub accounts: Vec<NoteAccount>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[non_exhaustive]
38pub struct NotesListing {
39    #[serde(flatten)]
40    pub availability: Availability,
41    pub notes: Vec<NoteSummary>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[non_exhaustive]
46pub struct ReminderList {
47    pub id: String,
48    pub name: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[non_exhaustive]
53pub struct ReminderItem {
54    pub id: String,
55    pub name: String,
56    pub list: Option<String>,
57    pub due: Option<String>,
58    pub completed: bool,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[non_exhaustive]
63pub struct ReminderListListing {
64    #[serde(flatten)]
65    pub availability: Availability,
66    pub lists: Vec<ReminderList>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[non_exhaustive]
71pub struct ReminderItemListing {
72    #[serde(flatten)]
73    pub availability: Availability,
74    pub reminders: Vec<ReminderItem>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[non_exhaustive]
79pub struct PhotoAlbum {
80    pub id: String,
81    pub name: String,
82    pub count: Option<u32>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[non_exhaustive]
87pub struct PhotoAlbumListing {
88    #[serde(flatten)]
89    pub availability: Availability,
90    pub albums: Vec<PhotoAlbum>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[non_exhaustive]
95pub struct Bookmark {
96    pub title: String,
97    pub url: Option<String>,
98    pub source: String,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[non_exhaustive]
103pub struct BookmarkListing {
104    #[serde(flatten)]
105    pub availability: Availability,
106    pub bookmarks: Vec<Bookmark>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[non_exhaustive]
111pub struct FileLocation {
112    pub id: String,
113    pub name: String,
114    pub path: String,
115    pub exists: bool,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[non_exhaustive]
120pub struct FileLocationListing {
121    #[serde(flatten)]
122    pub availability: Availability,
123    pub locations: Vec<FileLocation>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[non_exhaustive]
128pub struct KeychainStatus {
129    #[serde(flatten)]
130    pub availability: Availability,
131}
132
133pub fn notes_accounts() -> Result<NotesAccountListing, IntegrationError> {
134    backend::notes_accounts()
135}
136
137pub fn notes_find(query: &str, limit: usize) -> Result<NotesListing, IntegrationError> {
138    backend::notes_find(query, limit)
139}
140
141pub fn reminders_lists() -> Result<ReminderListListing, IntegrationError> {
142    backend::reminders_lists()
143}
144
145pub fn reminders_items(limit: usize) -> Result<ReminderItemListing, IntegrationError> {
146    backend::reminders_items(limit)
147}
148
149pub fn photos_albums() -> Result<PhotoAlbumListing, IntegrationError> {
150    backend::photos_albums()
151}
152
153pub fn bookmarks_list(limit: usize) -> Result<BookmarkListing, IntegrationError> {
154    backend::bookmarks_list(limit)
155}
156
157pub fn files_locations() -> Result<FileLocationListing, IntegrationError> {
158    backend::files_locations()
159}
160
161pub fn keychain_status() -> Result<KeychainStatus, IntegrationError> {
162    backend::keychain_status()
163}
164
165#[cfg(target_os = "macos")]
166mod backend {
167    use super::*;
168    use serde_json::Value;
169    use std::path::PathBuf;
170    use std::process::Command;
171
172    const NOTES_JXA: &str = r#"
173function app() { const a = Application("/System/Applications/Notes.app"); a.includeStandardAdditions = true; return a; }
174function accountOut(a) {
175  let id = ""; let name = "";
176  try { id = String(a.id()); } catch (e) {}
177  try { name = String(a.name()); } catch (e) {}
178  return {id: id || name, name: name || id};
179}
180function noteOut(n) {
181  let id = ""; let name = ""; let folder = null; let modified = null;
182  try { id = String(n.id()); } catch (e) {}
183  try { name = String(n.name()); } catch (e) {}
184  try { folder = String(n.container().name()); } catch (e) {}
185  try { modified = String(n.modificationDate()); } catch (e) {}
186  return {id: id || name, name: name || id, folder: folder, modified: modified};
187}
188function run(argv) {
189  const mode = argv[0] || "accounts";
190  try {
191    const Notes = app();
192    if (mode === "accounts") {
193      return JSON.stringify({available:true, backend:"notes_app", reason:null, accounts: Notes.accounts().map(accountOut)});
194    }
195    const query = String(argv[1] || "").toLowerCase();
196    const limit = Number(argv[2] || "50");
197    let out = [];
198    Notes.accounts().forEach(a => {
199      a.notes().forEach(n => {
200        let hay = "";
201        try { hay += " " + String(n.name()).toLowerCase(); } catch (e) {}
202        try { hay += " " + String(n.plaintext()).toLowerCase(); } catch (e) {}
203        if (!query || hay.indexOf(query) >= 0) out.push(noteOut(n));
204      });
205    });
206    return JSON.stringify({available:true, backend:"notes_app", reason:null, notes: out.slice(0, limit)});
207  } catch (e) {
208    if (mode === "accounts") return JSON.stringify({available:false, backend:"notes_app", reason:String(e), accounts:[]});
209    return JSON.stringify({available:false, backend:"notes_app", reason:String(e), notes:[]});
210  }
211}
212"#;
213
214    const REMINDERS_JXA: &str = r#"
215function app() { const a = Application("/System/Applications/Reminders.app"); a.includeStandardAdditions = true; return a; }
216function listOut(l) {
217  let id = ""; let name = "";
218  try { id = String(l.id()); } catch (e) {}
219  try { name = String(l.name()); } catch (e) {}
220  return {id: id || name, name: name || id};
221}
222function itemOut(r) {
223  let id = ""; let name = ""; let list = null; let due = null; let completed = false;
224  try { id = String(r.id()); } catch (e) {}
225  try { name = String(r.name()); } catch (e) {}
226  try { list = String(r.container().name()); } catch (e) {}
227  try { due = String(r.dueDate()); } catch (e) {}
228  try { completed = !!r.completed(); } catch (e) {}
229  return {id: id || name, name: name || id, list: list, due: due, completed: completed};
230}
231function run(argv) {
232  const mode = argv[0] || "lists";
233  try {
234    const Reminders = app();
235    if (mode === "lists") {
236      return JSON.stringify({available:true, backend:"reminders_app", reason:null, lists: Reminders.lists().map(listOut)});
237    }
238    const limit = Number(argv[1] || "50");
239    let out = [];
240    Reminders.lists().forEach(l => l.reminders().forEach(r => { if (!r.completed()) out.push(itemOut(r)); }));
241    return JSON.stringify({available:true, backend:"reminders_app", reason:null, reminders: out.slice(0, limit)});
242  } catch (e) {
243    if (mode === "lists") return JSON.stringify({available:false, backend:"reminders_app", reason:String(e), lists:[]});
244    return JSON.stringify({available:false, backend:"reminders_app", reason:String(e), reminders:[]});
245  }
246}
247"#;
248
249    const PHOTOS_JXA: &str = r#"
250function run(argv) {
251  try {
252    const Photos = Application("/System/Applications/Photos.app");
253    Photos.includeStandardAdditions = true;
254    const albums = Photos.albums().map(a => {
255      let id = ""; let name = ""; let count = null;
256      try { id = String(a.id()); } catch (e) {}
257      try { name = String(a.name()); } catch (e) {}
258      try { count = a.mediaItems().length; } catch (e) {}
259      return {id: id || name, name: name || id, count: count};
260    });
261    return JSON.stringify({available:true, backend:"photos_app", reason:null, albums: albums});
262  } catch (e) {
263    return JSON.stringify({available:false, backend:"photos_app", reason:String(e), albums:[]});
264  }
265}
266"#;
267
268    pub fn notes_accounts() -> Result<NotesAccountListing, IntegrationError> {
269        Ok(
270            run_jxa(NOTES_JXA, &["accounts"]).unwrap_or_else(|e| NotesAccountListing {
271                availability: Availability::pending("notes_app", e.to_string()),
272                accounts: vec![],
273            }),
274        )
275    }
276
277    pub fn notes_find(query: &str, limit: usize) -> Result<NotesListing, IntegrationError> {
278        Ok(
279            run_jxa(NOTES_JXA, &["find", query, &limit.to_string()]).unwrap_or_else(|e| {
280                NotesListing {
281                    availability: Availability::pending("notes_app", e.to_string()),
282                    notes: vec![],
283                }
284            }),
285        )
286    }
287
288    pub fn reminders_lists() -> Result<ReminderListListing, IntegrationError> {
289        Ok(
290            run_jxa(REMINDERS_JXA, &["lists"]).unwrap_or_else(|e| ReminderListListing {
291                availability: Availability::pending("reminders_app", e.to_string()),
292                lists: vec![],
293            }),
294        )
295    }
296
297    pub fn reminders_items(limit: usize) -> Result<ReminderItemListing, IntegrationError> {
298        Ok(
299            run_jxa(REMINDERS_JXA, &["items", &limit.to_string()]).unwrap_or_else(|e| {
300                ReminderItemListing {
301                    availability: Availability::pending("reminders_app", e.to_string()),
302                    reminders: vec![],
303                }
304            }),
305        )
306    }
307
308    pub fn photos_albums() -> Result<PhotoAlbumListing, IntegrationError> {
309        Ok(
310            run_jxa(PHOTOS_JXA, &[]).unwrap_or_else(|e| PhotoAlbumListing {
311                availability: Availability::pending("photos_app", e.to_string()),
312                albums: vec![],
313            }),
314        )
315    }
316
317    pub fn bookmarks_list(limit: usize) -> Result<BookmarkListing, IntegrationError> {
318        let mut path = home();
319        path.push("Library/Safari/Bookmarks.plist");
320        let output = Command::new("/usr/bin/plutil")
321            .args(["-convert", "json", "-o", "-"])
322            .arg(path)
323            .output()
324            .map_err(|e| IntegrationError::Backend(format!("bookmarks plutil: {e}")))?;
325        if !output.status.success() {
326            return Ok(BookmarkListing {
327                availability: Availability::pending(
328                    "safari_bookmarks",
329                    String::from_utf8_lossy(&output.stderr).trim().to_string(),
330                ),
331                bookmarks: vec![],
332            });
333        }
334        let value: Value = serde_json::from_slice(&output.stdout)
335            .map_err(|e| IntegrationError::Backend(format!("bookmarks json: {e}")))?;
336        let mut bookmarks = Vec::new();
337        collect_bookmarks(&value, &mut bookmarks, limit);
338        Ok(BookmarkListing {
339            availability: Availability::available("safari_bookmarks"),
340            bookmarks,
341        })
342    }
343
344    pub fn files_locations() -> Result<FileLocationListing, IntegrationError> {
345        let mut locations = Vec::new();
346        let mut add = |id: &str, name: &str, path: PathBuf| {
347            locations.push(FileLocation {
348                id: id.to_string(),
349                name: name.to_string(),
350                exists: path.exists(),
351                path: path.to_string_lossy().to_string(),
352            });
353        };
354        let home = home();
355        add(
356            "icloud_drive",
357            "iCloud Drive",
358            home.join("Library/Mobile Documents/com~apple~CloudDocs"),
359        );
360        add("desktop", "Desktop", home.join("Desktop"));
361        add("documents", "Documents", home.join("Documents"));
362        let available = locations.iter().any(|location| location.exists);
363        Ok(FileLocationListing {
364            availability: if available {
365                Availability::available("macos_files")
366            } else {
367                Availability::pending("macos_files", "No standard macOS file locations found.")
368            },
369            locations,
370        })
371    }
372
373    pub fn keychain_status() -> Result<KeychainStatus, IntegrationError> {
374        let check = car_secrets::SecretStore::new().availability();
375        Ok(KeychainStatus {
376            availability: if check.available {
377                Availability::available("keychain")
378            } else {
379                Availability::pending(
380                    "keychain",
381                    check
382                        .reason
383                        .unwrap_or_else(|| "macOS Keychain is unavailable.".to_string()),
384                )
385            },
386        })
387    }
388
389    fn run_jxa<T: serde::de::DeserializeOwned>(
390        script: &str,
391        args: &[&str],
392    ) -> Result<T, IntegrationError> {
393        let mut child = Command::new("/usr/bin/osascript")
394            .arg("-l")
395            .arg("JavaScript")
396            .arg("-")
397            .args(args)
398            .stdin(std::process::Stdio::piped())
399            .stdout(std::process::Stdio::piped())
400            .stderr(std::process::Stdio::piped())
401            .spawn()
402            .map_err(|e| IntegrationError::Backend(format!("osascript: {e}")))?;
403        {
404            use std::io::Write;
405            if let Some(stdin) = child.stdin.as_mut() {
406                stdin
407                    .write_all(script.as_bytes())
408                    .map_err(|e| IntegrationError::Backend(format!("osascript stdin: {e}")))?;
409            }
410        }
411
412        let start = std::time::Instant::now();
413        loop {
414            if child
415                .try_wait()
416                .map_err(|e| IntegrationError::Backend(format!("osascript wait: {e}")))?
417                .is_some()
418            {
419                break;
420            }
421            if start.elapsed() > std::time::Duration::from_secs(5) {
422                let _ = child.kill();
423                let _ = child.wait();
424                return Err(IntegrationError::Backend(
425                    "osascript timed out waiting for Apple Events response".to_string(),
426                ));
427            }
428            std::thread::sleep(std::time::Duration::from_millis(50));
429        }
430
431        let output = child
432            .wait_with_output()
433            .map_err(|e| IntegrationError::Backend(format!("osascript output: {e}")))?;
434        if !output.status.success() {
435            return Err(IntegrationError::Backend(format!(
436                "osascript failed: {}",
437                String::from_utf8_lossy(&output.stderr).trim()
438            )));
439        }
440        serde_json::from_slice(&output.stdout)
441            .map_err(|e| IntegrationError::Backend(format!("osascript json: {e}")))
442    }
443
444    fn collect_bookmarks(value: &Value, out: &mut Vec<Bookmark>, limit: usize) {
445        if out.len() >= limit {
446            return;
447        }
448        if let Some(url) = value.get("URLString").and_then(Value::as_str) {
449            let title = value
450                .get("URIDictionary")
451                .and_then(|v| v.get("title"))
452                .and_then(Value::as_str)
453                .or_else(|| value.get("Title").and_then(Value::as_str))
454                .unwrap_or(url);
455            out.push(Bookmark {
456                title: title.to_string(),
457                url: Some(url.to_string()),
458                source: "safari".to_string(),
459            });
460        }
461        if let Some(children) = value.get("Children").and_then(Value::as_array) {
462            for child in children {
463                collect_bookmarks(child, out, limit);
464                if out.len() >= limit {
465                    break;
466                }
467            }
468        }
469    }
470
471    fn home() -> PathBuf {
472        PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
473    }
474}
475
476#[cfg(not(target_os = "macos"))]
477mod backend {
478    use super::*;
479
480    pub fn notes_accounts() -> Result<NotesAccountListing, IntegrationError> {
481        Ok(NotesAccountListing {
482            availability: pending("notes_app"),
483            accounts: vec![],
484        })
485    }
486
487    pub fn notes_find(_query: &str, _limit: usize) -> Result<NotesListing, IntegrationError> {
488        Ok(NotesListing {
489            availability: pending("notes_app"),
490            notes: vec![],
491        })
492    }
493
494    pub fn reminders_lists() -> Result<ReminderListListing, IntegrationError> {
495        Ok(ReminderListListing {
496            availability: pending("reminders_app"),
497            lists: vec![],
498        })
499    }
500
501    pub fn reminders_items(_limit: usize) -> Result<ReminderItemListing, IntegrationError> {
502        Ok(ReminderItemListing {
503            availability: pending("reminders_app"),
504            reminders: vec![],
505        })
506    }
507
508    pub fn photos_albums() -> Result<PhotoAlbumListing, IntegrationError> {
509        Ok(PhotoAlbumListing {
510            availability: pending("photos_app"),
511            albums: vec![],
512        })
513    }
514
515    pub fn bookmarks_list(_limit: usize) -> Result<BookmarkListing, IntegrationError> {
516        Ok(BookmarkListing {
517            availability: pending("safari_bookmarks"),
518            bookmarks: vec![],
519        })
520    }
521
522    pub fn files_locations() -> Result<FileLocationListing, IntegrationError> {
523        Ok(FileLocationListing {
524            availability: pending("macos_files"),
525            locations: vec![],
526        })
527    }
528
529    pub fn keychain_status() -> Result<KeychainStatus, IntegrationError> {
530        let check = car_secrets::SecretStore::new().availability();
531        Ok(KeychainStatus {
532            availability: if check.available {
533                Availability::available("keychain")
534            } else {
535                Availability::pending(
536                    "keychain",
537                    check
538                        .reason
539                        .unwrap_or_else(|| "OS keychain is unavailable.".to_string()),
540                )
541            },
542        })
543    }
544
545    fn pending(backend: &'static str) -> Availability {
546        Availability::pending(backend, "This Apple integration is only modeled on macOS.")
547    }
548}