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    /// Thin wrapper over [`crate::jxa::run`]. This used to be its own copy of
390    /// the spawn/write/wait dance, and it wrote the script through
391    /// `child.stdin.as_mut()` — which never closes the pipe, so `osascript -`
392    /// never saw EOF and never ran the script. Every call here burned its 5s
393    /// watchdog and reported an Apple Events timeout, making `notes.*`,
394    /// `reminders.*` and `photos.*` permanently unavailable no matter the TCC
395    /// state (Parslee-ai/car#618).
396    fn run_jxa<T: serde::de::DeserializeOwned>(
397        script: &str,
398        args: &[&str],
399    ) -> Result<T, IntegrationError> {
400        crate::jxa::run(script, args, crate::jxa::DEFAULT_TIMEOUT)
401    }
402
403    fn collect_bookmarks(value: &Value, out: &mut Vec<Bookmark>, limit: usize) {
404        if out.len() >= limit {
405            return;
406        }
407        if let Some(url) = value.get("URLString").and_then(Value::as_str) {
408            let title = value
409                .get("URIDictionary")
410                .and_then(|v| v.get("title"))
411                .and_then(Value::as_str)
412                .or_else(|| value.get("Title").and_then(Value::as_str))
413                .unwrap_or(url);
414            out.push(Bookmark {
415                title: title.to_string(),
416                url: Some(url.to_string()),
417                source: "safari".to_string(),
418            });
419        }
420        if let Some(children) = value.get("Children").and_then(Value::as_array) {
421            for child in children {
422                collect_bookmarks(child, out, limit);
423                if out.len() >= limit {
424                    break;
425                }
426            }
427        }
428    }
429
430    fn home() -> PathBuf {
431        PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
432    }
433}
434
435#[cfg(not(target_os = "macos"))]
436mod backend {
437    use super::*;
438    use serde_json::Value;
439
440    // Notes.app / Reminders.app are macOS-only, but the equivalents for the
441    // signed-in Microsoft 365 account — OneNote and Microsoft To Do — are
442    // reachable through the same Graph device-code OAuth that already backs
443    // Contacts/Calendar/Mail off macOS (car#520). So these are a real backend
444    // when `CAR_MSGRAPH_CLIENT_ID` is set, and a Graph-pointing pending stub
445    // otherwise (not the generic "only modeled on macOS").
446    fn graph_pending(surface: &str) -> Availability {
447        Availability::pending(
448            "msgraph",
449            format!(
450                "Set CAR_MSGRAPH_CLIENT_ID (Azure AD app) to enable the Microsoft \
451                 Graph {surface} backend (car#520)."
452            ),
453        )
454    }
455
456    pub fn notes_accounts() -> Result<NotesAccountListing, IntegrationError> {
457        if crate::msgraph::is_configured() {
458            return Ok(match crate::msgraph::onenote_notebooks() {
459                Ok(nbs) => NotesAccountListing {
460                    availability: Availability::available("msgraph"),
461                    accounts: nbs
462                        .into_iter()
463                        .map(|n| NoteAccount {
464                            id: n.id,
465                            name: n.name,
466                        })
467                        .collect(),
468                },
469                Err(e) => NotesAccountListing {
470                    availability: Availability::pending("msgraph", e.to_string()),
471                    accounts: vec![],
472                },
473            });
474        }
475        Ok(NotesAccountListing {
476            availability: graph_pending("OneNote notes"),
477            accounts: vec![],
478        })
479    }
480
481    pub fn notes_find(query: &str, limit: usize) -> Result<NotesListing, IntegrationError> {
482        if crate::msgraph::is_configured() {
483            return Ok(match crate::msgraph::onenote_pages(query, limit.max(1)) {
484                Ok(pages) => NotesListing {
485                    availability: Availability::available("msgraph"),
486                    notes: pages
487                        .into_iter()
488                        .map(|p| NoteSummary {
489                            id: p.id,
490                            name: p.title,
491                            folder: p.notebook,
492                            modified: p.modified,
493                        })
494                        .collect(),
495                },
496                Err(e) => NotesListing {
497                    availability: Availability::pending("msgraph", e.to_string()),
498                    notes: vec![],
499                },
500            });
501        }
502        Ok(NotesListing {
503            availability: graph_pending("OneNote notes"),
504            notes: vec![],
505        })
506    }
507
508    pub fn reminders_lists() -> Result<ReminderListListing, IntegrationError> {
509        if crate::msgraph::is_configured() {
510            return Ok(match crate::msgraph::todo_lists() {
511                Ok(lists) => ReminderListListing {
512                    availability: Availability::available("msgraph"),
513                    lists: lists
514                        .into_iter()
515                        .map(|l| ReminderList {
516                            id: l.id,
517                            name: l.name,
518                        })
519                        .collect(),
520                },
521                Err(e) => ReminderListListing {
522                    availability: Availability::pending("msgraph", e.to_string()),
523                    lists: vec![],
524                },
525            });
526        }
527        Ok(ReminderListListing {
528            availability: graph_pending("To Do reminders"),
529            lists: vec![],
530        })
531    }
532
533    pub fn reminders_items(limit: usize) -> Result<ReminderItemListing, IntegrationError> {
534        if crate::msgraph::is_configured() {
535            return Ok(match crate::msgraph::todo_tasks(limit) {
536                Ok(tasks) => ReminderItemListing {
537                    availability: Availability::available("msgraph"),
538                    reminders: tasks
539                        .into_iter()
540                        .map(|t| ReminderItem {
541                            id: t.id,
542                            name: t.title,
543                            list: t.list,
544                            due: t.due,
545                            completed: t.completed,
546                        })
547                        .collect(),
548                },
549                Err(e) => ReminderItemListing {
550                    availability: Availability::pending("msgraph", e.to_string()),
551                    reminders: vec![],
552                },
553            });
554        }
555        Ok(ReminderItemListing {
556            availability: graph_pending("To Do reminders"),
557            reminders: vec![],
558        })
559    }
560
561    pub fn photos_albums() -> Result<PhotoAlbumListing, IntegrationError> {
562        Ok(PhotoAlbumListing {
563            availability: pending("photos_app"),
564            albums: vec![],
565        })
566    }
567
568    /// Safari is macOS-only, but the Chromium family (Chrome/Edge/Brave) is the
569    /// default browser off macOS and stores bookmarks as a stable JSON file per
570    /// profile, so this is a real backend on Windows and Linux rather than a
571    /// pending stub. Each installed browser's every profile is read and merged.
572    ///
573    /// Permission posture (deliberate, differs from the macOS Safari path): the
574    /// macOS reader goes through `plutil` against a TCC-protected file, so it can
575    /// be denied by Full Disk Access. A Chromium `Bookmarks` file sits in the
576    /// user's own profile under `%LOCALAPPDATA%` / `~/.config` with no OS gate —
577    /// this process already runs as the user, so reading their own browser
578    /// bookmarks is not a privilege escalation. Like every other integration it
579    /// is still reached only through the auth-token-gated localhost daemon.
580    pub fn bookmarks_list(limit: usize) -> Result<BookmarkListing, IntegrationError> {
581        let files = chromium_bookmark_files();
582        if files.is_empty() {
583            return Ok(BookmarkListing {
584                availability: Availability::pending(
585                    "chromium_bookmarks",
586                    "No Chromium-based browser (Chrome, Edge, or Brave) bookmarks found.",
587                ),
588                bookmarks: vec![],
589            });
590        }
591        // A browser is installed but may simply have no bookmarks yet — that is a
592        // real `available: true` with an empty list, not a failure.
593        let mut bookmarks = Vec::new();
594        for (source, file) in &files {
595            if bookmarks.len() >= limit {
596                break;
597            }
598            if let Ok(text) = std::fs::read_to_string(file) {
599                parse_chromium_bookmarks(&text, source, &mut bookmarks, limit);
600            }
601        }
602        Ok(BookmarkListing {
603            availability: Availability::available("chromium_bookmarks"),
604            bookmarks,
605        })
606    }
607
608    /// Every `<profile>/Bookmarks` file across every installed Chromium browser,
609    /// paired with a source label (`"chrome"`, or `"edge:Profile 1"` for a
610    /// non-default profile). Scans each browser's user-data dir for subdirectories
611    /// that actually contain a `Bookmarks` file, so arbitrarily-named profiles and
612    /// the `System Profile`/`Guest Profile` pseudo-dirs are handled without a
613    /// hard-coded `Default`/`Profile N` list.
614    fn chromium_bookmark_files() -> Vec<(String, std::path::PathBuf)> {
615        use std::path::PathBuf;
616        let mut roots: Vec<(&'static str, PathBuf)> = Vec::new();
617        #[cfg(target_os = "windows")]
618        {
619            if let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) {
620                roots.push(("chrome", local.join(r"Google\Chrome\User Data")));
621                roots.push(("edge", local.join(r"Microsoft\Edge\User Data")));
622                roots.push((
623                    "brave",
624                    local.join(r"BraveSoftware\Brave-Browser\User Data"),
625                ));
626            }
627        }
628        #[cfg(not(target_os = "windows"))]
629        {
630            if let Some(config) = dirs::config_dir() {
631                roots.push(("chrome", config.join("google-chrome")));
632                roots.push(("chromium", config.join("chromium")));
633                roots.push(("edge", config.join("microsoft-edge")));
634                roots.push(("brave", config.join("BraveSoftware/Brave-Browser")));
635            }
636        }
637        let mut files = Vec::new();
638        for (browser, base) in roots {
639            let Ok(entries) = std::fs::read_dir(&base) else {
640                continue;
641            };
642            for entry in entries.flatten() {
643                let profile_dir = entry.path();
644                if !profile_dir.is_dir() {
645                    continue;
646                }
647                let bookmarks = profile_dir.join("Bookmarks");
648                if !bookmarks.exists() {
649                    continue;
650                }
651                let profile = entry.file_name().to_string_lossy().into_owned();
652                let source = if profile == "Default" {
653                    browser.to_string()
654                } else {
655                    format!("{browser}:{profile}")
656                };
657                files.push((source, bookmarks));
658            }
659        }
660        files
661    }
662
663    /// Parse one Chromium `Bookmarks` JSON document, appending every URL bookmark
664    /// (bookmark bar, other, and synced roots, depth-first) up to `limit`.
665    fn parse_chromium_bookmarks(json: &str, source: &str, out: &mut Vec<Bookmark>, limit: usize) {
666        let Ok(value) = serde_json::from_str::<Value>(json) else {
667            return;
668        };
669        let Some(roots) = value.get("roots") else {
670            return;
671        };
672        for key in ["bookmark_bar", "other", "synced"] {
673            if out.len() >= limit {
674                break;
675            }
676            if let Some(node) = roots.get(key) {
677                collect_chromium(node, source, out, limit);
678            }
679        }
680    }
681
682    /// Depth-first walk of a Chromium bookmark node: `type: "url"` nodes are
683    /// collected, `type: "folder"` (and any node carrying `children`) are recursed.
684    fn collect_chromium(node: &Value, source: &str, out: &mut Vec<Bookmark>, limit: usize) {
685        if out.len() >= limit {
686            return;
687        }
688        if node.get("type").and_then(Value::as_str) == Some("url") {
689            if let Some(url) = node.get("url").and_then(Value::as_str) {
690                let title = node
691                    .get("name")
692                    .and_then(Value::as_str)
693                    .filter(|s| !s.is_empty())
694                    .unwrap_or(url);
695                out.push(Bookmark {
696                    title: title.to_string(),
697                    url: Some(url.to_string()),
698                    source: source.to_string(),
699                });
700            }
701            return;
702        }
703        if let Some(children) = node.get("children").and_then(Value::as_array) {
704            for child in children {
705                collect_chromium(child, source, out, limit);
706                if out.len() >= limit {
707                    break;
708                }
709            }
710        }
711    }
712
713    /// Unlike Notes/Reminders/Photos/Safari — which are genuinely macOS-only apps
714    /// — user file locations exist on every platform, so this is a real backend
715    /// on Windows and Linux, not a pending stub. `dirs` resolves them through the
716    /// Known Folder API on Windows (so a Documents/Downloads folder relocated to
717    /// another drive is reported at its real path, not a guessed
718    /// `%USERPROFILE%\Documents`) and XDG user-dirs on Linux.
719    pub fn files_locations() -> Result<FileLocationListing, IntegrationError> {
720        let mut locations = Vec::new();
721        let mut add = |id: &str, name: &str, path: Option<std::path::PathBuf>| {
722            if let Some(path) = path {
723                locations.push(FileLocation {
724                    id: id.to_string(),
725                    name: name.to_string(),
726                    exists: path.exists(),
727                    path: path.to_string_lossy().to_string(),
728                });
729            }
730        };
731        add("desktop", "Desktop", dirs::desktop_dir());
732        add("documents", "Documents", dirs::document_dir());
733        add("downloads", "Downloads", dirs::download_dir());
734        // OneDrive is the standard cloud drive on Windows and has no `dirs`
735        // accessor. Prefer the env var Windows sets when a personal OneDrive is
736        // configured, else fall back to the conventional `%USERPROFILE%\OneDrive`.
737        #[cfg(target_os = "windows")]
738        {
739            let onedrive = std::env::var_os("OneDrive")
740                .map(std::path::PathBuf::from)
741                .or_else(|| dirs::home_dir().map(|h| h.join("OneDrive")));
742            add("onedrive", "OneDrive", onedrive);
743        }
744
745        let backend = if cfg!(target_os = "windows") {
746            "windows_files"
747        } else {
748            "xdg_files"
749        };
750        let available = locations.iter().any(|location| location.exists);
751        Ok(FileLocationListing {
752            availability: if available {
753                Availability::available(backend)
754            } else {
755                Availability::pending(backend, "No standard user file locations found.")
756            },
757            locations,
758        })
759    }
760
761    pub fn keychain_status() -> Result<KeychainStatus, IntegrationError> {
762        let check = car_secrets::SecretStore::new().availability();
763        Ok(KeychainStatus {
764            availability: if check.available {
765                Availability::available("keychain")
766            } else {
767                Availability::pending(
768                    "keychain",
769                    check
770                        .reason
771                        .unwrap_or_else(|| "OS keychain is unavailable.".to_string()),
772                )
773            },
774        })
775    }
776
777    fn pending(backend: &'static str) -> Availability {
778        Availability::pending(backend, "This Apple integration is only modeled on macOS.")
779    }
780
781    #[cfg(test)]
782    mod bookmark_tests {
783        use super::*;
784
785        // A trimmed but structurally faithful Chromium `Bookmarks` document:
786        // nested folders, the three standard roots, and an empty-name entry.
787        const FIXTURE: &str = r#"{
788          "roots": {
789            "bookmark_bar": {
790              "type": "folder",
791              "children": [
792                { "type": "url", "name": "Rust", "url": "https://www.rust-lang.org/" },
793                { "type": "folder", "name": "Dev", "children": [
794                  { "type": "url", "name": "GitHub", "url": "https://github.com/" }
795                ]}
796              ]
797            },
798            "other": {
799              "type": "folder",
800              "children": [
801                { "type": "url", "name": "", "url": "https://example.com/" }
802              ]
803            },
804            "synced": { "type": "folder", "children": [] }
805          }
806        }"#;
807
808        #[test]
809        fn parses_nested_bookmarks_across_roots() {
810            let mut out = Vec::new();
811            parse_chromium_bookmarks(FIXTURE, "chrome", &mut out, 100);
812            let urls: Vec<&str> = out.iter().filter_map(|b| b.url.as_deref()).collect();
813            assert_eq!(
814                urls,
815                [
816                    "https://www.rust-lang.org/",
817                    "https://github.com/",
818                    "https://example.com/"
819                ]
820            );
821            // An empty `name` falls back to the URL as the display title.
822            let example = out
823                .iter()
824                .find(|b| b.url.as_deref() == Some("https://example.com/"))
825                .unwrap();
826            assert_eq!(example.title, "https://example.com/");
827            assert!(out.iter().all(|b| b.source == "chrome"));
828        }
829
830        #[test]
831        fn respects_the_limit() {
832            let mut out = Vec::new();
833            parse_chromium_bookmarks(FIXTURE, "edge", &mut out, 2);
834            assert_eq!(out.len(), 2);
835        }
836
837        #[test]
838        fn malformed_json_yields_nothing_and_does_not_panic() {
839            let mut out = Vec::new();
840            parse_chromium_bookmarks("}{ not json", "brave", &mut out, 10);
841            assert!(out.is_empty());
842        }
843    }
844}
845
846#[cfg(all(test, not(target_os = "macos")))]
847mod non_macos_tests {
848    use super::*;
849
850    // Unlike the other Apple-only surfaces, file locations are a real backend on
851    // Windows/Linux — the CLI's `car files locations` must return the user's
852    // folders, not the macOS-only empty stub it used to.
853    #[test]
854    fn files_locations_is_real_off_macos() {
855        let listing = files_locations().expect("files_locations returns a listing");
856        let expected_backend = if cfg!(target_os = "windows") {
857            "windows_files"
858        } else {
859            "xdg_files"
860        };
861        assert_eq!(listing.availability.backend, expected_backend);
862
863        // On Windows the Known Folder API always resolves Desktop/Documents/
864        // Downloads (+OneDrive, which has no `dirs` accessor), so assert they're
865        // present. On Linux `dirs` reads `~/.config/user-dirs.dirs`, which a
866        // headless CI runner does not have — there `dirs::*` returns `None` and
867        // `locations` is legitimately empty. So off Windows we only require the
868        // envelope to be well-formed and not panic, not any specific entry.
869        #[cfg(target_os = "windows")]
870        {
871            for id in ["desktop", "documents", "downloads", "onedrive"] {
872                assert!(
873                    listing.locations.iter().any(|l| l.id == id),
874                    "expected a {id} location on Windows, got {:?}",
875                    listing.locations
876                );
877            }
878        }
879    }
880
881    // Bookmarks read the Chromium family off macOS. On a runner with no browser
882    // installed this is a well-formed pending/empty envelope; with a browser it's
883    // available. Either way the backend is `chromium_bookmarks` and it never
884    // panics — the deterministic parsing is covered by `backend::bookmark_tests`.
885    #[test]
886    fn bookmarks_use_the_chromium_backend_off_macos() {
887        let listing = bookmarks_list(10).expect("bookmarks_list returns a listing");
888        assert_eq!(listing.availability.backend, "chromium_bookmarks");
889        if listing.availability.available {
890            assert!(
891                listing.bookmarks.iter().all(|b| b.url.is_some()),
892                "every collected bookmark should carry a URL"
893            );
894        } else {
895            assert!(listing.bookmarks.is_empty());
896        }
897    }
898
899    // Off macOS, notes/reminders route through Microsoft Graph (OneNote / To
900    // Do) — the `msgraph` backend, pending when unconfigured, not the old "only
901    // modeled on macOS" stub. Guarded on `is_configured()` so the test never
902    // triggers a device-code login on a runner that happens to set the env.
903    #[test]
904    fn notes_reminders_use_graph_backend_off_macos() {
905        if crate::msgraph::is_configured() {
906            return;
907        }
908        let na = notes_accounts().unwrap();
909        assert_eq!(na.availability.backend, "msgraph");
910        assert!(!na.availability.available && na.accounts.is_empty());
911
912        let nf = notes_find("x", 5).unwrap();
913        assert_eq!(nf.availability.backend, "msgraph");
914        assert!(nf.notes.is_empty());
915
916        let rl = reminders_lists().unwrap();
917        assert_eq!(rl.availability.backend, "msgraph");
918        assert!(rl.lists.is_empty());
919
920        let ri = reminders_items(5).unwrap();
921        assert_eq!(ri.availability.backend, "msgraph");
922        assert!(ri.reminders.is_empty());
923    }
924}