use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[cfg(target_os = "macos")]
use std::process::Command;
use super::{Availability, IntegrationError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Calendar {
pub id: String,
pub title: String,
pub source: Option<String>,
pub color: Option<String>,
pub writable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub id: String,
pub calendar_id: String,
pub title: String,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
#[serde(default)]
pub all_day: bool,
pub location: Option<String>,
pub notes: Option<String>,
#[serde(default)]
pub attendees: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarListing {
#[serde(flatten)]
pub availability: Availability,
pub calendars: Vec<Calendar>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventListing {
#[serde(flatten)]
pub availability: Availability,
pub events: Vec<Event>,
}
pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
backend::list_calendars()
}
pub fn list_events(
start: DateTime<Utc>,
end: DateTime<Utc>,
calendar_ids: &[String],
) -> Result<EventListing, IntegrationError> {
backend::list_events(start, end, calendar_ids)
}
#[cfg(target_os = "macos")]
mod backend {
use super::*;
const HELPER_VERSION: &str = "v4";
const SCRIPT: &str = r##"
import EventKit
import Foundation
import Dispatch
struct Availability: Codable {
let available: Bool
let backend: String
let reason: String?
}
struct CalendarOut: Codable {
let id: String
let title: String
let source: String?
let color: String?
let writable: Bool
}
struct EventOut: Codable {
let id: String
let calendar_id: String
let title: String
let start: Date
let end: Date
let all_day: Bool
let location: String?
let notes: String?
let attendees: [String]
}
struct CalendarListing: Codable {
let available: Bool
let backend: String
let reason: String?
let calendars: [CalendarOut]
}
struct EventListing: Codable {
let available: Bool
let backend: String
let reason: String?
let events: [EventOut]
}
func emit<T: Encodable>(_ value: T) {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try! encoder.encode(value)
FileHandle.standardOutput.write(data)
}
func unavailable<T: Encodable>(_ reason: String, empty: T) {
emit(empty)
}
func ensureAccess(_ store: EKEventStore) -> String? {
let status = EKEventStore.authorizationStatus(for: .event)
switch status {
case .authorized:
return nil
case .notDetermined:
let semaphore = DispatchSemaphore(value: 0)
var granted = false
if #available(macOS 14.0, *) {
store.requestFullAccessToEvents { ok, _ in
granted = ok
semaphore.signal()
}
} else {
store.requestAccess(to: .event) { ok, _ in
granted = ok
semaphore.signal()
}
}
_ = semaphore.wait(timeout: .now() + 60)
return granted ? nil : "Calendar permission was not granted"
case .restricted:
return "Calendar permission is restricted by system policy"
case .denied:
return "Calendar permission is denied"
case .writeOnly:
return "Calendar permission is write-only; read access is required"
case .fullAccess:
return nil
@unknown default:
return "Calendar permission is unavailable"
}
}
func hexColor(_ cgColor: CGColor?) -> String? {
guard let cgColor = cgColor, let components = cgColor.components else { return nil }
let r: CGFloat
let g: CGFloat
let b: CGFloat
if components.count >= 3 {
r = components[0]
g = components[1]
b = components[2]
} else if components.count >= 1 {
r = components[0]
g = components[0]
b = components[0]
} else {
return nil
}
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))
}
let args = CommandLine.arguments
let mode = args.count > 1 ? args[1] : "calendars"
let store = EKEventStore()
if let reason = ensureAccess(store) {
if mode == "events" {
emit(EventListing(available: false, backend: "eventkit", reason: reason, events: []))
} else {
emit(CalendarListing(available: false, backend: "eventkit", reason: reason, calendars: []))
}
exit(0)
}
if mode == "events" {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let fallback = ISO8601DateFormatter()
guard args.count >= 4,
let start = formatter.date(from: args[2]) ?? fallback.date(from: args[2]),
let end = formatter.date(from: args[3]) ?? fallback.date(from: args[3]) else {
emit(EventListing(available: false, backend: "eventkit", reason: "Invalid RFC3339 date range", events: []))
exit(0)
}
let requested = Set(args.dropFirst(4))
let calendars = store.calendars(for: .event).filter { requested.isEmpty || requested.contains($0.calendarIdentifier) }
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
let events = store.events(matching: predicate).map { event in
EventOut(
id: event.eventIdentifier ?? "\(event.calendarItemIdentifier)-\(event.startDate.timeIntervalSince1970)",
calendar_id: event.calendar.calendarIdentifier,
title: event.title ?? "",
start: event.startDate,
end: event.endDate,
all_day: event.isAllDay,
location: event.location,
notes: event.notes,
attendees: (event.attendees ?? []).compactMap { $0.name ?? $0.url.absoluteString }
)
}
emit(EventListing(available: true, backend: "eventkit", reason: nil, events: events))
} else {
let calendars = store.calendars(for: .event).map { cal in
CalendarOut(
id: cal.calendarIdentifier,
title: cal.title,
source: cal.source?.title,
color: hexColor(cal.cgColor),
writable: cal.allowsContentModifications
)
}
emit(CalendarListing(available: true, backend: "eventkit", reason: nil, calendars: calendars))
}
"##;
pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
run_swift(&["calendars"])
}
pub fn list_events(
start: DateTime<Utc>,
end: DateTime<Utc>,
calendar_ids: &[String],
) -> Result<EventListing, IntegrationError> {
let start = start.to_rfc3339();
let end = end.to_rfc3339();
let mut args = vec!["events", start.as_str(), end.as_str()];
args.extend(calendar_ids.iter().map(String::as_str));
run_swift(&args)
}
fn run_swift<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
let helper = ensure_helper()?;
let output = Command::new(helper)
.env(
"SWIFT_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-swift-module-cache"),
)
.env(
"CLANG_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-clang-module-cache"),
)
.args(args)
.output()
.map_err(|e| IntegrationError::Backend(format!("swift: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(IntegrationError::Backend(format!(
"eventkit swift failed: {stderr}"
)));
}
serde_json::from_slice(&output.stdout)
.map_err(|e| IntegrationError::Backend(format!("eventkit json: {e}")))
}
fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
let dir = helper_cache_dir();
let app = dir.join(format!("CAR EventKit Helper {HELPER_VERSION}.app"));
let contents = app.join("Contents");
let macos = contents.join("MacOS");
let helper = macos.join("CAR EventKit Helper");
if helper.exists() {
return Ok(helper);
}
std::fs::create_dir_all(&macos)
.map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
let source = dir.join(format!("car-eventkit-helper-{HELPER_VERSION}.swift"));
let plist = contents.join("Info.plist");
let entitlements = dir.join(format!("car-eventkit-helper-{HELPER_VERSION}.entitlements"));
std::fs::write(&source, SCRIPT)
.map_err(|e| IntegrationError::Backend(format!("eventkit helper source: {e}")))?;
std::fs::write(
&plist,
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>ai.parslee.car.eventkit-helper</string>
<key>CFBundleExecutable</key>
<string>CAR EventKit Helper</string>
<key>CFBundleName</key>
<string>CAR EventKit Helper</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.9.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSCalendarsUsageDescription</key>
<string>CAR reads calendars when an agent uses the calendar capability.</string>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>CAR reads calendars when an agent uses the calendar capability.</string>
</dict>
</plist>
"#,
)
.map_err(|e| IntegrationError::Backend(format!("eventkit helper plist: {e}")))?;
std::fs::write(
&entitlements,
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.personal-information.calendars</key>
<true/>
</dict>
</plist>
"#,
)
.map_err(|e| IntegrationError::Backend(format!("eventkit helper entitlements: {e}")))?;
let status = Command::new("/usr/bin/swiftc")
.env(
"SWIFT_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-swift-module-cache"),
)
.env(
"CLANG_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-clang-module-cache"),
)
.arg(&source)
.arg("-o")
.arg(&helper)
.arg("-Xlinker")
.arg("-sectcreate")
.arg("-Xlinker")
.arg("__TEXT")
.arg("-Xlinker")
.arg("__info_plist")
.arg("-Xlinker")
.arg(&plist)
.status()
.map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
if !status.success() {
return Err(IntegrationError::Backend(format!(
"eventkit helper compile failed with status {status}"
)));
}
sign_helper(&app, &entitlements)?;
Ok(helper)
}
fn helper_cache_dir() -> std::path::PathBuf {
if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
return std::path::PathBuf::from(path);
}
if let Some(home) = std::env::var_os("HOME") {
return std::path::PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("CAR")
.join("NativeHelpers");
}
std::env::temp_dir().join("car-native-helpers")
}
fn sign_helper(
app: &std::path::Path,
entitlements: &std::path::Path,
) -> Result<(), IntegrationError> {
let status = Command::new("/usr/bin/codesign")
.arg("--force")
.arg("--sign")
.arg("-")
.arg("--entitlements")
.arg(entitlements)
.arg(app)
.status()
.map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
if !status.success() {
return Err(IntegrationError::Backend(format!(
"eventkit helper codesign failed with status {status}"
)));
}
Ok(())
}
}
#[cfg(not(target_os = "macos"))]
mod backend {
use super::*;
pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
Ok(CalendarListing {
availability: current_backend_pending(),
calendars: vec![],
})
}
pub fn list_events(
_start: DateTime<Utc>,
_end: DateTime<Utc>,
_calendar_ids: &[String],
) -> Result<EventListing, IntegrationError> {
Ok(EventListing {
availability: current_backend_pending(),
events: vec![],
})
}
fn current_backend_pending() -> Availability {
#[cfg(target_os = "windows")]
{
Availability::pending(
"msgraph",
"MS Graph / Outlook MAPI backends not yet wired. API shape \
is stable; downstream apps can code against it now.",
)
}
#[cfg(target_os = "linux")]
{
Availability::pending(
"eds",
"Evolution Data Server + CalDAV backends not yet wired. \
API shape is stable; downstream apps can code against it now.",
)
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
Availability::pending("none", "Unsupported OS — no calendar backend modeled.")
}
}
}