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