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> {
let mut child = Command::new("/usr/bin/osascript")
.arg("-l")
.arg("JavaScript")
.arg("-")
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| IntegrationError::Backend(format!("osascript: {e}")))?;
{
use std::io::Write;
if let Some(stdin) = child.stdin.as_mut() {
stdin
.write_all(script.as_bytes())
.map_err(|e| IntegrationError::Backend(format!("osascript stdin: {e}")))?;
}
}
let start = std::time::Instant::now();
loop {
if child
.try_wait()
.map_err(|e| IntegrationError::Backend(format!("osascript wait: {e}")))?
.is_some()
{
break;
}
if start.elapsed() > std::time::Duration::from_secs(5) {
let _ = child.kill();
let _ = child.wait();
return Err(IntegrationError::Backend(
"osascript timed out waiting for Apple Events response".to_string(),
));
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
let output = child
.wait_with_output()
.map_err(|e| IntegrationError::Backend(format!("osascript output: {e}")))?;
if !output.status.success() {
return Err(IntegrationError::Backend(format!(
"osascript failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
serde_json::from_slice(&output.stdout)
.map_err(|e| IntegrationError::Backend(format!("osascript json: {e}")))
}
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::*;
pub fn notes_accounts() -> Result<NotesAccountListing, IntegrationError> {
Ok(NotesAccountListing {
availability: pending("notes_app"),
accounts: vec![],
})
}
pub fn notes_find(_query: &str, _limit: usize) -> Result<NotesListing, IntegrationError> {
Ok(NotesListing {
availability: pending("notes_app"),
notes: vec![],
})
}
pub fn reminders_lists() -> Result<ReminderListListing, IntegrationError> {
Ok(ReminderListListing {
availability: pending("reminders_app"),
lists: vec![],
})
}
pub fn reminders_items(_limit: usize) -> Result<ReminderItemListing, IntegrationError> {
Ok(ReminderItemListing {
availability: pending("reminders_app"),
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> {
Ok(BookmarkListing {
availability: pending("safari_bookmarks"),
bookmarks: vec![],
})
}
pub fn files_locations() -> Result<FileLocationListing, IntegrationError> {
Ok(FileLocationListing {
availability: pending("macos_files"),
locations: vec![],
})
}
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.")
}
}