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