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 if crate::msgraph::is_configured() {
837 return Ok(CalendarListing {
839 availability: Availability::available("msgraph"),
840 calendars: vec![Calendar {
841 id: "graph".into(),
842 title: "Calendar".into(),
843 source: Some("microsoft".into()),
844 color: None,
845 writable: true,
846 }],
847 });
848 }
849 Ok(CalendarListing {
850 availability: current_backend_pending(),
851 calendars: vec![],
852 })
853 }
854
855 pub fn list_events(
856 start: DateTime<Utc>,
857 end: DateTime<Utc>,
858 _calendar_ids: &[String],
859 ) -> Result<EventListing, IntegrationError> {
860 if crate::msgraph::is_configured() {
862 return Ok(match crate::msgraph::events(start, end) {
863 Ok(events) => EventListing {
864 availability: Availability::available("msgraph"),
865 events,
866 },
867 Err(e) => EventListing {
868 availability: Availability::pending("msgraph", e.to_string()),
869 events: vec![],
870 },
871 });
872 }
873 Ok(EventListing {
874 availability: current_backend_pending(),
875 events: vec![],
876 })
877 }
878
879 pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
880 if crate::msgraph::is_configured() {
882 return Ok(match crate::msgraph::create_event(&input) {
883 Ok(event) => EventMutationResult {
884 ok: true,
885 event: Some(event),
886 reason: None,
887 },
888 Err(e) => EventMutationResult {
889 ok: false,
890 event: None,
891 reason: Some(e.to_string()),
892 },
893 });
894 }
895 Ok(EventMutationResult {
896 ok: false,
897 event: None,
898 reason: Some(
899 "calendar event creation needs the Microsoft Graph backend \
900 (set CAR_MSGRAPH_CLIENT_ID); no local backend on this platform"
901 .into(),
902 ),
903 })
904 }
905
906 pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
907 if crate::msgraph::is_configured() {
908 return Ok(match crate::msgraph::update_event(&input) {
909 Ok(event) => EventMutationResult {
910 ok: true,
911 event: Some(event),
912 reason: None,
913 },
914 Err(e) => EventMutationResult {
915 ok: false,
916 event: None,
917 reason: Some(e.to_string()),
918 },
919 });
920 }
921 Ok(EventMutationResult {
922 ok: false,
923 event: None,
924 reason: Some(
925 "calendar event updates need the Microsoft Graph backend \
926 (set CAR_MSGRAPH_CLIENT_ID); no local backend on this platform"
927 .into(),
928 ),
929 })
930 }
931
932 pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
933 if crate::msgraph::is_configured() {
934 return Ok(match crate::msgraph::delete_event(event_id) {
935 Ok(()) => EventMutationResult {
936 ok: true,
937 event: None,
938 reason: None,
939 },
940 Err(e) => EventMutationResult {
941 ok: false,
942 event: None,
943 reason: Some(e.to_string()),
944 },
945 });
946 }
947 Ok(EventMutationResult {
948 ok: false,
949 event: None,
950 reason: Some(
951 "calendar event deletion needs the Microsoft Graph backend \
952 (set CAR_MSGRAPH_CLIENT_ID); no local backend on this platform"
953 .into(),
954 ),
955 })
956 }
957
958 fn current_backend_pending() -> Availability {
959 #[cfg(target_os = "windows")]
960 {
961 Availability::pending(
962 "msgraph",
963 "Set CAR_MSGRAPH_CLIENT_ID (Azure AD app) to enable the Microsoft \
964 Graph calendar backend (car#520/#531 — read + event \
965 create/update/delete).",
966 )
967 }
968 #[cfg(target_os = "linux")]
969 {
970 Availability::pending(
971 "eds",
972 "Evolution Data Server + CalDAV backends not yet wired. \
973 API shape is stable; downstream apps can code against it now.",
974 )
975 }
976 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
977 {
978 Availability::pending("none", "Unsupported OS — no calendar backend modeled.")
979 }
980 }
981}
982
983#[cfg(test)]
984mod event_shape_tests {
985 use super::*;
986
987 #[test]
988 fn enriched_attendees_and_status_deserialize() {
989 let json = r#"{
994 "available": true,
995 "backend": "eventkit",
996 "events": [{
997 "id": "evt-1",
998 "calendar_id": "cal-1",
999 "title": "Design sync",
1000 "start": "2026-06-21T15:00:00Z",
1001 "end": "2026-06-21T16:00:00Z",
1002 "all_day": false,
1003 "location": null,
1004 "notes": null,
1005 "status": "confirmed",
1006 "attendees": [
1007 {"name": "Matt Liotta", "email": "matt@parslee.ai", "status": "accepted", "role": "chair", "is_current_user": true},
1008 {"name": "Dan Capri", "email": "dan@example.com", "status": "tentative", "role": "required", "is_current_user": false}
1009 ]
1010 }]
1011 }"#;
1012 let listing: EventListing = serde_json::from_str(json).expect("deserialize");
1013 let e = &listing.events[0];
1014 assert_eq!(e.status.as_deref(), Some("confirmed"));
1015 assert_eq!(e.attendees.len(), 2);
1016 assert_eq!(e.attendees[0].status.as_deref(), Some("accepted"));
1018 assert!(e.attendees[0].is_current_user);
1019 assert_eq!(e.attendees[0].email.as_deref(), Some("matt@parslee.ai"));
1020 assert_eq!(e.attendees[0].role.as_deref(), Some("chair"));
1021 assert_eq!(e.attendees[1].status.as_deref(), Some("tentative"));
1023 assert!(!e.attendees[1].is_current_user);
1024 }
1025
1026 #[test]
1027 fn authorization_status_returns_a_known_label() {
1028 let s = authorization_status();
1032 assert!(
1033 matches!(
1034 s.as_str(),
1035 "granted"
1036 | "write_only"
1037 | "denied"
1038 | "restricted"
1039 | "not_determined"
1040 | "not_applicable"
1041 | "unknown"
1042 ),
1043 "unexpected status label: {s}"
1044 );
1045 eprintln!("calendar authorization_status() = {s}");
1046 }
1047
1048 #[test]
1049 fn legacy_event_without_enrichment_still_deserializes() {
1050 let json = r#"{"available":true,"backend":"none","events":[{
1053 "id":"e","calendar_id":"c","title":"t",
1054 "start":"2026-06-21T15:00:00Z","end":"2026-06-21T16:00:00Z"
1055 }]}"#;
1056 let listing: EventListing = serde_json::from_str(json).expect("deserialize");
1057 assert!(listing.events[0].attendees.is_empty());
1058 assert_eq!(listing.events[0].status, None);
1059 }
1060}