1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5#[cfg(target_os = "macos")]
6use std::process::Command;
7
8use super::{Availability, IntegrationError};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Calendar {
12 pub id: String,
14 pub title: String,
16 pub source: Option<String>,
18 pub color: Option<String>,
20 pub writable: bool,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Event {
26 pub id: String,
27 pub calendar_id: String,
28 pub title: String,
29 pub start: DateTime<Utc>,
30 pub end: DateTime<Utc>,
31 #[serde(default)]
32 pub all_day: bool,
33 pub location: Option<String>,
34 pub notes: Option<String>,
35 #[serde(default)]
41 pub attendees: Vec<Attendee>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub status: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Attendee {
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub name: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub email: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub status: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub role: Option<String>,
66 #[serde(default)]
68 pub is_current_user: bool,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CalendarListing {
73 #[serde(flatten)]
74 pub availability: Availability,
75 pub calendars: Vec<Calendar>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct EventListing {
80 #[serde(flatten)]
81 pub availability: Availability,
82 pub events: Vec<Event>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct EventCreateInput {
87 pub calendar_id: String,
88 pub title: String,
89 pub start: DateTime<Utc>,
90 pub end: DateTime<Utc>,
91 #[serde(default)]
92 pub all_day: bool,
93 #[serde(default)]
94 pub notes: Option<String>,
95 #[serde(default)]
96 pub location: Option<String>,
97 #[serde(default)]
98 pub url: Option<String>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct EventUpdateInput {
103 pub event_id: String,
104 #[serde(default)]
105 pub title: Option<String>,
106 #[serde(default)]
107 pub start: Option<DateTime<Utc>>,
108 #[serde(default)]
109 pub end: Option<DateTime<Utc>>,
110 #[serde(default)]
111 pub all_day: Option<bool>,
112 #[serde(default)]
113 pub notes: Option<String>,
114 #[serde(default)]
115 pub location: Option<String>,
116 #[serde(default)]
117 pub url: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct EventMutationResult {
122 pub ok: bool,
123 #[serde(default)]
124 pub event: Option<Event>,
125 #[serde(default)]
126 pub reason: Option<String>,
127}
128
129pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
130 backend::list_calendars()
131}
132
133pub fn authorization_status() -> String {
139 backend::authorization_status()
140}
141
142pub fn list_events(
145 start: DateTime<Utc>,
146 end: DateTime<Utc>,
147 calendar_ids: &[String],
148) -> Result<EventListing, IntegrationError> {
149 backend::list_events(start, end, calendar_ids)
150}
151
152pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
156 backend::create_event(input)
157}
158
159pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
165 backend::update_event(input)
166}
167
168pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
172 backend::delete_event(event_id)
173}
174
175#[cfg(target_os = "macos")]
176mod backend {
177 use super::*;
178
179 const HELPER_VERSION: &str = "v6";
188
189 const SCRIPT: &str = r##"
190import EventKit
191import Foundation
192import Dispatch
193
194struct Availability: Codable {
195 let available: Bool
196 let backend: String
197 let reason: String?
198}
199
200struct CalendarOut: Codable {
201 let id: String
202 let title: String
203 let source: String?
204 let color: String?
205 let writable: Bool
206}
207
208struct AttendeeOut: Codable {
209 let name: String?
210 let email: String?
211 let status: String?
212 let role: String?
213 let is_current_user: Bool
214}
215
216struct EventOut: Codable {
217 let id: String
218 let calendar_id: String
219 let title: String
220 let start: Date
221 let end: Date
222 let all_day: Bool
223 let location: String?
224 let notes: String?
225 let attendees: [AttendeeOut]
226 let status: String
227}
228
229struct CalendarListing: Codable {
230 let available: Bool
231 let backend: String
232 let reason: String?
233 let calendars: [CalendarOut]
234}
235
236struct EventListing: Codable {
237 let available: Bool
238 let backend: String
239 let reason: String?
240 let events: [EventOut]
241}
242
243struct EventCreateInput: Codable {
244 let calendar_id: String
245 let title: String
246 let start: Date
247 let end: Date
248 let all_day: Bool?
249 let notes: String?
250 let location: String?
251 let url: String?
252}
253
254struct EventUpdateInput: Codable {
255 let event_id: String
256 let title: String?
257 let start: Date?
258 let end: Date?
259 let all_day: Bool?
260 let notes: String?
261 let location: String?
262 let url: String?
263}
264
265struct EventMutationOut: Codable {
266 let ok: Bool
267 let event: EventOut?
268 let reason: String?
269}
270
271func emit<T: Encodable>(_ value: T) {
272 let encoder = JSONEncoder()
273 encoder.dateEncodingStrategy = .iso8601
274 let data = try! encoder.encode(value)
275 FileHandle.standardOutput.write(data)
276}
277
278func attendeeStatus(_ s: EKParticipantStatus) -> String {
279 switch s {
280 case .accepted: return "accepted"
281 case .declined: return "declined"
282 case .tentative: return "tentative"
283 case .pending: return "pending"
284 case .delegated: return "delegated"
285 case .completed: return "completed"
286 case .inProcess: return "in_process"
287 default: return "unknown"
288 }
289}
290
291func attendeeRole(_ r: EKParticipantRole) -> String {
292 switch r {
293 case .required: return "required"
294 case .optional: return "optional"
295 case .chair: return "chair"
296 case .nonParticipant: return "non_participant"
297 default: return "unknown"
298 }
299}
300
301func attendeeOut(_ p: EKParticipant) -> AttendeeOut {
302 // EventKit exposes the address as a `mailto:` URL; surface the bare email.
303 var email: String? = nil
304 let urlStr = p.url.absoluteString
305 if urlStr.lowercased().hasPrefix("mailto:") {
306 email = String(urlStr.dropFirst("mailto:".count))
307 }
308 return AttendeeOut(
309 name: p.name,
310 email: email,
311 status: attendeeStatus(p.participantStatus),
312 role: attendeeRole(p.participantRole),
313 is_current_user: p.isCurrentUser
314 )
315}
316
317func eventStatusString(_ s: EKEventStatus) -> String {
318 switch s {
319 case .confirmed: return "confirmed"
320 case .tentative: return "tentative"
321 case .canceled: return "canceled"
322 default: return "none"
323 }
324}
325
326func eventOut(_ event: EKEvent) -> EventOut {
327 return EventOut(
328 id: event.eventIdentifier ?? "\(event.calendarItemIdentifier)-\(event.startDate.timeIntervalSince1970)",
329 calendar_id: event.calendar.calendarIdentifier,
330 title: event.title ?? "",
331 start: event.startDate,
332 end: event.endDate,
333 all_day: event.isAllDay,
334 location: event.location,
335 notes: event.notes,
336 attendees: (event.attendees ?? []).map { attendeeOut($0) },
337 status: eventStatusString(event.status)
338 )
339}
340
341func unavailable<T: Encodable>(_ reason: String, empty: T) {
342 emit(empty)
343}
344
345let internetDateFormatter: ISO8601DateFormatter = {
346 let formatter = ISO8601DateFormatter()
347 formatter.formatOptions = [.withInternetDateTime]
348 return formatter
349}()
350
351let fractionalDateFormatter: ISO8601DateFormatter = {
352 let formatter = ISO8601DateFormatter()
353 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
354 return formatter
355}()
356
357func decodeDate(_ decoder: Decoder) throws -> Date {
358 let container = try decoder.singleValueContainer()
359 let raw = try container.decode(String.self)
360 if let date = fractionalDateFormatter.date(from: raw) ?? internetDateFormatter.date(from: raw) {
361 return date
362 }
363 throw DecodingError.dataCorruptedError(
364 in: container,
365 debugDescription: "Invalid RFC3339 date: \(raw)"
366 )
367}
368
369func ensureAccess(_ store: EKEventStore) -> String? {
370 let status = EKEventStore.authorizationStatus(for: .event)
371 switch status {
372 case .authorized:
373 return nil
374 case .notDetermined:
375 let semaphore = DispatchSemaphore(value: 0)
376 var granted = false
377 if #available(macOS 14.0, *) {
378 store.requestFullAccessToEvents { ok, _ in
379 granted = ok
380 semaphore.signal()
381 }
382 } else {
383 store.requestAccess(to: .event) { ok, _ in
384 granted = ok
385 semaphore.signal()
386 }
387 }
388 _ = semaphore.wait(timeout: .now() + 60)
389 return granted ? nil : "Calendar permission was not granted"
390 case .restricted:
391 return "Calendar permission is restricted by system policy"
392 case .denied:
393 return "Calendar permission is denied"
394 case .writeOnly:
395 return "Calendar permission is write-only; read access is required"
396 case .fullAccess:
397 return nil
398 @unknown default:
399 return "Calendar permission is unavailable"
400 }
401}
402
403func hexColor(_ cgColor: CGColor?) -> String? {
404 guard let cgColor = cgColor, let components = cgColor.components else { return nil }
405 let r: CGFloat
406 let g: CGFloat
407 let b: CGFloat
408 if components.count >= 3 {
409 r = components[0]
410 g = components[1]
411 b = components[2]
412 } else if components.count >= 1 {
413 r = components[0]
414 g = components[0]
415 b = components[0]
416 } else {
417 return nil
418 }
419 return String(format: "#%02X%02X%02X", Int(max(0, min(1, r)) * 255), Int(max(0, min(1, g)) * 255), Int(max(0, min(1, b)) * 255))
420}
421
422let args = CommandLine.arguments
423let mode = args.count > 1 ? args[1] : "calendars"
424
425// Non-prompting authorization-status query (car-releases#71). Reports the
426// CURRENT EKEventStore.authorizationStatus — never instantiates a store or
427// triggers a TCC prompt, unlike ensureAccess below — so `permissionStatus`
428// can be an honest gate. Handles the macOS 14 split (.fullAccess/.writeOnly
429// replaced .authorized).
430if mode == "auth-status" {
431 struct AuthStatusOut: Codable { let status: String }
432 let label: String
433 switch EKEventStore.authorizationStatus(for: .event) {
434 case .authorized, .fullAccess: label = "granted"
435 case .writeOnly: label = "write_only"
436 case .denied: label = "denied"
437 case .restricted: label = "restricted"
438 case .notDetermined: label = "not_determined"
439 @unknown default: label = "unknown"
440 }
441 emit(AuthStatusOut(status: label))
442 exit(0)
443}
444
445let store = EKEventStore()
446if let reason = ensureAccess(store) {
447 if mode == "events" {
448 emit(EventListing(available: false, backend: "eventkit", reason: reason, events: []))
449 } else if mode == "create" || mode == "update" || mode == "delete" {
450 emit(EventMutationOut(ok: false, event: nil, reason: reason))
451 } else {
452 emit(CalendarListing(available: false, backend: "eventkit", reason: reason, calendars: []))
453 }
454 exit(0)
455}
456
457if mode == "events" {
458 let formatter = ISO8601DateFormatter()
459 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
460 let fallback = ISO8601DateFormatter()
461 guard args.count >= 4,
462 let start = formatter.date(from: args[2]) ?? fallback.date(from: args[2]),
463 let end = formatter.date(from: args[3]) ?? fallback.date(from: args[3]) else {
464 emit(EventListing(available: false, backend: "eventkit", reason: "Invalid RFC3339 date range", events: []))
465 exit(0)
466 }
467 let requested = Set(args.dropFirst(4))
468 let calendars = store.calendars(for: .event).filter { requested.isEmpty || requested.contains($0.calendarIdentifier) }
469 let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
470 let events = store.events(matching: predicate).map { eventOut($0) }
471 emit(EventListing(available: true, backend: "eventkit", reason: nil, events: events))
472} else if mode == "create" {
473 guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
474 emit(EventMutationOut(ok: false, event: nil, reason: "create requires JSON payload as args[2]"))
475 exit(0)
476 }
477 let decoder = JSONDecoder()
478 decoder.dateDecodingStrategy = .custom(decodeDate)
479 guard let input = try? decoder.decode(EventCreateInput.self, from: payload) else {
480 emit(EventMutationOut(ok: false, event: nil, reason: "invalid_create_input_json"))
481 exit(0)
482 }
483 guard let calendar = store.calendar(withIdentifier: input.calendar_id) else {
484 emit(EventMutationOut(ok: false, event: nil, reason: "calendar_not_found"))
485 exit(0)
486 }
487 guard calendar.allowsContentModifications else {
488 emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
489 exit(0)
490 }
491 let event = EKEvent(eventStore: store)
492 event.calendar = calendar
493 event.title = input.title
494 event.startDate = input.start
495 event.endDate = input.end
496 event.isAllDay = input.all_day ?? false
497 if let notes = input.notes, !notes.isEmpty { event.notes = notes }
498 if let location = input.location, !location.isEmpty { event.location = location }
499 if let urlRaw = input.url, !urlRaw.isEmpty, let parsed = URL(string: urlRaw) { event.url = parsed }
500 do {
501 try store.save(event, span: .thisEvent)
502 emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
503 } catch {
504 emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
505 }
506} else if mode == "update" {
507 guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
508 emit(EventMutationOut(ok: false, event: nil, reason: "update requires JSON payload as args[2]"))
509 exit(0)
510 }
511 let decoder = JSONDecoder()
512 decoder.dateDecodingStrategy = .custom(decodeDate)
513 guard let input = try? decoder.decode(EventUpdateInput.self, from: payload) else {
514 emit(EventMutationOut(ok: false, event: nil, reason: "invalid_update_input_json"))
515 exit(0)
516 }
517 guard let event = store.event(withIdentifier: input.event_id) else {
518 emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
519 exit(0)
520 }
521 guard event.calendar.allowsContentModifications else {
522 emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
523 exit(0)
524 }
525 if let title = input.title { event.title = title }
526 if let start = input.start { event.startDate = start }
527 if let end = input.end { event.endDate = end }
528 if let allDay = input.all_day { event.isAllDay = allDay }
529 // Empty-string for notes/location/url means "clear the field". The
530 // None case (field absent in JSON) leaves the existing value alone.
531 if let notes = input.notes { event.notes = notes.isEmpty ? nil : notes }
532 if let location = input.location { event.location = location.isEmpty ? nil : location }
533 if let urlRaw = input.url {
534 event.url = urlRaw.isEmpty ? nil : URL(string: urlRaw)
535 }
536 do {
537 try store.save(event, span: .thisEvent)
538 emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
539 } catch {
540 emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
541 }
542} else if mode == "delete" {
543 guard args.count >= 3 else {
544 emit(EventMutationOut(ok: false, event: nil, reason: "delete requires event_id as args[2]"))
545 exit(0)
546 }
547 let eventId = args[2]
548 guard let event = store.event(withIdentifier: eventId) else {
549 emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
550 exit(0)
551 }
552 guard event.calendar.allowsContentModifications else {
553 emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
554 exit(0)
555 }
556 do {
557 try store.remove(event, span: .thisEvent)
558 emit(EventMutationOut(ok: true, event: nil, reason: nil))
559 } catch {
560 emit(EventMutationOut(ok: false, event: nil, reason: "delete_failed: \(error.localizedDescription)"))
561 }
562} else {
563 let calendars = store.calendars(for: .event).map { cal in
564 CalendarOut(
565 id: cal.calendarIdentifier,
566 title: cal.title,
567 source: cal.source?.title,
568 color: hexColor(cal.cgColor),
569 writable: cal.allowsContentModifications
570 )
571 }
572 emit(CalendarListing(available: true, backend: "eventkit", reason: nil, calendars: calendars))
573}
574"##;
575
576 pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
577 run_swift(&["calendars"])
578 }
579
580 #[derive(serde::Deserialize)]
581 struct AuthStatus {
582 status: String,
583 }
584
585 pub fn authorization_status() -> String {
586 match run_swift::<AuthStatus>(&["auth-status"]) {
591 Ok(a) => a.status,
592 Err(e) => {
593 tracing::debug!(error = %e, "calendar auth-status helper failed; reporting unknown");
594 "unknown".to_string()
595 }
596 }
597 }
598
599 pub fn list_events(
600 start: DateTime<Utc>,
601 end: DateTime<Utc>,
602 calendar_ids: &[String],
603 ) -> Result<EventListing, IntegrationError> {
604 let start = start.to_rfc3339();
605 let end = end.to_rfc3339();
606 let mut args = vec!["events", start.as_str(), end.as_str()];
607 args.extend(calendar_ids.iter().map(String::as_str));
608 run_swift(&args)
609 }
610
611 pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
612 let payload = serde_json::to_string(&input)
613 .map_err(|e| IntegrationError::Backend(format!("encode create input: {e}")))?;
614 run_swift(&["create", &payload])
615 }
616
617 pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
618 let payload = serde_json::to_string(&input)
619 .map_err(|e| IntegrationError::Backend(format!("encode update input: {e}")))?;
620 run_swift(&["update", &payload])
621 }
622
623 pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
624 run_swift(&["delete", event_id])
625 }
626
627 fn run_swift<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
628 let helper = ensure_helper()?;
629 let output = Command::new(helper)
630 .env(
631 "SWIFT_MODULE_CACHE_PATH",
632 std::env::temp_dir().join("car-swift-module-cache"),
633 )
634 .env(
635 "CLANG_MODULE_CACHE_PATH",
636 std::env::temp_dir().join("car-clang-module-cache"),
637 )
638 .args(args)
639 .output()
640 .map_err(|e| IntegrationError::Backend(format!("swift: {e}")))?;
641
642 if !output.status.success() {
643 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
644 return Err(IntegrationError::Backend(format!(
645 "eventkit swift failed: {stderr}"
646 )));
647 }
648
649 serde_json::from_slice(&output.stdout)
650 .map_err(|e| IntegrationError::Backend(format!("eventkit json: {e}")))
651 }
652
653 fn source_fingerprint(s: &str) -> String {
663 use std::hash::{Hash, Hasher};
664 let mut h = std::collections::hash_map::DefaultHasher::new();
665 s.hash(&mut h);
666 format!("{:016x}", h.finish())
667 }
668
669 fn helper_cache_key() -> String {
670 format!("{HELPER_VERSION}-{}", source_fingerprint(SCRIPT))
671 }
672
673 fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
674 let dir = helper_cache_dir();
675 let key = helper_cache_key();
676 let app = dir.join(format!("CAR EventKit Helper {key}.app"));
677 let contents = app.join("Contents");
678 let macos = contents.join("MacOS");
679 let helper = macos.join("CAR EventKit Helper");
680 if helper.exists() {
681 return Ok(helper);
682 }
683
684 std::fs::create_dir_all(&macos)
685 .map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
686 let source = dir.join(format!("car-eventkit-helper-{key}.swift"));
687 let plist = contents.join("Info.plist");
688 let entitlements = dir.join(format!("car-eventkit-helper-{key}.entitlements"));
689 std::fs::write(&source, SCRIPT)
690 .map_err(|e| IntegrationError::Backend(format!("eventkit helper source: {e}")))?;
691 std::fs::write(
692 &plist,
693 r#"<?xml version="1.0" encoding="UTF-8"?>
694<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
695<plist version="1.0">
696<dict>
697 <key>CFBundleIdentifier</key>
698 <string>ai.parslee.car.eventkit-helper</string>
699 <key>CFBundleExecutable</key>
700 <string>CAR EventKit Helper</string>
701 <key>CFBundleName</key>
702 <string>CAR EventKit Helper</string>
703 <key>CFBundlePackageType</key>
704 <string>APPL</string>
705 <key>CFBundleShortVersionString</key>
706 <string>0.9.0</string>
707 <key>CFBundleVersion</key>
708 <string>1</string>
709 <key>NSCalendarsUsageDescription</key>
710 <string>CAR reads calendars when an agent uses the calendar capability.</string>
711 <key>NSCalendarsFullAccessUsageDescription</key>
712 <string>CAR reads calendars when an agent uses the calendar capability.</string>
713</dict>
714</plist>
715"#,
716 )
717 .map_err(|e| IntegrationError::Backend(format!("eventkit helper plist: {e}")))?;
718 std::fs::write(
719 &entitlements,
720 r#"<?xml version="1.0" encoding="UTF-8"?>
721<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
722<plist version="1.0">
723<dict>
724 <key>com.apple.security.personal-information.calendars</key>
725 <true/>
726</dict>
727</plist>
728"#,
729 )
730 .map_err(|e| IntegrationError::Backend(format!("eventkit helper entitlements: {e}")))?;
731
732 let status = Command::new("/usr/bin/swiftc")
733 .env(
734 "SWIFT_MODULE_CACHE_PATH",
735 std::env::temp_dir().join("car-swift-module-cache"),
736 )
737 .env(
738 "CLANG_MODULE_CACHE_PATH",
739 std::env::temp_dir().join("car-clang-module-cache"),
740 )
741 .arg(&source)
742 .arg("-o")
743 .arg(&helper)
744 .arg("-Xlinker")
745 .arg("-sectcreate")
746 .arg("-Xlinker")
747 .arg("__TEXT")
748 .arg("-Xlinker")
749 .arg("__info_plist")
750 .arg("-Xlinker")
751 .arg(&plist)
752 .status()
753 .map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
754
755 if !status.success() {
756 return Err(IntegrationError::Backend(format!(
757 "eventkit helper compile failed with status {status}"
758 )));
759 }
760
761 sign_helper(&app, &entitlements)?;
762 Ok(helper)
763 }
764
765 fn helper_cache_dir() -> std::path::PathBuf {
766 if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
767 return std::path::PathBuf::from(path);
768 }
769 if let Some(home) = std::env::var_os("HOME") {
770 return std::path::PathBuf::from(home)
771 .join("Library")
772 .join("Application Support")
773 .join("CAR")
774 .join("NativeHelpers");
775 }
776 std::env::temp_dir().join("car-native-helpers")
777 }
778
779 fn sign_helper(
780 app: &std::path::Path,
781 entitlements: &std::path::Path,
782 ) -> Result<(), IntegrationError> {
783 let status = Command::new("/usr/bin/codesign")
784 .arg("--force")
785 .arg("--sign")
786 .arg("-")
787 .arg("--entitlements")
788 .arg(entitlements)
789 .arg(app)
790 .status()
791 .map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
792 if !status.success() {
793 return Err(IntegrationError::Backend(format!(
794 "eventkit helper codesign failed with status {status}"
795 )));
796 }
797 Ok(())
798 }
799
800 #[cfg(test)]
801 mod helper_cache_tests {
802 use super::{helper_cache_key, source_fingerprint, HELPER_VERSION};
803
804 #[test]
805 fn fingerprint_is_deterministic_and_input_sensitive() {
806 assert_eq!(source_fingerprint("abc"), source_fingerprint("abc"));
809 assert_ne!(
810 source_fingerprint("mode == create"),
811 source_fingerprint("mode == delete")
812 );
813 assert_eq!(source_fingerprint("abc").len(), 16);
814 }
815
816 #[test]
817 fn cache_key_carries_version_prefix_and_source_hash() {
818 let key = helper_cache_key();
819 assert!(key.starts_with(&format!("{HELPER_VERSION}-")), "key: {key}");
820 assert_eq!(key.len(), HELPER_VERSION.len() + 1 + 16);
822 }
823 }
824}
825
826#[cfg(not(target_os = "macos"))]
827mod backend {
828 use super::*;
829
830 pub fn authorization_status() -> String {
831 "not_applicable".to_string()
833 }
834
835 pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
836 #[cfg(target_os = "windows")]
841 if let Some(listing) =
842 crate::local_first::LocalRead::classify(win_read::list_calendars(), |listing| {
843 !listing.calendars.is_empty()
844 })
845 .final_answer(crate::msgraph::is_configured())
846 {
847 return Ok(listing);
848 }
849 if crate::msgraph::is_configured() {
850 return Ok(CalendarListing {
852 availability: Availability::available("msgraph"),
853 calendars: vec![Calendar {
854 id: "graph".into(),
855 title: "Calendar".into(),
856 source: Some("microsoft".into()),
857 color: None,
858 writable: true,
859 }],
860 });
861 }
862 Ok(CalendarListing {
863 availability: current_backend_pending(),
864 calendars: vec![],
865 })
866 }
867
868 pub fn list_events(
869 start: DateTime<Utc>,
870 end: DateTime<Utc>,
871 _calendar_ids: &[String],
872 ) -> Result<EventListing, IntegrationError> {
873 #[cfg(target_os = "windows")]
883 if let Some(listing) = crate::local_first::LocalRead::classify(
884 win_read::list_events(start, end, _calendar_ids),
885 |listing| !listing.events.is_empty(),
886 )
887 .final_answer(crate::msgraph::is_configured())
888 {
889 return Ok(listing);
890 }
891 if crate::msgraph::is_configured() {
893 return Ok(match crate::msgraph::events(start, end) {
894 Ok(events) => EventListing {
895 availability: Availability::available("msgraph"),
896 events,
897 },
898 Err(e) => EventListing {
899 availability: Availability::pending("msgraph", e.to_string()),
900 events: vec![],
901 },
902 });
903 }
904 Ok(EventListing {
905 availability: current_backend_pending(),
906 events: vec![],
907 })
908 }
909
910 pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
911 if crate::msgraph::is_configured() {
913 return Ok(match crate::msgraph::create_event(&input) {
914 Ok(event) => EventMutationResult {
915 ok: true,
916 event: Some(event),
917 reason: None,
918 },
919 Err(e) => EventMutationResult {
920 ok: false,
921 event: None,
922 reason: Some(e.to_string()),
923 },
924 });
925 }
926 Ok(EventMutationResult {
927 ok: false,
928 event: None,
929 reason: Some(
930 "calendar event creation needs the Microsoft Graph backend \
931 (set CAR_MSGRAPH_CLIENT_ID); no local backend on this platform"
932 .into(),
933 ),
934 })
935 }
936
937 pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
938 if crate::msgraph::is_configured() {
939 return Ok(match crate::msgraph::update_event(&input) {
940 Ok(event) => EventMutationResult {
941 ok: true,
942 event: Some(event),
943 reason: None,
944 },
945 Err(e) => EventMutationResult {
946 ok: false,
947 event: None,
948 reason: Some(e.to_string()),
949 },
950 });
951 }
952 Ok(EventMutationResult {
953 ok: false,
954 event: None,
955 reason: Some(
956 "calendar event updates need the Microsoft Graph backend \
957 (set CAR_MSGRAPH_CLIENT_ID); no local backend on this platform"
958 .into(),
959 ),
960 })
961 }
962
963 pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
964 if crate::msgraph::is_configured() {
965 return Ok(match crate::msgraph::delete_event(event_id) {
966 Ok(()) => EventMutationResult {
967 ok: true,
968 event: None,
969 reason: None,
970 },
971 Err(e) => EventMutationResult {
972 ok: false,
973 event: None,
974 reason: Some(e.to_string()),
975 },
976 });
977 }
978 Ok(EventMutationResult {
979 ok: false,
980 event: None,
981 reason: Some(
982 "calendar event deletion needs the Microsoft Graph backend \
983 (set CAR_MSGRAPH_CLIENT_ID); no local backend on this platform"
984 .into(),
985 ),
986 })
987 }
988
989 fn current_backend_pending() -> Availability {
990 #[cfg(target_os = "windows")]
991 {
992 Availability::pending(
993 "msgraph",
994 "Set CAR_MSGRAPH_CLIENT_ID (Azure AD app) to enable the Microsoft \
995 Graph calendar backend (car#520/#531 — read + event \
996 create/update/delete).",
997 )
998 }
999 #[cfg(target_os = "linux")]
1000 {
1001 Availability::pending(
1002 "eds",
1003 "Evolution Data Server + CalDAV backends not yet wired. \
1004 API shape is stable; downstream apps can code against it now.",
1005 )
1006 }
1007 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
1008 {
1009 Availability::pending("none", "Unsupported OS — no calendar backend modeled.")
1010 }
1011 }
1012
1013 #[cfg(target_os = "windows")]
1018 mod win_read {
1019 use super::*;
1020 use windows::ApplicationModel::Appointments::{
1021 AppointmentManager, AppointmentStore, AppointmentStoreAccessType,
1022 };
1023 use windows::Foundation::{DateTime as WinDateTime, TimeSpan};
1024
1025 const TICKS_1601_TO_1970: i64 = 116_444_736_000_000_000;
1027
1028 fn open_store() -> Result<AppointmentStore, IntegrationError> {
1029 AppointmentManager::RequestStoreAsync(AppointmentStoreAccessType::AllCalendarsReadOnly)
1030 .and_then(|o| o.get())
1031 .map_err(|e| IntegrationError::Backend(format!("windows appointment store: {e}")))
1032 }
1033
1034 fn to_chrono(dt: WinDateTime) -> Option<DateTime<Utc>> {
1035 let unix_ticks = dt.UniversalTime - TICKS_1601_TO_1970;
1036 let secs = unix_ticks.div_euclid(10_000_000);
1037 let nanos = (unix_ticks.rem_euclid(10_000_000) * 100) as u32;
1038 DateTime::from_timestamp(secs, nanos)
1039 }
1040
1041 fn to_winrt(dt: DateTime<Utc>) -> WinDateTime {
1042 let ticks = dt.timestamp() * 10_000_000
1043 + (dt.timestamp_subsec_nanos() as i64 / 100)
1044 + TICKS_1601_TO_1970;
1045 WinDateTime {
1046 UniversalTime: ticks,
1047 }
1048 }
1049
1050 fn hstr(v: windows::core::Result<windows::core::HSTRING>) -> windows::core::Result<String> {
1051 v.map(|s| s.to_string_lossy())
1052 }
1053
1054 pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
1055 let store = open_store()?;
1056 let cals = store
1057 .FindAppointmentCalendarsAsync()
1058 .and_then(|o| o.get())
1059 .map_err(|e| IntegrationError::Backend(format!("appointment calendars: {e}")))?;
1060 let mut calendars = Vec::new();
1061 for cal in cals {
1062 calendars.push(Calendar {
1063 id: hstr(cal.LocalId()).unwrap_or_default(),
1064 title: hstr(cal.DisplayName()).unwrap_or_default(),
1065 source: Some("windows".into()),
1066 color: None,
1067 writable: cal.CanCreateOrUpdateAppointments().unwrap_or(false),
1068 });
1069 }
1070 Ok(CalendarListing {
1071 availability: Availability::available("windows_calendar"),
1072 calendars,
1073 })
1074 }
1075
1076 pub fn list_events(
1077 start: DateTime<Utc>,
1078 end: DateTime<Utc>,
1079 calendar_ids: &[String],
1080 ) -> Result<EventListing, IntegrationError> {
1081 let store = open_store()?;
1082 let span = TimeSpan {
1083 Duration: ((end - start).num_nanoseconds().unwrap_or(0) / 100).max(0),
1084 };
1085 let appts = store
1086 .FindAppointmentsAsync(to_winrt(start), span)
1087 .and_then(|o| o.get())
1088 .map_err(|e| IntegrationError::Backend(format!("find appointments: {e}")))?;
1089 let want: std::collections::HashSet<&str> =
1090 calendar_ids.iter().map(String::as_str).collect();
1091 let mut events = Vec::new();
1092 for a in appts {
1093 let calendar_id = hstr(a.CalendarId()).unwrap_or_default();
1094 if !want.is_empty() && !want.contains(calendar_id.as_str()) {
1095 continue;
1096 }
1097 let ev_start = a.StartTime().ok().and_then(to_chrono).unwrap_or(start);
1098 let dur_ticks = a.Duration().map(|t| t.Duration).unwrap_or(0);
1099 let ev_end = ev_start + chrono::Duration::nanoseconds(dur_ticks * 100);
1100 events.push(Event {
1101 id: hstr(a.LocalId()).unwrap_or_default(),
1102 calendar_id,
1103 title: hstr(a.Subject()).unwrap_or_default(),
1104 start: ev_start,
1105 end: ev_end,
1106 all_day: a.AllDay().unwrap_or(false),
1107 location: hstr(a.Location()).ok().filter(|s| !s.is_empty()),
1108 notes: hstr(a.Details()).ok().filter(|s| !s.is_empty()),
1109 attendees: Vec::new(),
1112 status: None,
1113 });
1114 }
1115 Ok(EventListing {
1116 availability: Availability::available("windows_calendar"),
1117 events,
1118 })
1119 }
1120 }
1121}
1122
1123#[cfg(test)]
1124mod event_shape_tests {
1125 use super::*;
1126
1127 #[test]
1128 fn enriched_attendees_and_status_deserialize() {
1129 let json = r#"{
1134 "available": true,
1135 "backend": "eventkit",
1136 "events": [{
1137 "id": "evt-1",
1138 "calendar_id": "cal-1",
1139 "title": "Design sync",
1140 "start": "2026-06-21T15:00:00Z",
1141 "end": "2026-06-21T16:00:00Z",
1142 "all_day": false,
1143 "location": null,
1144 "notes": null,
1145 "status": "confirmed",
1146 "attendees": [
1147 {"name": "Matt Liotta", "email": "matt@parslee.ai", "status": "accepted", "role": "chair", "is_current_user": true},
1148 {"name": "Dan Capri", "email": "dan@example.com", "status": "tentative", "role": "required", "is_current_user": false}
1149 ]
1150 }]
1151 }"#;
1152 let listing: EventListing = serde_json::from_str(json).expect("deserialize");
1153 let e = &listing.events[0];
1154 assert_eq!(e.status.as_deref(), Some("confirmed"));
1155 assert_eq!(e.attendees.len(), 2);
1156 assert_eq!(e.attendees[0].status.as_deref(), Some("accepted"));
1158 assert!(e.attendees[0].is_current_user);
1159 assert_eq!(e.attendees[0].email.as_deref(), Some("matt@parslee.ai"));
1160 assert_eq!(e.attendees[0].role.as_deref(), Some("chair"));
1161 assert_eq!(e.attendees[1].status.as_deref(), Some("tentative"));
1163 assert!(!e.attendees[1].is_current_user);
1164 }
1165
1166 #[test]
1167 fn authorization_status_returns_a_known_label() {
1168 let s = authorization_status();
1172 assert!(
1173 matches!(
1174 s.as_str(),
1175 "granted"
1176 | "write_only"
1177 | "denied"
1178 | "restricted"
1179 | "not_determined"
1180 | "not_applicable"
1181 | "unknown"
1182 ),
1183 "unexpected status label: {s}"
1184 );
1185 eprintln!("calendar authorization_status() = {s}");
1186 }
1187
1188 #[test]
1189 fn legacy_event_without_enrichment_still_deserializes() {
1190 let json = r#"{"available":true,"backend":"none","events":[{
1193 "id":"e","calendar_id":"c","title":"t",
1194 "start":"2026-06-21T15:00:00Z","end":"2026-06-21T16:00:00Z"
1195 }]}"#;
1196 let listing: EventListing = serde_json::from_str(json).expect("deserialize");
1197 assert!(listing.events[0].attendees.is_empty());
1198 assert_eq!(listing.events[0].status, None);
1199 }
1200}