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)]
36 pub attendees: Vec<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CalendarListing {
41 #[serde(flatten)]
42 pub availability: Availability,
43 pub calendars: Vec<Calendar>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct EventListing {
48 #[serde(flatten)]
49 pub availability: Availability,
50 pub events: Vec<Event>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct EventCreateInput {
55 pub calendar_id: String,
56 pub title: String,
57 pub start: DateTime<Utc>,
58 pub end: DateTime<Utc>,
59 #[serde(default)]
60 pub all_day: bool,
61 #[serde(default)]
62 pub notes: Option<String>,
63 #[serde(default)]
64 pub location: Option<String>,
65 #[serde(default)]
66 pub url: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct EventUpdateInput {
71 pub event_id: String,
72 #[serde(default)]
73 pub title: Option<String>,
74 #[serde(default)]
75 pub start: Option<DateTime<Utc>>,
76 #[serde(default)]
77 pub end: Option<DateTime<Utc>>,
78 #[serde(default)]
79 pub all_day: Option<bool>,
80 #[serde(default)]
81 pub notes: Option<String>,
82 #[serde(default)]
83 pub location: Option<String>,
84 #[serde(default)]
85 pub url: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EventMutationResult {
90 pub ok: bool,
91 #[serde(default)]
92 pub event: Option<Event>,
93 #[serde(default)]
94 pub reason: Option<String>,
95}
96
97pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
98 backend::list_calendars()
99}
100
101pub fn list_events(
104 start: DateTime<Utc>,
105 end: DateTime<Utc>,
106 calendar_ids: &[String],
107) -> Result<EventListing, IntegrationError> {
108 backend::list_events(start, end, calendar_ids)
109}
110
111pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
115 backend::create_event(input)
116}
117
118pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
124 backend::update_event(input)
125}
126
127pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
131 backend::delete_event(event_id)
132}
133
134#[cfg(target_os = "macos")]
135mod backend {
136 use super::*;
137
138 const HELPER_VERSION: &str = "v4";
144
145 const SCRIPT: &str = r##"
146import EventKit
147import Foundation
148import Dispatch
149
150struct Availability: Codable {
151 let available: Bool
152 let backend: String
153 let reason: String?
154}
155
156struct CalendarOut: Codable {
157 let id: String
158 let title: String
159 let source: String?
160 let color: String?
161 let writable: Bool
162}
163
164struct EventOut: Codable {
165 let id: String
166 let calendar_id: String
167 let title: String
168 let start: Date
169 let end: Date
170 let all_day: Bool
171 let location: String?
172 let notes: String?
173 let attendees: [String]
174}
175
176struct CalendarListing: Codable {
177 let available: Bool
178 let backend: String
179 let reason: String?
180 let calendars: [CalendarOut]
181}
182
183struct EventListing: Codable {
184 let available: Bool
185 let backend: String
186 let reason: String?
187 let events: [EventOut]
188}
189
190struct EventCreateInput: Codable {
191 let calendar_id: String
192 let title: String
193 let start: Date
194 let end: Date
195 let all_day: Bool?
196 let notes: String?
197 let location: String?
198 let url: String?
199}
200
201struct EventUpdateInput: Codable {
202 let event_id: String
203 let title: String?
204 let start: Date?
205 let end: Date?
206 let all_day: Bool?
207 let notes: String?
208 let location: String?
209 let url: String?
210}
211
212struct EventMutationOut: Codable {
213 let ok: Bool
214 let event: EventOut?
215 let reason: String?
216}
217
218func emit<T: Encodable>(_ value: T) {
219 let encoder = JSONEncoder()
220 encoder.dateEncodingStrategy = .iso8601
221 let data = try! encoder.encode(value)
222 FileHandle.standardOutput.write(data)
223}
224
225func eventOut(_ event: EKEvent) -> EventOut {
226 return EventOut(
227 id: event.eventIdentifier ?? "\(event.calendarItemIdentifier)-\(event.startDate.timeIntervalSince1970)",
228 calendar_id: event.calendar.calendarIdentifier,
229 title: event.title ?? "",
230 start: event.startDate,
231 end: event.endDate,
232 all_day: event.isAllDay,
233 location: event.location,
234 notes: event.notes,
235 attendees: (event.attendees ?? []).compactMap { $0.name ?? $0.url.absoluteString }
236 )
237}
238
239func unavailable<T: Encodable>(_ reason: String, empty: T) {
240 emit(empty)
241}
242
243let internetDateFormatter: ISO8601DateFormatter = {
244 let formatter = ISO8601DateFormatter()
245 formatter.formatOptions = [.withInternetDateTime]
246 return formatter
247}()
248
249let fractionalDateFormatter: ISO8601DateFormatter = {
250 let formatter = ISO8601DateFormatter()
251 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
252 return formatter
253}()
254
255func decodeDate(_ decoder: Decoder) throws -> Date {
256 let container = try decoder.singleValueContainer()
257 let raw = try container.decode(String.self)
258 if let date = fractionalDateFormatter.date(from: raw) ?? internetDateFormatter.date(from: raw) {
259 return date
260 }
261 throw DecodingError.dataCorruptedError(
262 in: container,
263 debugDescription: "Invalid RFC3339 date: \(raw)"
264 )
265}
266
267func ensureAccess(_ store: EKEventStore) -> String? {
268 let status = EKEventStore.authorizationStatus(for: .event)
269 switch status {
270 case .authorized:
271 return nil
272 case .notDetermined:
273 let semaphore = DispatchSemaphore(value: 0)
274 var granted = false
275 if #available(macOS 14.0, *) {
276 store.requestFullAccessToEvents { ok, _ in
277 granted = ok
278 semaphore.signal()
279 }
280 } else {
281 store.requestAccess(to: .event) { ok, _ in
282 granted = ok
283 semaphore.signal()
284 }
285 }
286 _ = semaphore.wait(timeout: .now() + 60)
287 return granted ? nil : "Calendar permission was not granted"
288 case .restricted:
289 return "Calendar permission is restricted by system policy"
290 case .denied:
291 return "Calendar permission is denied"
292 case .writeOnly:
293 return "Calendar permission is write-only; read access is required"
294 case .fullAccess:
295 return nil
296 @unknown default:
297 return "Calendar permission is unavailable"
298 }
299}
300
301func hexColor(_ cgColor: CGColor?) -> String? {
302 guard let cgColor = cgColor, let components = cgColor.components else { return nil }
303 let r: CGFloat
304 let g: CGFloat
305 let b: CGFloat
306 if components.count >= 3 {
307 r = components[0]
308 g = components[1]
309 b = components[2]
310 } else if components.count >= 1 {
311 r = components[0]
312 g = components[0]
313 b = components[0]
314 } else {
315 return nil
316 }
317 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))
318}
319
320let args = CommandLine.arguments
321let mode = args.count > 1 ? args[1] : "calendars"
322let store = EKEventStore()
323if let reason = ensureAccess(store) {
324 if mode == "events" {
325 emit(EventListing(available: false, backend: "eventkit", reason: reason, events: []))
326 } else if mode == "create" || mode == "update" || mode == "delete" {
327 emit(EventMutationOut(ok: false, event: nil, reason: reason))
328 } else {
329 emit(CalendarListing(available: false, backend: "eventkit", reason: reason, calendars: []))
330 }
331 exit(0)
332}
333
334if mode == "events" {
335 let formatter = ISO8601DateFormatter()
336 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
337 let fallback = ISO8601DateFormatter()
338 guard args.count >= 4,
339 let start = formatter.date(from: args[2]) ?? fallback.date(from: args[2]),
340 let end = formatter.date(from: args[3]) ?? fallback.date(from: args[3]) else {
341 emit(EventListing(available: false, backend: "eventkit", reason: "Invalid RFC3339 date range", events: []))
342 exit(0)
343 }
344 let requested = Set(args.dropFirst(4))
345 let calendars = store.calendars(for: .event).filter { requested.isEmpty || requested.contains($0.calendarIdentifier) }
346 let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
347 let events = store.events(matching: predicate).map { eventOut($0) }
348 emit(EventListing(available: true, backend: "eventkit", reason: nil, events: events))
349} else if mode == "create" {
350 guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
351 emit(EventMutationOut(ok: false, event: nil, reason: "create requires JSON payload as args[2]"))
352 exit(0)
353 }
354 let decoder = JSONDecoder()
355 decoder.dateDecodingStrategy = .custom(decodeDate)
356 guard let input = try? decoder.decode(EventCreateInput.self, from: payload) else {
357 emit(EventMutationOut(ok: false, event: nil, reason: "invalid_create_input_json"))
358 exit(0)
359 }
360 guard let calendar = store.calendar(withIdentifier: input.calendar_id) else {
361 emit(EventMutationOut(ok: false, event: nil, reason: "calendar_not_found"))
362 exit(0)
363 }
364 guard calendar.allowsContentModifications else {
365 emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
366 exit(0)
367 }
368 let event = EKEvent(eventStore: store)
369 event.calendar = calendar
370 event.title = input.title
371 event.startDate = input.start
372 event.endDate = input.end
373 event.isAllDay = input.all_day ?? false
374 if let notes = input.notes, !notes.isEmpty { event.notes = notes }
375 if let location = input.location, !location.isEmpty { event.location = location }
376 if let urlRaw = input.url, !urlRaw.isEmpty, let parsed = URL(string: urlRaw) { event.url = parsed }
377 do {
378 try store.save(event, span: .thisEvent)
379 emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
380 } catch {
381 emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
382 }
383} else if mode == "update" {
384 guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
385 emit(EventMutationOut(ok: false, event: nil, reason: "update requires JSON payload as args[2]"))
386 exit(0)
387 }
388 let decoder = JSONDecoder()
389 decoder.dateDecodingStrategy = .custom(decodeDate)
390 guard let input = try? decoder.decode(EventUpdateInput.self, from: payload) else {
391 emit(EventMutationOut(ok: false, event: nil, reason: "invalid_update_input_json"))
392 exit(0)
393 }
394 guard let event = store.event(withIdentifier: input.event_id) else {
395 emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
396 exit(0)
397 }
398 guard event.calendar.allowsContentModifications else {
399 emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
400 exit(0)
401 }
402 if let title = input.title { event.title = title }
403 if let start = input.start { event.startDate = start }
404 if let end = input.end { event.endDate = end }
405 if let allDay = input.all_day { event.isAllDay = allDay }
406 // Empty-string for notes/location/url means "clear the field". The
407 // None case (field absent in JSON) leaves the existing value alone.
408 if let notes = input.notes { event.notes = notes.isEmpty ? nil : notes }
409 if let location = input.location { event.location = location.isEmpty ? nil : location }
410 if let urlRaw = input.url {
411 event.url = urlRaw.isEmpty ? nil : URL(string: urlRaw)
412 }
413 do {
414 try store.save(event, span: .thisEvent)
415 emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
416 } catch {
417 emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
418 }
419} else if mode == "delete" {
420 guard args.count >= 3 else {
421 emit(EventMutationOut(ok: false, event: nil, reason: "delete requires event_id as args[2]"))
422 exit(0)
423 }
424 let eventId = args[2]
425 guard let event = store.event(withIdentifier: eventId) else {
426 emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
427 exit(0)
428 }
429 guard event.calendar.allowsContentModifications else {
430 emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
431 exit(0)
432 }
433 do {
434 try store.remove(event, span: .thisEvent)
435 emit(EventMutationOut(ok: true, event: nil, reason: nil))
436 } catch {
437 emit(EventMutationOut(ok: false, event: nil, reason: "delete_failed: \(error.localizedDescription)"))
438 }
439} else {
440 let calendars = store.calendars(for: .event).map { cal in
441 CalendarOut(
442 id: cal.calendarIdentifier,
443 title: cal.title,
444 source: cal.source?.title,
445 color: hexColor(cal.cgColor),
446 writable: cal.allowsContentModifications
447 )
448 }
449 emit(CalendarListing(available: true, backend: "eventkit", reason: nil, calendars: calendars))
450}
451"##;
452
453 pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
454 run_swift(&["calendars"])
455 }
456
457 pub fn list_events(
458 start: DateTime<Utc>,
459 end: DateTime<Utc>,
460 calendar_ids: &[String],
461 ) -> Result<EventListing, IntegrationError> {
462 let start = start.to_rfc3339();
463 let end = end.to_rfc3339();
464 let mut args = vec!["events", start.as_str(), end.as_str()];
465 args.extend(calendar_ids.iter().map(String::as_str));
466 run_swift(&args)
467 }
468
469 pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
470 let payload = serde_json::to_string(&input)
471 .map_err(|e| IntegrationError::Backend(format!("encode create input: {e}")))?;
472 run_swift(&["create", &payload])
473 }
474
475 pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
476 let payload = serde_json::to_string(&input)
477 .map_err(|e| IntegrationError::Backend(format!("encode update input: {e}")))?;
478 run_swift(&["update", &payload])
479 }
480
481 pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
482 run_swift(&["delete", event_id])
483 }
484
485 fn run_swift<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
486 let helper = ensure_helper()?;
487 let output = Command::new(helper)
488 .env(
489 "SWIFT_MODULE_CACHE_PATH",
490 std::env::temp_dir().join("car-swift-module-cache"),
491 )
492 .env(
493 "CLANG_MODULE_CACHE_PATH",
494 std::env::temp_dir().join("car-clang-module-cache"),
495 )
496 .args(args)
497 .output()
498 .map_err(|e| IntegrationError::Backend(format!("swift: {e}")))?;
499
500 if !output.status.success() {
501 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
502 return Err(IntegrationError::Backend(format!(
503 "eventkit swift failed: {stderr}"
504 )));
505 }
506
507 serde_json::from_slice(&output.stdout)
508 .map_err(|e| IntegrationError::Backend(format!("eventkit json: {e}")))
509 }
510
511 fn source_fingerprint(s: &str) -> String {
521 use std::hash::{Hash, Hasher};
522 let mut h = std::collections::hash_map::DefaultHasher::new();
523 s.hash(&mut h);
524 format!("{:016x}", h.finish())
525 }
526
527 fn helper_cache_key() -> String {
528 format!("{HELPER_VERSION}-{}", source_fingerprint(SCRIPT))
529 }
530
531 #[cfg(test)]
532 mod helper_cache_tests {
533 use super::{helper_cache_key, source_fingerprint, HELPER_VERSION};
534
535 #[test]
536 fn fingerprint_is_deterministic_and_input_sensitive() {
537 assert_eq!(source_fingerprint("abc"), source_fingerprint("abc"));
540 assert_ne!(
541 source_fingerprint("mode == create"),
542 source_fingerprint("mode == delete")
543 );
544 assert_eq!(source_fingerprint("abc").len(), 16);
545 }
546
547 #[test]
548 fn cache_key_carries_version_prefix_and_source_hash() {
549 let key = helper_cache_key();
550 assert!(key.starts_with(&format!("{HELPER_VERSION}-")), "key: {key}");
551 assert_eq!(key.len(), HELPER_VERSION.len() + 1 + 16);
553 }
554 }
555
556 fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
557 let dir = helper_cache_dir();
558 let key = helper_cache_key();
559 let app = dir.join(format!("CAR EventKit Helper {key}.app"));
560 let contents = app.join("Contents");
561 let macos = contents.join("MacOS");
562 let helper = macos.join("CAR EventKit Helper");
563 if helper.exists() {
564 return Ok(helper);
565 }
566
567 std::fs::create_dir_all(&macos)
568 .map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
569 let source = dir.join(format!("car-eventkit-helper-{key}.swift"));
570 let plist = contents.join("Info.plist");
571 let entitlements = dir.join(format!("car-eventkit-helper-{key}.entitlements"));
572 std::fs::write(&source, SCRIPT)
573 .map_err(|e| IntegrationError::Backend(format!("eventkit helper source: {e}")))?;
574 std::fs::write(
575 &plist,
576 r#"<?xml version="1.0" encoding="UTF-8"?>
577<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
578<plist version="1.0">
579<dict>
580 <key>CFBundleIdentifier</key>
581 <string>ai.parslee.car.eventkit-helper</string>
582 <key>CFBundleExecutable</key>
583 <string>CAR EventKit Helper</string>
584 <key>CFBundleName</key>
585 <string>CAR EventKit Helper</string>
586 <key>CFBundlePackageType</key>
587 <string>APPL</string>
588 <key>CFBundleShortVersionString</key>
589 <string>0.9.0</string>
590 <key>CFBundleVersion</key>
591 <string>1</string>
592 <key>NSCalendarsUsageDescription</key>
593 <string>CAR reads calendars when an agent uses the calendar capability.</string>
594 <key>NSCalendarsFullAccessUsageDescription</key>
595 <string>CAR reads calendars when an agent uses the calendar capability.</string>
596</dict>
597</plist>
598"#,
599 )
600 .map_err(|e| IntegrationError::Backend(format!("eventkit helper plist: {e}")))?;
601 std::fs::write(
602 &entitlements,
603 r#"<?xml version="1.0" encoding="UTF-8"?>
604<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
605<plist version="1.0">
606<dict>
607 <key>com.apple.security.personal-information.calendars</key>
608 <true/>
609</dict>
610</plist>
611"#,
612 )
613 .map_err(|e| IntegrationError::Backend(format!("eventkit helper entitlements: {e}")))?;
614
615 let status = Command::new("/usr/bin/swiftc")
616 .env(
617 "SWIFT_MODULE_CACHE_PATH",
618 std::env::temp_dir().join("car-swift-module-cache"),
619 )
620 .env(
621 "CLANG_MODULE_CACHE_PATH",
622 std::env::temp_dir().join("car-clang-module-cache"),
623 )
624 .arg(&source)
625 .arg("-o")
626 .arg(&helper)
627 .arg("-Xlinker")
628 .arg("-sectcreate")
629 .arg("-Xlinker")
630 .arg("__TEXT")
631 .arg("-Xlinker")
632 .arg("__info_plist")
633 .arg("-Xlinker")
634 .arg(&plist)
635 .status()
636 .map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
637
638 if !status.success() {
639 return Err(IntegrationError::Backend(format!(
640 "eventkit helper compile failed with status {status}"
641 )));
642 }
643
644 sign_helper(&app, &entitlements)?;
645 Ok(helper)
646 }
647
648 fn helper_cache_dir() -> std::path::PathBuf {
649 if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
650 return std::path::PathBuf::from(path);
651 }
652 if let Some(home) = std::env::var_os("HOME") {
653 return std::path::PathBuf::from(home)
654 .join("Library")
655 .join("Application Support")
656 .join("CAR")
657 .join("NativeHelpers");
658 }
659 std::env::temp_dir().join("car-native-helpers")
660 }
661
662 fn sign_helper(
663 app: &std::path::Path,
664 entitlements: &std::path::Path,
665 ) -> Result<(), IntegrationError> {
666 let status = Command::new("/usr/bin/codesign")
667 .arg("--force")
668 .arg("--sign")
669 .arg("-")
670 .arg("--entitlements")
671 .arg(entitlements)
672 .arg(app)
673 .status()
674 .map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
675 if !status.success() {
676 return Err(IntegrationError::Backend(format!(
677 "eventkit helper codesign failed with status {status}"
678 )));
679 }
680 Ok(())
681 }
682}
683
684#[cfg(not(target_os = "macos"))]
685mod backend {
686 use super::*;
687
688 pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
689 Ok(CalendarListing {
690 availability: current_backend_pending(),
691 calendars: vec![],
692 })
693 }
694
695 pub fn list_events(
696 _start: DateTime<Utc>,
697 _end: DateTime<Utc>,
698 _calendar_ids: &[String],
699 ) -> Result<EventListing, IntegrationError> {
700 Ok(EventListing {
701 availability: current_backend_pending(),
702 events: vec![],
703 })
704 }
705
706 pub fn create_event(_input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
707 Ok(EventMutationResult {
708 ok: false,
709 event: None,
710 reason: Some("calendar event creation is not yet implemented on this platform".into()),
711 })
712 }
713
714 pub fn update_event(_input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
715 Ok(EventMutationResult {
716 ok: false,
717 event: None,
718 reason: Some("calendar event updates are not yet implemented on this platform".into()),
719 })
720 }
721
722 pub fn delete_event(_event_id: &str) -> Result<EventMutationResult, IntegrationError> {
723 Ok(EventMutationResult {
724 ok: false,
725 event: None,
726 reason: Some("calendar event deletion is not yet implemented on this platform".into()),
727 })
728 }
729
730 fn current_backend_pending() -> Availability {
731 #[cfg(target_os = "windows")]
732 {
733 Availability::pending(
734 "msgraph",
735 "MS Graph / Outlook MAPI backends not yet wired. API shape \
736 is stable; downstream apps can code against it now.",
737 )
738 }
739 #[cfg(target_os = "linux")]
740 {
741 Availability::pending(
742 "eds",
743 "Evolution Data Server + CalDAV backends not yet wired. \
744 API shape is stable; downstream apps can code against it now.",
745 )
746 }
747 #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
748 {
749 Availability::pending("none", "Unsupported OS — no calendar backend modeled.")
750 }
751 }
752}