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 #[cfg(test)]
674 mod helper_cache_tests {
675 use super::{helper_cache_key, source_fingerprint, HELPER_VERSION};
676
677 #[test]
678 fn fingerprint_is_deterministic_and_input_sensitive() {
679 assert_eq!(source_fingerprint("abc"), source_fingerprint("abc"));
682 assert_ne!(
683 source_fingerprint("mode == create"),
684 source_fingerprint("mode == delete")
685 );
686 assert_eq!(source_fingerprint("abc").len(), 16);
687 }
688
689 #[test]
690 fn cache_key_carries_version_prefix_and_source_hash() {
691 let key = helper_cache_key();
692 assert!(key.starts_with(&format!("{HELPER_VERSION}-")), "key: {key}");
693 assert_eq!(key.len(), HELPER_VERSION.len() + 1 + 16);
695 }
696 }
697
698 fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
699 let dir = helper_cache_dir();
700 let key = helper_cache_key();
701 let app = dir.join(format!("CAR EventKit Helper {key}.app"));
702 let contents = app.join("Contents");
703 let macos = contents.join("MacOS");
704 let helper = macos.join("CAR EventKit Helper");
705 if helper.exists() {
706 return Ok(helper);
707 }
708
709 std::fs::create_dir_all(&macos)
710 .map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
711 let source = dir.join(format!("car-eventkit-helper-{key}.swift"));
712 let plist = contents.join("Info.plist");
713 let entitlements = dir.join(format!("car-eventkit-helper-{key}.entitlements"));
714 std::fs::write(&source, SCRIPT)
715 .map_err(|e| IntegrationError::Backend(format!("eventkit helper source: {e}")))?;
716 std::fs::write(
717 &plist,
718 r#"<?xml version="1.0" encoding="UTF-8"?>
719<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
720<plist version="1.0">
721<dict>
722 <key>CFBundleIdentifier</key>
723 <string>ai.parslee.car.eventkit-helper</string>
724 <key>CFBundleExecutable</key>
725 <string>CAR EventKit Helper</string>
726 <key>CFBundleName</key>
727 <string>CAR EventKit Helper</string>
728 <key>CFBundlePackageType</key>
729 <string>APPL</string>
730 <key>CFBundleShortVersionString</key>
731 <string>0.9.0</string>
732 <key>CFBundleVersion</key>
733 <string>1</string>
734 <key>NSCalendarsUsageDescription</key>
735 <string>CAR reads calendars when an agent uses the calendar capability.</string>
736 <key>NSCalendarsFullAccessUsageDescription</key>
737 <string>CAR reads calendars when an agent uses the calendar capability.</string>
738</dict>
739</plist>
740"#,
741 )
742 .map_err(|e| IntegrationError::Backend(format!("eventkit helper plist: {e}")))?;
743 std::fs::write(
744 &entitlements,
745 r#"<?xml version="1.0" encoding="UTF-8"?>
746<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
747<plist version="1.0">
748<dict>
749 <key>com.apple.security.personal-information.calendars</key>
750 <true/>
751</dict>
752</plist>
753"#,
754 )
755 .map_err(|e| IntegrationError::Backend(format!("eventkit helper entitlements: {e}")))?;
756
757 let status = Command::new("/usr/bin/swiftc")
758 .env(
759 "SWIFT_MODULE_CACHE_PATH",
760 std::env::temp_dir().join("car-swift-module-cache"),
761 )
762 .env(
763 "CLANG_MODULE_CACHE_PATH",
764 std::env::temp_dir().join("car-clang-module-cache"),
765 )
766 .arg(&source)
767 .arg("-o")
768 .arg(&helper)
769 .arg("-Xlinker")
770 .arg("-sectcreate")
771 .arg("-Xlinker")
772 .arg("__TEXT")
773 .arg("-Xlinker")
774 .arg("__info_plist")
775 .arg("-Xlinker")
776 .arg(&plist)
777 .status()
778 .map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
779
780 if !status.success() {
781 return Err(IntegrationError::Backend(format!(
782 "eventkit helper compile failed with status {status}"
783 )));
784 }
785
786 sign_helper(&app, &entitlements)?;
787 Ok(helper)
788 }
789
790 fn helper_cache_dir() -> std::path::PathBuf {
791 if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
792 return std::path::PathBuf::from(path);
793 }
794 if let Some(home) = std::env::var_os("HOME") {
795 return std::path::PathBuf::from(home)
796 .join("Library")
797 .join("Application Support")
798 .join("CAR")
799 .join("NativeHelpers");
800 }
801 std::env::temp_dir().join("car-native-helpers")
802 }
803
804 fn sign_helper(
805 app: &std::path::Path,
806 entitlements: &std::path::Path,
807 ) -> Result<(), IntegrationError> {
808 let status = Command::new("/usr/bin/codesign")
809 .arg("--force")
810 .arg("--sign")
811 .arg("-")
812 .arg("--entitlements")
813 .arg(entitlements)
814 .arg(app)
815 .status()
816 .map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
817 if !status.success() {
818 return Err(IntegrationError::Backend(format!(
819 "eventkit helper codesign failed with status {status}"
820 )));
821 }
822 Ok(())
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 Ok(CalendarListing {
837 availability: current_backend_pending(),
838 calendars: vec![],
839 })
840 }
841
842 pub fn list_events(
843 _start: DateTime<Utc>,
844 _end: DateTime<Utc>,
845 _calendar_ids: &[String],
846 ) -> Result<EventListing, IntegrationError> {
847 Ok(EventListing {
848 availability: current_backend_pending(),
849 events: vec![],
850 })
851 }
852
853 pub fn create_event(_input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
854 Ok(EventMutationResult {
855 ok: false,
856 event: None,
857 reason: Some("calendar event creation is not yet implemented on this platform".into()),
858 })
859 }
860
861 pub fn update_event(_input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
862 Ok(EventMutationResult {
863 ok: false,
864 event: None,
865 reason: Some("calendar event updates are not yet implemented on this platform".into()),
866 })
867 }
868
869 pub fn delete_event(_event_id: &str) -> Result<EventMutationResult, IntegrationError> {
870 Ok(EventMutationResult {
871 ok: false,
872 event: None,
873 reason: Some("calendar event deletion is not yet implemented on this platform".into()),
874 })
875 }
876
877 fn current_backend_pending() -> Availability {
878 #[cfg(target_os = "windows")]
879 {
880 Availability::pending(
881 "msgraph",
882 "MS Graph / Outlook MAPI backends not yet wired. API shape \
883 is stable; downstream apps can code against it now.",
884 )
885 }
886 #[cfg(target_os = "linux")]
887 {
888 Availability::pending(
889 "eds",
890 "Evolution Data Server + CalDAV backends not yet wired. \
891 API shape is stable; downstream apps can code against it now.",
892 )
893 }
894 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
895 {
896 Availability::pending("none", "Unsupported OS — no calendar backend modeled.")
897 }
898 }
899}
900
901#[cfg(test)]
902mod event_shape_tests {
903 use super::*;
904
905 #[test]
906 fn enriched_attendees_and_status_deserialize() {
907 let json = r#"{
912 "available": true,
913 "backend": "eventkit",
914 "events": [{
915 "id": "evt-1",
916 "calendar_id": "cal-1",
917 "title": "Design sync",
918 "start": "2026-06-21T15:00:00Z",
919 "end": "2026-06-21T16:00:00Z",
920 "all_day": false,
921 "location": null,
922 "notes": null,
923 "status": "confirmed",
924 "attendees": [
925 {"name": "Matt Liotta", "email": "matt@parslee.ai", "status": "accepted", "role": "chair", "is_current_user": true},
926 {"name": "Dan Capri", "email": "dan@example.com", "status": "tentative", "role": "required", "is_current_user": false}
927 ]
928 }]
929 }"#;
930 let listing: EventListing = serde_json::from_str(json).expect("deserialize");
931 let e = &listing.events[0];
932 assert_eq!(e.status.as_deref(), Some("confirmed"));
933 assert_eq!(e.attendees.len(), 2);
934 assert_eq!(e.attendees[0].status.as_deref(), Some("accepted"));
936 assert!(e.attendees[0].is_current_user);
937 assert_eq!(e.attendees[0].email.as_deref(), Some("matt@parslee.ai"));
938 assert_eq!(e.attendees[0].role.as_deref(), Some("chair"));
939 assert_eq!(e.attendees[1].status.as_deref(), Some("tentative"));
941 assert!(!e.attendees[1].is_current_user);
942 }
943
944 #[test]
945 fn authorization_status_returns_a_known_label() {
946 let s = authorization_status();
950 assert!(
951 matches!(
952 s.as_str(),
953 "granted" | "write_only" | "denied" | "restricted"
954 | "not_determined" | "not_applicable" | "unknown"
955 ),
956 "unexpected status label: {s}"
957 );
958 eprintln!("calendar authorization_status() = {s}");
959 }
960
961 #[test]
962 fn legacy_event_without_enrichment_still_deserializes() {
963 let json = r#"{"available":true,"backend":"none","events":[{
966 "id":"e","calendar_id":"c","title":"t",
967 "start":"2026-06-21T15:00:00Z","end":"2026-06-21T16:00:00Z"
968 }]}"#;
969 let listing: EventListing = serde_json::from_str(json).expect("deserialize");
970 assert!(listing.events[0].attendees.is_empty());
971 assert_eq!(listing.events[0].status, None);
972 }
973}