use crate::{Availability, IntegrationError};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NoteAccount {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NoteSummary {
pub id: String,
pub name: String,
pub folder: Option<String>,
pub modified: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NotesAccountListing {
#[serde(flatten)]
pub availability: Availability,
pub accounts: Vec<NoteAccount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NotesListing {
#[serde(flatten)]
pub availability: Availability,
pub notes: Vec<NoteSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ReminderList {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ReminderItem {
pub id: String,
pub name: String,
pub list: Option<String>,
pub due: Option<String>,
pub completed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ReminderListListing {
#[serde(flatten)]
pub availability: Availability,
pub lists: Vec<ReminderList>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ReminderItemListing {
#[serde(flatten)]
pub availability: Availability,
pub reminders: Vec<ReminderItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PhotoAlbum {
pub id: String,
pub name: String,
pub count: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PhotoAlbumListing {
#[serde(flatten)]
pub availability: Availability,
pub albums: Vec<PhotoAlbum>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Bookmark {
pub title: String,
pub url: Option<String>,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BookmarkListing {
#[serde(flatten)]
pub availability: Availability,
pub bookmarks: Vec<Bookmark>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FileLocation {
pub id: String,
pub name: String,
pub path: String,
pub exists: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FileLocationListing {
#[serde(flatten)]
pub availability: Availability,
pub locations: Vec<FileLocation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct KeychainStatus {
#[serde(flatten)]
pub availability: Availability,
}
pub fn notes_accounts() -> Result<NotesAccountListing, IntegrationError> {
backend::notes_accounts()
}
pub fn notes_find(query: &str, limit: usize) -> Result<NotesListing, IntegrationError> {
backend::notes_find(query, limit)
}
pub fn reminders_lists() -> Result<ReminderListListing, IntegrationError> {
backend::reminders_lists()
}
pub fn reminders_items(limit: usize) -> Result<ReminderItemListing, IntegrationError> {
backend::reminders_items(limit)
}
pub fn photos_albums() -> Result<PhotoAlbumListing, IntegrationError> {
backend::photos_albums()
}
pub fn bookmarks_list(limit: usize) -> Result<BookmarkListing, IntegrationError> {
backend::bookmarks_list(limit)
}
pub fn files_locations() -> Result<FileLocationListing, IntegrationError> {
backend::files_locations()
}
pub fn keychain_status() -> Result<KeychainStatus, IntegrationError> {
backend::keychain_status()
}
#[cfg(target_os = "macos")]
mod backend {
use super::*;
use serde_json::Value;
use std::path::PathBuf;
use std::process::Command;
const NOTES_JXA: &str = r#"
function app() { const a = Application("/System/Applications/Notes.app"); a.includeStandardAdditions = true; return a; }
function accountOut(a) {
let id = ""; let name = "";
try { id = String(a.id()); } catch (e) {}
try { name = String(a.name()); } catch (e) {}
return {id: id || name, name: name || id};
}
function noteOut(n) {
let id = ""; let name = ""; let folder = null; let modified = null;
try { id = String(n.id()); } catch (e) {}
try { name = String(n.name()); } catch (e) {}
try { folder = String(n.container().name()); } catch (e) {}
try { modified = String(n.modificationDate()); } catch (e) {}
return {id: id || name, name: name || id, folder: folder, modified: modified};
}
function run(argv) {
const mode = argv[0] || "accounts";
try {
const Notes = app();
if (mode === "accounts") {
return JSON.stringify({available:true, backend:"notes_app", reason:null, accounts: Notes.accounts().map(accountOut)});
}
const query = String(argv[1] || "").toLowerCase();
const limit = Number(argv[2] || "50");
let out = [];
Notes.accounts().forEach(a => {
a.notes().forEach(n => {
let hay = "";
try { hay += " " + String(n.name()).toLowerCase(); } catch (e) {}
try { hay += " " + String(n.plaintext()).toLowerCase(); } catch (e) {}
if (!query || hay.indexOf(query) >= 0) out.push(noteOut(n));
});
});
return JSON.stringify({available:true, backend:"notes_app", reason:null, notes: out.slice(0, limit)});
} catch (e) {
if (mode === "accounts") return JSON.stringify({available:false, backend:"notes_app", reason:String(e), accounts:[]});
return JSON.stringify({available:false, backend:"notes_app", reason:String(e), notes:[]});
}
}
"#;
const REMINDERS_JXA: &str = r#"
function app() { const a = Application("/System/Applications/Reminders.app"); a.includeStandardAdditions = true; return a; }
function listOut(l) {
let id = ""; let name = "";
try { id = String(l.id()); } catch (e) {}
try { name = String(l.name()); } catch (e) {}
return {id: id || name, name: name || id};
}
function itemOut(r) {
let id = ""; let name = ""; let list = null; let due = null; let completed = false;
try { id = String(r.id()); } catch (e) {}
try { name = String(r.name()); } catch (e) {}
try { list = String(r.container().name()); } catch (e) {}
try { due = String(r.dueDate()); } catch (e) {}
try { completed = !!r.completed(); } catch (e) {}
return {id: id || name, name: name || id, list: list, due: due, completed: completed};
}
function run(argv) {
const mode = argv[0] || "lists";
try {
const Reminders = app();
if (mode === "lists") {
return JSON.stringify({available:true, backend:"reminders_app", reason:null, lists: Reminders.lists().map(listOut)});
}
const limit = Number(argv[1] || "50");
let out = [];
Reminders.lists().forEach(l => l.reminders().forEach(r => { if (!r.completed()) out.push(itemOut(r)); }));
return JSON.stringify({available:true, backend:"reminders_app", reason:null, reminders: out.slice(0, limit)});
} catch (e) {
if (mode === "lists") return JSON.stringify({available:false, backend:"reminders_app", reason:String(e), lists:[]});
return JSON.stringify({available:false, backend:"reminders_app", reason:String(e), reminders:[]});
}
}
"#;
const PHOTOS_JXA: &str = r#"
function run(argv) {
try {
const Photos = Application("/System/Applications/Photos.app");
Photos.includeStandardAdditions = true;
const albums = Photos.albums().map(a => {
let id = ""; let name = ""; let count = null;
try { id = String(a.id()); } catch (e) {}
try { name = String(a.name()); } catch (e) {}
try { count = a.mediaItems().length; } catch (e) {}
return {id: id || name, name: name || id, count: count};
});
return JSON.stringify({available:true, backend:"photos_app", reason:null, albums: albums});
} catch (e) {
return JSON.stringify({available:false, backend:"photos_app", reason:String(e), albums:[]});
}
}
"#;
pub fn notes_accounts() -> Result<NotesAccountListing, IntegrationError> {
Ok(
run_jxa(NOTES_JXA, &["accounts"]).unwrap_or_else(|e| NotesAccountListing {
availability: Availability::pending("notes_app", e.to_string()),
accounts: vec![],
}),
)
}
pub fn notes_find(query: &str, limit: usize) -> Result<NotesListing, IntegrationError> {
Ok(
run_jxa(NOTES_JXA, &["find", query, &limit.to_string()]).unwrap_or_else(|e| {
NotesListing {
availability: Availability::pending("notes_app", e.to_string()),
notes: vec![],
}
}),
)
}
pub fn reminders_lists() -> Result<ReminderListListing, IntegrationError> {
Ok(
run_jxa(REMINDERS_JXA, &["lists"]).unwrap_or_else(|e| ReminderListListing {
availability: Availability::pending("reminders_app", e.to_string()),
lists: vec![],
}),
)
}
pub fn reminders_items(limit: usize) -> Result<ReminderItemListing, IntegrationError> {
Ok(
run_jxa(REMINDERS_JXA, &["items", &limit.to_string()]).unwrap_or_else(|e| {
ReminderItemListing {
availability: Availability::pending("reminders_app", e.to_string()),
reminders: vec![],
}
}),
)
}
pub fn photos_albums() -> Result<PhotoAlbumListing, IntegrationError> {
Ok(
run_jxa(PHOTOS_JXA, &[]).unwrap_or_else(|e| PhotoAlbumListing {
availability: Availability::pending("photos_app", e.to_string()),
albums: vec![],
}),
)
}
pub fn bookmarks_list(limit: usize) -> Result<BookmarkListing, IntegrationError> {
let mut path = home();
path.push("Library/Safari/Bookmarks.plist");
let output = Command::new("/usr/bin/plutil")
.args(["-convert", "json", "-o", "-"])
.arg(path)
.output()
.map_err(|e| IntegrationError::Backend(format!("bookmarks plutil: {e}")))?;
if !output.status.success() {
return Ok(BookmarkListing {
availability: Availability::pending(
"safari_bookmarks",
String::from_utf8_lossy(&output.stderr).trim().to_string(),
),
bookmarks: vec![],
});
}
let value: Value = serde_json::from_slice(&output.stdout)
.map_err(|e| IntegrationError::Backend(format!("bookmarks json: {e}")))?;
let mut bookmarks = Vec::new();
collect_bookmarks(&value, &mut bookmarks, limit);
Ok(BookmarkListing {
availability: Availability::available("safari_bookmarks"),
bookmarks,
})
}
pub fn files_locations() -> Result<FileLocationListing, IntegrationError> {
let mut locations = Vec::new();
let mut add = |id: &str, name: &str, path: PathBuf| {
locations.push(FileLocation {
id: id.to_string(),
name: name.to_string(),
exists: path.exists(),
path: path.to_string_lossy().to_string(),
});
};
let home = home();
add(
"icloud_drive",
"iCloud Drive",
home.join("Library/Mobile Documents/com~apple~CloudDocs"),
);
add("desktop", "Desktop", home.join("Desktop"));
add("documents", "Documents", home.join("Documents"));
let available = locations.iter().any(|location| location.exists);
Ok(FileLocationListing {
availability: if available {
Availability::available("macos_files")
} else {
Availability::pending("macos_files", "No standard macOS file locations found.")
},
locations,
})
}
pub fn keychain_status() -> Result<KeychainStatus, IntegrationError> {
let check = car_secrets::SecretStore::new().availability();
Ok(KeychainStatus {
availability: if check.available {
Availability::available("keychain")
} else {
Availability::pending(
"keychain",
check
.reason
.unwrap_or_else(|| "macOS Keychain is unavailable.".to_string()),
)
},
})
}
fn run_jxa<T: serde::de::DeserializeOwned>(
script: &str,
args: &[&str],
) -> Result<T, IntegrationError> {
crate::jxa::run(script, args, crate::jxa::DEFAULT_TIMEOUT)
}
fn collect_bookmarks(value: &Value, out: &mut Vec<Bookmark>, limit: usize) {
if out.len() >= limit {
return;
}
if let Some(url) = value.get("URLString").and_then(Value::as_str) {
let title = value
.get("URIDictionary")
.and_then(|v| v.get("title"))
.and_then(Value::as_str)
.or_else(|| value.get("Title").and_then(Value::as_str))
.unwrap_or(url);
out.push(Bookmark {
title: title.to_string(),
url: Some(url.to_string()),
source: "safari".to_string(),
});
}
if let Some(children) = value.get("Children").and_then(Value::as_array) {
for child in children {
collect_bookmarks(child, out, limit);
if out.len() >= limit {
break;
}
}
}
}
fn home() -> PathBuf {
PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
}
}
#[cfg(not(target_os = "macos"))]
mod backend {
use super::*;
use serde_json::Value;
fn graph_pending(surface: &str) -> Availability {
Availability::pending(
"msgraph",
format!(
"Set CAR_MSGRAPH_CLIENT_ID (Azure AD app) to enable the Microsoft \
Graph {surface} backend (car#520)."
),
)
}
pub fn notes_accounts() -> Result<NotesAccountListing, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(match crate::msgraph::onenote_notebooks() {
Ok(nbs) => NotesAccountListing {
availability: Availability::available("msgraph"),
accounts: nbs
.into_iter()
.map(|n| NoteAccount {
id: n.id,
name: n.name,
})
.collect(),
},
Err(e) => NotesAccountListing {
availability: Availability::pending("msgraph", e.to_string()),
accounts: vec![],
},
});
}
Ok(NotesAccountListing {
availability: graph_pending("OneNote notes"),
accounts: vec![],
})
}
pub fn notes_find(query: &str, limit: usize) -> Result<NotesListing, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(match crate::msgraph::onenote_pages(query, limit.max(1)) {
Ok(pages) => NotesListing {
availability: Availability::available("msgraph"),
notes: pages
.into_iter()
.map(|p| NoteSummary {
id: p.id,
name: p.title,
folder: p.notebook,
modified: p.modified,
})
.collect(),
},
Err(e) => NotesListing {
availability: Availability::pending("msgraph", e.to_string()),
notes: vec![],
},
});
}
Ok(NotesListing {
availability: graph_pending("OneNote notes"),
notes: vec![],
})
}
pub fn reminders_lists() -> Result<ReminderListListing, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(match crate::msgraph::todo_lists() {
Ok(lists) => ReminderListListing {
availability: Availability::available("msgraph"),
lists: lists
.into_iter()
.map(|l| ReminderList {
id: l.id,
name: l.name,
})
.collect(),
},
Err(e) => ReminderListListing {
availability: Availability::pending("msgraph", e.to_string()),
lists: vec![],
},
});
}
Ok(ReminderListListing {
availability: graph_pending("To Do reminders"),
lists: vec![],
})
}
pub fn reminders_items(limit: usize) -> Result<ReminderItemListing, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(match crate::msgraph::todo_tasks(limit) {
Ok(tasks) => ReminderItemListing {
availability: Availability::available("msgraph"),
reminders: tasks
.into_iter()
.map(|t| ReminderItem {
id: t.id,
name: t.title,
list: t.list,
due: t.due,
completed: t.completed,
})
.collect(),
},
Err(e) => ReminderItemListing {
availability: Availability::pending("msgraph", e.to_string()),
reminders: vec![],
},
});
}
Ok(ReminderItemListing {
availability: graph_pending("To Do reminders"),
reminders: vec![],
})
}
pub fn photos_albums() -> Result<PhotoAlbumListing, IntegrationError> {
Ok(PhotoAlbumListing {
availability: pending("photos_app"),
albums: vec![],
})
}
pub fn bookmarks_list(limit: usize) -> Result<BookmarkListing, IntegrationError> {
let files = chromium_bookmark_files();
if files.is_empty() {
return Ok(BookmarkListing {
availability: Availability::pending(
"chromium_bookmarks",
"No Chromium-based browser (Chrome, Edge, or Brave) bookmarks found.",
),
bookmarks: vec![],
});
}
let mut bookmarks = Vec::new();
for (source, file) in &files {
if bookmarks.len() >= limit {
break;
}
if let Ok(text) = std::fs::read_to_string(file) {
parse_chromium_bookmarks(&text, source, &mut bookmarks, limit);
}
}
Ok(BookmarkListing {
availability: Availability::available("chromium_bookmarks"),
bookmarks,
})
}
fn chromium_bookmark_files() -> Vec<(String, std::path::PathBuf)> {
use std::path::PathBuf;
let mut roots: Vec<(&'static str, PathBuf)> = Vec::new();
#[cfg(target_os = "windows")]
{
if let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) {
roots.push(("chrome", local.join(r"Google\Chrome\User Data")));
roots.push(("edge", local.join(r"Microsoft\Edge\User Data")));
roots.push((
"brave",
local.join(r"BraveSoftware\Brave-Browser\User Data"),
));
}
}
#[cfg(not(target_os = "windows"))]
{
if let Some(config) = dirs::config_dir() {
roots.push(("chrome", config.join("google-chrome")));
roots.push(("chromium", config.join("chromium")));
roots.push(("edge", config.join("microsoft-edge")));
roots.push(("brave", config.join("BraveSoftware/Brave-Browser")));
}
}
let mut files = Vec::new();
for (browser, base) in roots {
let Ok(entries) = std::fs::read_dir(&base) else {
continue;
};
for entry in entries.flatten() {
let profile_dir = entry.path();
if !profile_dir.is_dir() {
continue;
}
let bookmarks = profile_dir.join("Bookmarks");
if !bookmarks.exists() {
continue;
}
let profile = entry.file_name().to_string_lossy().into_owned();
let source = if profile == "Default" {
browser.to_string()
} else {
format!("{browser}:{profile}")
};
files.push((source, bookmarks));
}
}
files
}
fn parse_chromium_bookmarks(json: &str, source: &str, out: &mut Vec<Bookmark>, limit: usize) {
let Ok(value) = serde_json::from_str::<Value>(json) else {
return;
};
let Some(roots) = value.get("roots") else {
return;
};
for key in ["bookmark_bar", "other", "synced"] {
if out.len() >= limit {
break;
}
if let Some(node) = roots.get(key) {
collect_chromium(node, source, out, limit);
}
}
}
fn collect_chromium(node: &Value, source: &str, out: &mut Vec<Bookmark>, limit: usize) {
if out.len() >= limit {
return;
}
if node.get("type").and_then(Value::as_str) == Some("url") {
if let Some(url) = node.get("url").and_then(Value::as_str) {
let title = node
.get("name")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or(url);
out.push(Bookmark {
title: title.to_string(),
url: Some(url.to_string()),
source: source.to_string(),
});
}
return;
}
if let Some(children) = node.get("children").and_then(Value::as_array) {
for child in children {
collect_chromium(child, source, out, limit);
if out.len() >= limit {
break;
}
}
}
}
pub fn files_locations() -> Result<FileLocationListing, IntegrationError> {
let mut locations = Vec::new();
let mut add = |id: &str, name: &str, path: Option<std::path::PathBuf>| {
if let Some(path) = path {
locations.push(FileLocation {
id: id.to_string(),
name: name.to_string(),
exists: path.exists(),
path: path.to_string_lossy().to_string(),
});
}
};
add("desktop", "Desktop", dirs::desktop_dir());
add("documents", "Documents", dirs::document_dir());
add("downloads", "Downloads", dirs::download_dir());
#[cfg(target_os = "windows")]
{
let onedrive = std::env::var_os("OneDrive")
.map(std::path::PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join("OneDrive")));
add("onedrive", "OneDrive", onedrive);
}
let backend = if cfg!(target_os = "windows") {
"windows_files"
} else {
"xdg_files"
};
let available = locations.iter().any(|location| location.exists);
Ok(FileLocationListing {
availability: if available {
Availability::available(backend)
} else {
Availability::pending(backend, "No standard user file locations found.")
},
locations,
})
}
pub fn keychain_status() -> Result<KeychainStatus, IntegrationError> {
let check = car_secrets::SecretStore::new().availability();
Ok(KeychainStatus {
availability: if check.available {
Availability::available("keychain")
} else {
Availability::pending(
"keychain",
check
.reason
.unwrap_or_else(|| "OS keychain is unavailable.".to_string()),
)
},
})
}
fn pending(backend: &'static str) -> Availability {
Availability::pending(backend, "This Apple integration is only modeled on macOS.")
}
#[cfg(test)]
mod bookmark_tests {
use super::*;
const FIXTURE: &str = r#"{
"roots": {
"bookmark_bar": {
"type": "folder",
"children": [
{ "type": "url", "name": "Rust", "url": "https://www.rust-lang.org/" },
{ "type": "folder", "name": "Dev", "children": [
{ "type": "url", "name": "GitHub", "url": "https://github.com/" }
]}
]
},
"other": {
"type": "folder",
"children": [
{ "type": "url", "name": "", "url": "https://example.com/" }
]
},
"synced": { "type": "folder", "children": [] }
}
}"#;
#[test]
fn parses_nested_bookmarks_across_roots() {
let mut out = Vec::new();
parse_chromium_bookmarks(FIXTURE, "chrome", &mut out, 100);
let urls: Vec<&str> = out.iter().filter_map(|b| b.url.as_deref()).collect();
assert_eq!(
urls,
[
"https://www.rust-lang.org/",
"https://github.com/",
"https://example.com/"
]
);
let example = out
.iter()
.find(|b| b.url.as_deref() == Some("https://example.com/"))
.unwrap();
assert_eq!(example.title, "https://example.com/");
assert!(out.iter().all(|b| b.source == "chrome"));
}
#[test]
fn respects_the_limit() {
let mut out = Vec::new();
parse_chromium_bookmarks(FIXTURE, "edge", &mut out, 2);
assert_eq!(out.len(), 2);
}
#[test]
fn malformed_json_yields_nothing_and_does_not_panic() {
let mut out = Vec::new();
parse_chromium_bookmarks("}{ not json", "brave", &mut out, 10);
assert!(out.is_empty());
}
}
}
#[cfg(all(test, not(target_os = "macos")))]
mod non_macos_tests {
use super::*;
#[test]
fn files_locations_is_real_off_macos() {
let listing = files_locations().expect("files_locations returns a listing");
let expected_backend = if cfg!(target_os = "windows") {
"windows_files"
} else {
"xdg_files"
};
assert_eq!(listing.availability.backend, expected_backend);
#[cfg(target_os = "windows")]
{
for id in ["desktop", "documents", "downloads", "onedrive"] {
assert!(
listing.locations.iter().any(|l| l.id == id),
"expected a {id} location on Windows, got {:?}",
listing.locations
);
}
}
}
#[test]
fn bookmarks_use_the_chromium_backend_off_macos() {
let listing = bookmarks_list(10).expect("bookmarks_list returns a listing");
assert_eq!(listing.availability.backend, "chromium_bookmarks");
if listing.availability.available {
assert!(
listing.bookmarks.iter().all(|b| b.url.is_some()),
"every collected bookmark should carry a URL"
);
} else {
assert!(listing.bookmarks.is_empty());
}
}
#[test]
fn notes_reminders_use_graph_backend_off_macos() {
if crate::msgraph::is_configured() {
return;
}
let na = notes_accounts().unwrap();
assert_eq!(na.availability.backend, "msgraph");
assert!(!na.availability.available && na.accounts.is_empty());
let nf = notes_find("x", 5).unwrap();
assert_eq!(nf.availability.backend, "msgraph");
assert!(nf.notes.is_empty());
let rl = reminders_lists().unwrap();
assert_eq!(rl.availability.backend, "msgraph");
assert!(rl.lists.is_empty());
let ri = reminders_items(5).unwrap();
assert_eq!(ri.availability.backend, "msgraph");
assert!(ri.reminders.is_empty());
}
}