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 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}