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
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct EventCreateInput {
55    pub calendar_id: String,
56    pub title: String,
57    pub start: DateTime<Utc>,
58    pub end: DateTime<Utc>,
59    #[serde(default)]
60    pub all_day: bool,
61    #[serde(default)]
62    pub notes: Option<String>,
63    #[serde(default)]
64    pub location: Option<String>,
65    #[serde(default)]
66    pub url: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct EventUpdateInput {
71    pub event_id: String,
72    #[serde(default)]
73    pub title: Option<String>,
74    #[serde(default)]
75    pub start: Option<DateTime<Utc>>,
76    #[serde(default)]
77    pub end: Option<DateTime<Utc>>,
78    #[serde(default)]
79    pub all_day: Option<bool>,
80    #[serde(default)]
81    pub notes: Option<String>,
82    #[serde(default)]
83    pub location: Option<String>,
84    #[serde(default)]
85    pub url: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EventMutationResult {
90    pub ok: bool,
91    #[serde(default)]
92    pub event: Option<Event>,
93    #[serde(default)]
94    pub reason: Option<String>,
95}
96
97pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
98    backend::list_calendars()
99}
100
101/// List events between `start` and `end`. When no calendar IDs are
102/// supplied, backends include all accessible calendars.
103pub fn list_events(
104    start: DateTime<Utc>,
105    end: DateTime<Utc>,
106    calendar_ids: &[String],
107) -> Result<EventListing, IntegrationError> {
108    backend::list_events(start, end, calendar_ids)
109}
110
111/// Create a new event on the calendar identified by `input.calendar_id`.
112/// The calendar must allow content modifications. Returns the created
113/// event with its host-assigned id, or `ok: false` with a reason.
114pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
115    backend::create_event(input)
116}
117
118/// Update fields on an existing event. Any `None` field on
119/// `EventUpdateInput` leaves the event's existing value unchanged
120/// (except for `notes`/`location`/`url`, where an empty-string `Some`
121/// value clears the field — matches the semantics callers had via the
122/// previous shell-out helper).
123pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
124    backend::update_event(input)
125}
126
127/// Delete an event by its host-assigned id. Returns `ok: true` with the
128/// id echoed when the event was removed, or `ok: false` with a reason
129/// when it wasn't (event_not_found, write access denied, etc.).
130pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
131    backend::delete_event(event_id)
132}
133
134#[cfg(target_os = "macos")]
135mod backend {
136    use super::*;
137
138    // Bump on any SCRIPT edit. ensure_helper()'s on-disk cache is keyed on
139    // this string so stale binaries are superseded automatically — without
140    // it, the helper compiled on first use is reused forever even after
141    // bug fixes ship in the Rust source. Mirrors the contacts helper's
142    // convention (#200).
143    const HELPER_VERSION: &str = "v4";
144
145    const SCRIPT: &str = r##"
146import EventKit
147import Foundation
148import Dispatch
149
150struct Availability: Codable {
151    let available: Bool
152    let backend: String
153    let reason: String?
154}
155
156struct CalendarOut: Codable {
157    let id: String
158    let title: String
159    let source: String?
160    let color: String?
161    let writable: Bool
162}
163
164struct EventOut: Codable {
165    let id: String
166    let calendar_id: String
167    let title: String
168    let start: Date
169    let end: Date
170    let all_day: Bool
171    let location: String?
172    let notes: String?
173    let attendees: [String]
174}
175
176struct CalendarListing: Codable {
177    let available: Bool
178    let backend: String
179    let reason: String?
180    let calendars: [CalendarOut]
181}
182
183struct EventListing: Codable {
184    let available: Bool
185    let backend: String
186    let reason: String?
187    let events: [EventOut]
188}
189
190struct EventCreateInput: Codable {
191    let calendar_id: String
192    let title: String
193    let start: Date
194    let end: Date
195    let all_day: Bool?
196    let notes: String?
197    let location: String?
198    let url: String?
199}
200
201struct EventUpdateInput: Codable {
202    let event_id: String
203    let title: String?
204    let start: Date?
205    let end: Date?
206    let all_day: Bool?
207    let notes: String?
208    let location: String?
209    let url: String?
210}
211
212struct EventMutationOut: Codable {
213    let ok: Bool
214    let event: EventOut?
215    let reason: String?
216}
217
218func emit<T: Encodable>(_ value: T) {
219    let encoder = JSONEncoder()
220    encoder.dateEncodingStrategy = .iso8601
221    let data = try! encoder.encode(value)
222    FileHandle.standardOutput.write(data)
223}
224
225func eventOut(_ event: EKEvent) -> EventOut {
226    return EventOut(
227        id: event.eventIdentifier ?? "\(event.calendarItemIdentifier)-\(event.startDate.timeIntervalSince1970)",
228        calendar_id: event.calendar.calendarIdentifier,
229        title: event.title ?? "",
230        start: event.startDate,
231        end: event.endDate,
232        all_day: event.isAllDay,
233        location: event.location,
234        notes: event.notes,
235        attendees: (event.attendees ?? []).compactMap { $0.name ?? $0.url.absoluteString }
236    )
237}
238
239func unavailable<T: Encodable>(_ reason: String, empty: T) {
240    emit(empty)
241}
242
243let internetDateFormatter: ISO8601DateFormatter = {
244    let formatter = ISO8601DateFormatter()
245    formatter.formatOptions = [.withInternetDateTime]
246    return formatter
247}()
248
249let fractionalDateFormatter: ISO8601DateFormatter = {
250    let formatter = ISO8601DateFormatter()
251    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
252    return formatter
253}()
254
255func decodeDate(_ decoder: Decoder) throws -> Date {
256    let container = try decoder.singleValueContainer()
257    let raw = try container.decode(String.self)
258    if let date = fractionalDateFormatter.date(from: raw) ?? internetDateFormatter.date(from: raw) {
259        return date
260    }
261    throw DecodingError.dataCorruptedError(
262        in: container,
263        debugDescription: "Invalid RFC3339 date: \(raw)"
264    )
265}
266
267func ensureAccess(_ store: EKEventStore) -> String? {
268    let status = EKEventStore.authorizationStatus(for: .event)
269    switch status {
270    case .authorized:
271        return nil
272    case .notDetermined:
273        let semaphore = DispatchSemaphore(value: 0)
274        var granted = false
275        if #available(macOS 14.0, *) {
276            store.requestFullAccessToEvents { ok, _ in
277                granted = ok
278                semaphore.signal()
279            }
280        } else {
281            store.requestAccess(to: .event) { ok, _ in
282                granted = ok
283                semaphore.signal()
284            }
285        }
286        _ = semaphore.wait(timeout: .now() + 60)
287        return granted ? nil : "Calendar permission was not granted"
288    case .restricted:
289        return "Calendar permission is restricted by system policy"
290    case .denied:
291        return "Calendar permission is denied"
292    case .writeOnly:
293        return "Calendar permission is write-only; read access is required"
294    case .fullAccess:
295        return nil
296    @unknown default:
297        return "Calendar permission is unavailable"
298    }
299}
300
301func hexColor(_ cgColor: CGColor?) -> String? {
302    guard let cgColor = cgColor, let components = cgColor.components else { return nil }
303    let r: CGFloat
304    let g: CGFloat
305    let b: CGFloat
306    if components.count >= 3 {
307        r = components[0]
308        g = components[1]
309        b = components[2]
310    } else if components.count >= 1 {
311        r = components[0]
312        g = components[0]
313        b = components[0]
314    } else {
315        return nil
316    }
317    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))
318}
319
320let args = CommandLine.arguments
321let mode = args.count > 1 ? args[1] : "calendars"
322let store = EKEventStore()
323if let reason = ensureAccess(store) {
324    if mode == "events" {
325        emit(EventListing(available: false, backend: "eventkit", reason: reason, events: []))
326    } else if mode == "create" || mode == "update" || mode == "delete" {
327        emit(EventMutationOut(ok: false, event: nil, reason: reason))
328    } else {
329        emit(CalendarListing(available: false, backend: "eventkit", reason: reason, calendars: []))
330    }
331    exit(0)
332}
333
334if mode == "events" {
335    let formatter = ISO8601DateFormatter()
336    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
337    let fallback = ISO8601DateFormatter()
338    guard args.count >= 4,
339          let start = formatter.date(from: args[2]) ?? fallback.date(from: args[2]),
340          let end = formatter.date(from: args[3]) ?? fallback.date(from: args[3]) else {
341        emit(EventListing(available: false, backend: "eventkit", reason: "Invalid RFC3339 date range", events: []))
342        exit(0)
343    }
344    let requested = Set(args.dropFirst(4))
345    let calendars = store.calendars(for: .event).filter { requested.isEmpty || requested.contains($0.calendarIdentifier) }
346    let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
347    let events = store.events(matching: predicate).map { eventOut($0) }
348    emit(EventListing(available: true, backend: "eventkit", reason: nil, events: events))
349} else if mode == "create" {
350    guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
351        emit(EventMutationOut(ok: false, event: nil, reason: "create requires JSON payload as args[2]"))
352        exit(0)
353    }
354    let decoder = JSONDecoder()
355    decoder.dateDecodingStrategy = .custom(decodeDate)
356    guard let input = try? decoder.decode(EventCreateInput.self, from: payload) else {
357        emit(EventMutationOut(ok: false, event: nil, reason: "invalid_create_input_json"))
358        exit(0)
359    }
360    guard let calendar = store.calendar(withIdentifier: input.calendar_id) else {
361        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_not_found"))
362        exit(0)
363    }
364    guard calendar.allowsContentModifications else {
365        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
366        exit(0)
367    }
368    let event = EKEvent(eventStore: store)
369    event.calendar = calendar
370    event.title = input.title
371    event.startDate = input.start
372    event.endDate = input.end
373    event.isAllDay = input.all_day ?? false
374    if let notes = input.notes, !notes.isEmpty { event.notes = notes }
375    if let location = input.location, !location.isEmpty { event.location = location }
376    if let urlRaw = input.url, !urlRaw.isEmpty, let parsed = URL(string: urlRaw) { event.url = parsed }
377    do {
378        try store.save(event, span: .thisEvent)
379        emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
380    } catch {
381        emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
382    }
383} else if mode == "update" {
384    guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
385        emit(EventMutationOut(ok: false, event: nil, reason: "update requires JSON payload as args[2]"))
386        exit(0)
387    }
388    let decoder = JSONDecoder()
389    decoder.dateDecodingStrategy = .custom(decodeDate)
390    guard let input = try? decoder.decode(EventUpdateInput.self, from: payload) else {
391        emit(EventMutationOut(ok: false, event: nil, reason: "invalid_update_input_json"))
392        exit(0)
393    }
394    guard let event = store.event(withIdentifier: input.event_id) else {
395        emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
396        exit(0)
397    }
398    guard event.calendar.allowsContentModifications else {
399        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
400        exit(0)
401    }
402    if let title = input.title { event.title = title }
403    if let start = input.start { event.startDate = start }
404    if let end = input.end { event.endDate = end }
405    if let allDay = input.all_day { event.isAllDay = allDay }
406    // Empty-string for notes/location/url means "clear the field". The
407    // None case (field absent in JSON) leaves the existing value alone.
408    if let notes = input.notes { event.notes = notes.isEmpty ? nil : notes }
409    if let location = input.location { event.location = location.isEmpty ? nil : location }
410    if let urlRaw = input.url {
411        event.url = urlRaw.isEmpty ? nil : URL(string: urlRaw)
412    }
413    do {
414        try store.save(event, span: .thisEvent)
415        emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
416    } catch {
417        emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
418    }
419} else if mode == "delete" {
420    guard args.count >= 3 else {
421        emit(EventMutationOut(ok: false, event: nil, reason: "delete requires event_id as args[2]"))
422        exit(0)
423    }
424    let eventId = args[2]
425    guard let event = store.event(withIdentifier: eventId) else {
426        emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
427        exit(0)
428    }
429    guard event.calendar.allowsContentModifications else {
430        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
431        exit(0)
432    }
433    do {
434        try store.remove(event, span: .thisEvent)
435        emit(EventMutationOut(ok: true, event: nil, reason: nil))
436    } catch {
437        emit(EventMutationOut(ok: false, event: nil, reason: "delete_failed: \(error.localizedDescription)"))
438    }
439} else {
440    let calendars = store.calendars(for: .event).map { cal in
441        CalendarOut(
442            id: cal.calendarIdentifier,
443            title: cal.title,
444            source: cal.source?.title,
445            color: hexColor(cal.cgColor),
446            writable: cal.allowsContentModifications
447        )
448    }
449    emit(CalendarListing(available: true, backend: "eventkit", reason: nil, calendars: calendars))
450}
451"##;
452
453    pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
454        run_swift(&["calendars"])
455    }
456
457    pub fn list_events(
458        start: DateTime<Utc>,
459        end: DateTime<Utc>,
460        calendar_ids: &[String],
461    ) -> Result<EventListing, IntegrationError> {
462        let start = start.to_rfc3339();
463        let end = end.to_rfc3339();
464        let mut args = vec!["events", start.as_str(), end.as_str()];
465        args.extend(calendar_ids.iter().map(String::as_str));
466        run_swift(&args)
467    }
468
469    pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
470        let payload = serde_json::to_string(&input)
471            .map_err(|e| IntegrationError::Backend(format!("encode create input: {e}")))?;
472        run_swift(&["create", &payload])
473    }
474
475    pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
476        let payload = serde_json::to_string(&input)
477            .map_err(|e| IntegrationError::Backend(format!("encode update input: {e}")))?;
478        run_swift(&["update", &payload])
479    }
480
481    pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
482        run_swift(&["delete", event_id])
483    }
484
485    fn run_swift<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
486        let helper = ensure_helper()?;
487        let output = Command::new(helper)
488            .env(
489                "SWIFT_MODULE_CACHE_PATH",
490                std::env::temp_dir().join("car-swift-module-cache"),
491            )
492            .env(
493                "CLANG_MODULE_CACHE_PATH",
494                std::env::temp_dir().join("car-clang-module-cache"),
495            )
496            .args(args)
497            .output()
498            .map_err(|e| IntegrationError::Backend(format!("swift: {e}")))?;
499
500        if !output.status.success() {
501            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
502            return Err(IntegrationError::Backend(format!(
503                "eventkit swift failed: {stderr}"
504            )));
505        }
506
507        serde_json::from_slice(&output.stdout)
508            .map_err(|e| IntegrationError::Backend(format!("eventkit json: {e}")))
509    }
510
511    /// Cache key for the compiled helper. Combines the manual `HELPER_VERSION`
512    /// with a content hash of the embedded Swift `SCRIPT`, so ANY change to the
513    /// source auto-invalidates the cache. A manual bump alone was the car#64
514    /// regression: the Swift gained `create`/`update`/`delete` modes but the
515    /// cache key stayed `v4`, so upgraders kept reusing a stale pre-mutation
516    /// `v4.app` (every mutation fell through to the calendar-listing `else`,
517    /// failing Rust deserialization with `missing field \`ok\``). DefaultHasher
518    /// is deterministic for the same bytes; a hash change across a toolchain
519    /// bump just triggers one harmless recompile.
520    fn source_fingerprint(s: &str) -> String {
521        use std::hash::{Hash, Hasher};
522        let mut h = std::collections::hash_map::DefaultHasher::new();
523        s.hash(&mut h);
524        format!("{:016x}", h.finish())
525    }
526
527    fn helper_cache_key() -> String {
528        format!("{HELPER_VERSION}-{}", source_fingerprint(SCRIPT))
529    }
530
531    #[cfg(test)]
532    mod helper_cache_tests {
533        use super::{helper_cache_key, source_fingerprint, HELPER_VERSION};
534
535        #[test]
536        fn fingerprint_is_deterministic_and_input_sensitive() {
537            // Same source → same key (cache hit); any change → new key (forces
538            // a recompile). This is what prevents the car#64 stale-helper class.
539            assert_eq!(source_fingerprint("abc"), source_fingerprint("abc"));
540            assert_ne!(source_fingerprint("mode == create"), source_fingerprint("mode == delete"));
541            assert_eq!(source_fingerprint("abc").len(), 16);
542        }
543
544        #[test]
545        fn cache_key_carries_version_prefix_and_source_hash() {
546            let key = helper_cache_key();
547            assert!(key.starts_with(&format!("{HELPER_VERSION}-")), "key: {key}");
548            // version prefix + '-' + 16 hex chars
549            assert_eq!(key.len(), HELPER_VERSION.len() + 1 + 16);
550        }
551    }
552
553    fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
554        let dir = helper_cache_dir();
555        let key = helper_cache_key();
556        let app = dir.join(format!("CAR EventKit Helper {key}.app"));
557        let contents = app.join("Contents");
558        let macos = contents.join("MacOS");
559        let helper = macos.join("CAR EventKit Helper");
560        if helper.exists() {
561            return Ok(helper);
562        }
563
564        std::fs::create_dir_all(&macos)
565            .map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
566        let source = dir.join(format!("car-eventkit-helper-{key}.swift"));
567        let plist = contents.join("Info.plist");
568        let entitlements = dir.join(format!("car-eventkit-helper-{key}.entitlements"));
569        std::fs::write(&source, SCRIPT)
570            .map_err(|e| IntegrationError::Backend(format!("eventkit helper source: {e}")))?;
571        std::fs::write(
572            &plist,
573            r#"<?xml version="1.0" encoding="UTF-8"?>
574<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
575<plist version="1.0">
576<dict>
577  <key>CFBundleIdentifier</key>
578  <string>ai.parslee.car.eventkit-helper</string>
579  <key>CFBundleExecutable</key>
580  <string>CAR EventKit Helper</string>
581  <key>CFBundleName</key>
582  <string>CAR EventKit Helper</string>
583  <key>CFBundlePackageType</key>
584  <string>APPL</string>
585  <key>CFBundleShortVersionString</key>
586  <string>0.9.0</string>
587  <key>CFBundleVersion</key>
588  <string>1</string>
589  <key>NSCalendarsUsageDescription</key>
590  <string>CAR reads calendars when an agent uses the calendar capability.</string>
591  <key>NSCalendarsFullAccessUsageDescription</key>
592  <string>CAR reads calendars when an agent uses the calendar capability.</string>
593</dict>
594</plist>
595"#,
596        )
597        .map_err(|e| IntegrationError::Backend(format!("eventkit helper plist: {e}")))?;
598        std::fs::write(
599            &entitlements,
600            r#"<?xml version="1.0" encoding="UTF-8"?>
601<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
602<plist version="1.0">
603<dict>
604  <key>com.apple.security.personal-information.calendars</key>
605  <true/>
606</dict>
607</plist>
608"#,
609        )
610        .map_err(|e| IntegrationError::Backend(format!("eventkit helper entitlements: {e}")))?;
611
612        let status = Command::new("/usr/bin/swiftc")
613            .env(
614                "SWIFT_MODULE_CACHE_PATH",
615                std::env::temp_dir().join("car-swift-module-cache"),
616            )
617            .env(
618                "CLANG_MODULE_CACHE_PATH",
619                std::env::temp_dir().join("car-clang-module-cache"),
620            )
621            .arg(&source)
622            .arg("-o")
623            .arg(&helper)
624            .arg("-Xlinker")
625            .arg("-sectcreate")
626            .arg("-Xlinker")
627            .arg("__TEXT")
628            .arg("-Xlinker")
629            .arg("__info_plist")
630            .arg("-Xlinker")
631            .arg(&plist)
632            .status()
633            .map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
634
635        if !status.success() {
636            return Err(IntegrationError::Backend(format!(
637                "eventkit helper compile failed with status {status}"
638            )));
639        }
640
641        sign_helper(&app, &entitlements)?;
642        Ok(helper)
643    }
644
645    fn helper_cache_dir() -> std::path::PathBuf {
646        if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
647            return std::path::PathBuf::from(path);
648        }
649        if let Some(home) = std::env::var_os("HOME") {
650            return std::path::PathBuf::from(home)
651                .join("Library")
652                .join("Application Support")
653                .join("CAR")
654                .join("NativeHelpers");
655        }
656        std::env::temp_dir().join("car-native-helpers")
657    }
658
659    fn sign_helper(
660        app: &std::path::Path,
661        entitlements: &std::path::Path,
662    ) -> Result<(), IntegrationError> {
663        let status = Command::new("/usr/bin/codesign")
664            .arg("--force")
665            .arg("--sign")
666            .arg("-")
667            .arg("--entitlements")
668            .arg(entitlements)
669            .arg(app)
670            .status()
671            .map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
672        if !status.success() {
673            return Err(IntegrationError::Backend(format!(
674                "eventkit helper codesign failed with status {status}"
675            )));
676        }
677        Ok(())
678    }
679}
680
681#[cfg(not(target_os = "macos"))]
682mod backend {
683    use super::*;
684
685    pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
686        Ok(CalendarListing {
687            availability: current_backend_pending(),
688            calendars: vec![],
689        })
690    }
691
692    pub fn list_events(
693        _start: DateTime<Utc>,
694        _end: DateTime<Utc>,
695        _calendar_ids: &[String],
696    ) -> Result<EventListing, IntegrationError> {
697        Ok(EventListing {
698            availability: current_backend_pending(),
699            events: vec![],
700        })
701    }
702
703    pub fn create_event(_input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
704        Ok(EventMutationResult {
705            ok: false,
706            event: None,
707            reason: Some("calendar event creation is not yet implemented on this platform".into()),
708        })
709    }
710
711    pub fn update_event(_input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
712        Ok(EventMutationResult {
713            ok: false,
714            event: None,
715            reason: Some("calendar event updates are not yet implemented on this platform".into()),
716        })
717    }
718
719    pub fn delete_event(_event_id: &str) -> Result<EventMutationResult, IntegrationError> {
720        Ok(EventMutationResult {
721            ok: false,
722            event: None,
723            reason: Some("calendar event deletion is not yet implemented on this platform".into()),
724        })
725    }
726
727    fn current_backend_pending() -> Availability {
728        #[cfg(target_os = "windows")]
729        {
730            Availability::pending(
731                "msgraph",
732                "MS Graph / Outlook MAPI backends not yet wired. API shape \
733             is stable; downstream apps can code against it now.",
734            )
735        }
736        #[cfg(target_os = "linux")]
737        {
738            Availability::pending(
739                "eds",
740                "Evolution Data Server + CalDAV backends not yet wired. \
741             API shape is stable; downstream apps can code against it now.",
742            )
743        }
744        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
745        {
746            Availability::pending("none", "Unsupported OS — no calendar backend modeled.")
747        }
748    }
749}