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.
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user