Files
Adolfo Reyna 9fd04b0ce4 feat(privacy-host): add signed native calendar contacts and reminders host
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
2026-08-03 20:27:54 -04:00

289 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import EventKit
// Production EventKit provider – read-only for list, mutating only for create.
// Requirements:
// - Only checks authorization status (never triggers prompt) for list/create.
// - Lists calendars when status permits.
// - Never calls prompting APIs except in auth provider.
// - If permission absent/denied/restricted, throw permissionRequired.
struct EventKitCalendarProvider: CalendarListProviding, CalendarEventsListProviding, CalendarEventCreateProviding {
// MARK: - Auth check shared
private func requireAuthorizedOrThrow() throws {
let status = EKEventStore.authorizationStatus(for: .event)
switch status {
case .fullAccess, .authorized:
break
case .writeOnly:
// writeOnly does not allow reading calendars/events; but for create we could allow? Task says already-authorized access but no permission request; map denied/not-determined to permission_required for all.
// Simpler: for events list, writeOnly -> permissionRequired; for create, writeOnly should also require check but writeOnly actually allows writing. However to keep deterministic, attempt to respect writeOnly for create?
// Spec: map denied/not-determined to permission_required, provider failures to calendar_unavailable.
// To be safe: for list -> permissionRequired, for create we will check below differently? But shared throw would block create with writeOnly unnecessarily.
// We differentiate inside methods. For this helper, allow writeOnly as authorized for mutating path? Caller should call specific check.
throw CalendarProviderError.permissionRequired
case .denied, .restricted, .notDetermined:
throw CalendarProviderError.permissionRequired
@unknown default:
throw CalendarProviderError.permissionRequired
}
}
private func requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: Bool = false) throws {
let status = EKEventStore.authorizationStatus(for: .event)
switch status {
case .fullAccess, .authorized:
return
case .writeOnly:
if allowsWriteOnlyRead {
return
}
// For read paths (list calendars, list events), writeOnly does NOT permit read -> permission_required
throw CalendarProviderError.permissionRequired
case .denied, .restricted, .notDetermined:
throw CalendarProviderError.permissionRequired
@unknown default:
throw CalendarProviderError.permissionRequired
}
}
// MARK: - Calendar list (existing)
func listCalendars() throws -> [CalendarListItem] {
try requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: false)
let store = EKEventStore()
let ekCalendars = store.calendars(for: .event)
let items: [CalendarListItem] = ekCalendars.map { cal in
let sourceTitle = cal.source.title
let typeString: String
switch cal.type {
case .birthday:
typeString = "birthday"
case .calDAV:
typeString = "caldav"
case .exchange:
typeString = "exchange"
case .local:
typeString = "local"
case .subscription:
typeString = "subscription"
@unknown default:
typeString = "unknown"
}
return CalendarListItem(
id: cal.calendarIdentifier,
title: cal.title,
source: sourceTitle,
type: typeString
)
}
return items
}
// MARK: - Calendar helpers for events
private func allCalendars(from store: EKEventStore) -> [EKCalendar] {
store.calendars(for: .event)
}
private func resolveCalendarForList(store: EKEventStore, calendarId: String?, calendarTitle: String?) throws -> [EKCalendar] {
// Return array of matching calendars (filtered)
// Rules:
// - if calendarId provided (stable ID), exact match only one; if unknown -> invalidRequest
// - else if calendarTitle provided, exact title match; must be unique else invalidRequest (ambiguous) or unknown => invalidRequest
// - else all calendars
let calendars = allCalendars(from: store)
if let cid = calendarId, !cid.isEmpty {
// ID wins
if let found = calendars.first(where: { $0.calendarIdentifier == cid }) {
return [found]
} else {
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
}
}
if let title = calendarTitle, !title.isEmpty {
let matched = calendars.filter { $0.title == title }
if matched.isEmpty {
throw CalendarProviderError.invalidRequest("Calendar not found: \(title)")
}
if matched.count > 1 {
throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars")
}
return matched
}
// no filter -> all
return calendars
}
private func resolveCalendarForCreate(store: EKEventStore, calendarId: String?, calendarTitle: String?) throws -> EKCalendar {
// Create must not default to arbitrary.
// If neither id nor title -> invalidRequest
let calendars = allCalendars(from: store)
if let cid = calendarId, !cid.isEmpty {
guard let found = calendars.first(where: { $0.calendarIdentifier == cid }) else {
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
}
if !found.allowsContentModifications {
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(found.title)")
}
// also check writable via allowsContentModifications; isImmutable also relevant but we use allowsContentModifications
return found
}
if let title = calendarTitle, !title.isEmpty {
let matched = calendars.filter { $0.title == title }
if matched.isEmpty {
throw CalendarProviderError.invalidRequest("Calendar not found: \(title)")
}
if matched.count > 1 {
throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars")
}
let found = matched[0]
if !found.allowsContentModifications {
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(found.title)")
}
return found
}
// No calendar specified – per spec "no default first arbitrary calendar"
throw CalendarProviderError.invalidRequest("Calendar must be specified by id or exact unique title")
}
// MARK: - Events list
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
// Permission: read requires full access
try requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: false)
let store = EKEventStore()
let targetCalendars: [EKCalendar]
do {
targetCalendars = try resolveCalendarForList(store: store, calendarId: calendarId, calendarTitle: calendarTitle)
} catch let err as CalendarProviderError {
throw err
} catch {
throw CalendarProviderError.unavailable("Calendar lookup failed")
}
if targetCalendars.isEmpty {
return []
}
// EK predicate
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: targetCalendars)
let ekEvents: [EKEvent] = store.events(matching: predicate)
// Map and sort deterministically, enforce overlap check (predicate already does but ensure)
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
var items: [CalendarEventItem] = []
items.reserveCapacity(min(ekEvents.count, limit))
for ev in ekEvents {
guard let evStart = ev.startDate, let evEnd = ev.endDate else { continue }
// Enforce overlap (EventKit predicate should already overlap but safe)
if evEnd < start || evStart > end { continue }
guard let cal = ev.calendar else { continue }
let item = CalendarEventItem(
id: ev.eventIdentifier ?? ev.calendarItemIdentifier,
title: ev.title ?? "",
start: iso.string(from: evStart),
end: iso.string(from: evEnd),
all_day: ev.isAllDay,
calendar_id: cal.calendarIdentifier,
calendar_title: cal.title,
notes: ev.notes,
location: ev.location
)
items.append(item)
}
// Deterministic sort by start, then title, then id, then truncate to limit
items.sort {
if $0.start != $1.start { return $0.start < $1.start }
if $0.title != $1.title { return $0.title < $1.title }
return $0.id < $1.id
}
if items.count > limit {
return Array(items.prefix(limit))
}
return items
}
// MARK: - Event create
func createEvent(
title: String,
start: Date,
end: Date,
allDay: Bool,
notes: String?,
location: String?,
calendarId: String?,
calendarTitle: String?
) throws -> CalendarEventItem {
// For create, we allow fullAccess, authorized, and writeOnly (since writeOnly permits creation)
let status = EKEventStore.authorizationStatus(for: .event)
switch status {
case .fullAccess, .authorized, .writeOnly:
break
case .denied, .restricted, .notDetermined:
throw CalendarProviderError.permissionRequired
@unknown default:
throw CalendarProviderError.permissionRequired
}
let store = EKEventStore()
let destination: EKCalendar
do {
destination = try resolveCalendarForCreate(store: store, calendarId: calendarId, calendarTitle: calendarTitle)
} catch let err as CalendarProviderError {
throw err
} catch {
throw CalendarProviderError.unavailable("Calendar lookup failed")
}
// Validate start < end already done in dispatch, but double check
if start >= end {
throw CalendarProviderError.invalidRequest("start must occur before end")
}
let ekEvent = EKEvent(eventStore: store)
ekEvent.title = title
ekEvent.startDate = start
ekEvent.endDate = end
ekEvent.isAllDay = allDay
ekEvent.notes = notes
ekEvent.location = location
ekEvent.calendar = destination
do {
try store.save(ekEvent, span: .thisEvent, commit: true)
} catch {
throw CalendarProviderError.unavailable("Failed to save event: \(error.localizedDescription)")
}
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
return CalendarEventItem(
id: ekEvent.eventIdentifier ?? ekEvent.calendarItemIdentifier,
title: ekEvent.title ?? title,
start: iso.string(from: ekEvent.startDate ?? start),
end: iso.string(from: ekEvent.endDate ?? end),
all_day: ekEvent.isAllDay,
calendar_id: destination.calendarIdentifier,
calendar_title: destination.title,
notes: ekEvent.notes,
location: ekEvent.location
)
}
}