Skip to main content

car_integrations/calendar/
mod.rs

1//! Calendar capability — list calendars, list upcoming events.
2
3use 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    /// Stable identifier within the current host.
13    pub id: String,
14    /// Display name as shown in the user's Calendar app.
15    pub title: String,
16    /// Source label (account / provider) where the OS exposes it.
17    pub source: Option<String>,
18    /// Calendar color as `#RRGGBB` hex, when available.
19    pub color: Option<String>,
20    /// Whether the user can create/update events on this calendar.
21    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
53pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
54    backend::list_calendars()
55}
56
57/// List events between `start` and `end`. When no calendar IDs are
58/// supplied, backends include all accessible calendars.
59pub fn list_events(
60    start: DateTime<Utc>,
61    end: DateTime<Utc>,
62    calendar_ids: &[String],
63) -> Result<EventListing, IntegrationError> {
64    backend::list_events(start, end, calendar_ids)
65}
66
67#[cfg(target_os = "macos")]
68mod backend {
69    use super::*;
70
71    const SCRIPT: &str = r##"
72import EventKit
73import Foundation
74import Dispatch
75
76struct Availability: Codable {
77    let available: Bool
78    let backend: String
79    let reason: String?
80}
81
82struct CalendarOut: Codable {
83    let id: String
84    let title: String
85    let source: String?
86    let color: String?
87    let writable: Bool
88}
89
90struct EventOut: Codable {
91    let id: String
92    let calendar_id: String
93    let title: String
94    let start: Date
95    let end: Date
96    let all_day: Bool
97    let location: String?
98    let notes: String?
99    let attendees: [String]
100}
101
102struct CalendarListing: Codable {
103    let available: Bool
104    let backend: String
105    let reason: String?
106    let calendars: [CalendarOut]
107}
108
109struct EventListing: Codable {
110    let available: Bool
111    let backend: String
112    let reason: String?
113    let events: [EventOut]
114}
115
116func emit<T: Encodable>(_ value: T) {
117    let encoder = JSONEncoder()
118    encoder.dateEncodingStrategy = .iso8601
119    let data = try! encoder.encode(value)
120    FileHandle.standardOutput.write(data)
121}
122
123func unavailable<T: Encodable>(_ reason: String, empty: T) {
124    emit(empty)
125}
126
127func ensureAccess(_ store: EKEventStore) -> String? {
128    let status = EKEventStore.authorizationStatus(for: .event)
129    switch status {
130    case .authorized:
131        return nil
132    case .notDetermined:
133        let semaphore = DispatchSemaphore(value: 0)
134        var granted = false
135        if #available(macOS 14.0, *) {
136            store.requestFullAccessToEvents { ok, _ in
137                granted = ok
138                semaphore.signal()
139            }
140        } else {
141            store.requestAccess(to: .event) { ok, _ in
142                granted = ok
143                semaphore.signal()
144            }
145        }
146        _ = semaphore.wait(timeout: .now() + 60)
147        return granted ? nil : "Calendar permission was not granted"
148    case .restricted:
149        return "Calendar permission is restricted by system policy"
150    case .denied:
151        return "Calendar permission is denied"
152    case .writeOnly:
153        return "Calendar permission is write-only; read access is required"
154    case .fullAccess:
155        return nil
156    @unknown default:
157        return "Calendar permission is unavailable"
158    }
159}
160
161func hexColor(_ cgColor: CGColor?) -> String? {
162    guard let cgColor = cgColor, let components = cgColor.components else { return nil }
163    let r: CGFloat
164    let g: CGFloat
165    let b: CGFloat
166    if components.count >= 3 {
167        r = components[0]
168        g = components[1]
169        b = components[2]
170    } else if components.count >= 1 {
171        r = components[0]
172        g = components[0]
173        b = components[0]
174    } else {
175        return nil
176    }
177    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))
178}
179
180let args = CommandLine.arguments
181let mode = args.count > 1 ? args[1] : "calendars"
182let store = EKEventStore()
183if let reason = ensureAccess(store) {
184    if mode == "events" {
185        emit(EventListing(available: false, backend: "eventkit", reason: reason, events: []))
186    } else {
187        emit(CalendarListing(available: false, backend: "eventkit", reason: reason, calendars: []))
188    }
189    exit(0)
190}
191
192if mode == "events" {
193    let formatter = ISO8601DateFormatter()
194    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
195    let fallback = ISO8601DateFormatter()
196    guard args.count >= 4,
197          let start = formatter.date(from: args[2]) ?? fallback.date(from: args[2]),
198          let end = formatter.date(from: args[3]) ?? fallback.date(from: args[3]) else {
199        emit(EventListing(available: false, backend: "eventkit", reason: "Invalid RFC3339 date range", events: []))
200        exit(0)
201    }
202    let requested = Set(args.dropFirst(4))
203    let calendars = store.calendars(for: .event).filter { requested.isEmpty || requested.contains($0.calendarIdentifier) }
204    let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
205    let events = store.events(matching: predicate).map { event in
206        EventOut(
207            id: event.eventIdentifier ?? "\(event.calendarItemIdentifier)-\(event.startDate.timeIntervalSince1970)",
208            calendar_id: event.calendar.calendarIdentifier,
209            title: event.title ?? "",
210            start: event.startDate,
211            end: event.endDate,
212            all_day: event.isAllDay,
213            location: event.location,
214            notes: event.notes,
215            attendees: (event.attendees ?? []).compactMap { $0.name ?? $0.url.absoluteString }
216        )
217    }
218    emit(EventListing(available: true, backend: "eventkit", reason: nil, events: events))
219} else {
220    let calendars = store.calendars(for: .event).map { cal in
221        CalendarOut(
222            id: cal.calendarIdentifier,
223            title: cal.title,
224            source: cal.source?.title,
225            color: hexColor(cal.cgColor),
226            writable: cal.allowsContentModifications
227        )
228    }
229    emit(CalendarListing(available: true, backend: "eventkit", reason: nil, calendars: calendars))
230}
231"##;
232
233    pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
234        run_swift(&["calendars"])
235    }
236
237    pub fn list_events(
238        start: DateTime<Utc>,
239        end: DateTime<Utc>,
240        calendar_ids: &[String],
241    ) -> Result<EventListing, IntegrationError> {
242        let start = start.to_rfc3339();
243        let end = end.to_rfc3339();
244        let mut args = vec!["events", start.as_str(), end.as_str()];
245        args.extend(calendar_ids.iter().map(String::as_str));
246        run_swift(&args)
247    }
248
249    fn run_swift<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
250        let helper = ensure_helper()?;
251        let output = Command::new(helper)
252            .env(
253                "SWIFT_MODULE_CACHE_PATH",
254                std::env::temp_dir().join("car-swift-module-cache"),
255            )
256            .env(
257                "CLANG_MODULE_CACHE_PATH",
258                std::env::temp_dir().join("car-clang-module-cache"),
259            )
260            .args(args)
261            .output()
262            .map_err(|e| IntegrationError::Backend(format!("swift: {e}")))?;
263
264        if !output.status.success() {
265            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
266            return Err(IntegrationError::Backend(format!(
267                "eventkit swift failed: {stderr}"
268            )));
269        }
270
271        serde_json::from_slice(&output.stdout)
272            .map_err(|e| IntegrationError::Backend(format!("eventkit json: {e}")))
273    }
274
275    fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
276        let dir = helper_cache_dir();
277        let app = dir.join("CAR EventKit Helper.app");
278        let contents = app.join("Contents");
279        let macos = contents.join("MacOS");
280        let helper = macos.join("CAR EventKit Helper");
281        if helper.exists() {
282            return Ok(helper);
283        }
284
285        std::fs::create_dir_all(&macos)
286            .map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
287        let source = dir.join("car-eventkit-helper-v3.swift");
288        let plist = contents.join("Info.plist");
289        let entitlements = dir.join("car-eventkit-helper-v3.entitlements");
290        std::fs::write(&source, SCRIPT)
291            .map_err(|e| IntegrationError::Backend(format!("eventkit helper source: {e}")))?;
292        std::fs::write(
293            &plist,
294            r#"<?xml version="1.0" encoding="UTF-8"?>
295<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
296<plist version="1.0">
297<dict>
298  <key>CFBundleIdentifier</key>
299  <string>ai.parslee.car.eventkit-helper</string>
300  <key>CFBundleExecutable</key>
301  <string>CAR EventKit Helper</string>
302  <key>CFBundleName</key>
303  <string>CAR EventKit Helper</string>
304  <key>CFBundlePackageType</key>
305  <string>APPL</string>
306  <key>CFBundleShortVersionString</key>
307  <string>0.6.1</string>
308  <key>CFBundleVersion</key>
309  <string>1</string>
310  <key>NSCalendarsUsageDescription</key>
311  <string>CAR reads calendars when an agent uses the calendar capability.</string>
312  <key>NSCalendarsFullAccessUsageDescription</key>
313  <string>CAR reads calendars when an agent uses the calendar capability.</string>
314</dict>
315</plist>
316"#,
317        )
318        .map_err(|e| IntegrationError::Backend(format!("eventkit helper plist: {e}")))?;
319        std::fs::write(
320            &entitlements,
321            r#"<?xml version="1.0" encoding="UTF-8"?>
322<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
323<plist version="1.0">
324<dict>
325  <key>com.apple.security.personal-information.calendars</key>
326  <true/>
327</dict>
328</plist>
329"#,
330        )
331        .map_err(|e| IntegrationError::Backend(format!("eventkit helper entitlements: {e}")))?;
332
333        let status = Command::new("/usr/bin/swiftc")
334            .env(
335                "SWIFT_MODULE_CACHE_PATH",
336                std::env::temp_dir().join("car-swift-module-cache"),
337            )
338            .env(
339                "CLANG_MODULE_CACHE_PATH",
340                std::env::temp_dir().join("car-clang-module-cache"),
341            )
342            .arg(&source)
343            .arg("-o")
344            .arg(&helper)
345            .arg("-Xlinker")
346            .arg("-sectcreate")
347            .arg("-Xlinker")
348            .arg("__TEXT")
349            .arg("-Xlinker")
350            .arg("__info_plist")
351            .arg("-Xlinker")
352            .arg(&plist)
353            .status()
354            .map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
355
356        if !status.success() {
357            return Err(IntegrationError::Backend(format!(
358                "eventkit helper compile failed with status {status}"
359            )));
360        }
361
362        sign_helper(&app, &entitlements)?;
363        Ok(helper)
364    }
365
366    fn helper_cache_dir() -> std::path::PathBuf {
367        if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
368            return std::path::PathBuf::from(path);
369        }
370        if let Some(home) = std::env::var_os("HOME") {
371            return std::path::PathBuf::from(home)
372                .join("Library")
373                .join("Application Support")
374                .join("CAR")
375                .join("NativeHelpers");
376        }
377        std::env::temp_dir().join("car-native-helpers")
378    }
379
380    fn sign_helper(
381        app: &std::path::Path,
382        entitlements: &std::path::Path,
383    ) -> Result<(), IntegrationError> {
384        let status = Command::new("/usr/bin/codesign")
385            .arg("--force")
386            .arg("--sign")
387            .arg("-")
388            .arg("--entitlements")
389            .arg(entitlements)
390            .arg(app)
391            .status()
392            .map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
393        if !status.success() {
394            return Err(IntegrationError::Backend(format!(
395                "eventkit helper codesign failed with status {status}"
396            )));
397        }
398        Ok(())
399    }
400}
401
402#[cfg(not(target_os = "macos"))]
403mod backend {
404    use super::*;
405
406    pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
407        Ok(CalendarListing {
408            availability: current_backend_pending(),
409            calendars: vec![],
410        })
411    }
412
413    pub fn list_events(
414        _start: DateTime<Utc>,
415        _end: DateTime<Utc>,
416        _calendar_ids: &[String],
417    ) -> Result<EventListing, IntegrationError> {
418        Ok(EventListing {
419            availability: current_backend_pending(),
420            events: vec![],
421        })
422    }
423
424    fn current_backend_pending() -> Availability {
425        #[cfg(target_os = "windows")]
426        {
427            Availability::pending(
428                "msgraph",
429                "MS Graph / Outlook MAPI backends not yet wired. API shape \
430             is stable; downstream apps can code against it now.",
431            )
432        }
433        #[cfg(target_os = "linux")]
434        {
435            Availability::pending(
436                "eds",
437                "Evolution Data Server + CalDAV backends not yet wired. \
438             API shape is stable; downstream apps can code against it now.",
439            )
440        }
441        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
442        {
443            Availability::pending("none", "Unsupported OS — no calendar backend modeled.")
444        }
445    }
446}