car_integrations/calendar/
mod.rs1use 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
53pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
54 backend::list_calendars()
55}
56
57pub 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}