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,290 @@
|
||||
import Foundation
|
||||
import EventKit
|
||||
|
||||
// MARK: - Reminders data models
|
||||
struct ReminderListItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let source: String
|
||||
let type: String // local/caldav/exchange/etc
|
||||
}
|
||||
|
||||
struct ReminderItem: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let list_id: String
|
||||
let list_title: String
|
||||
let title: String
|
||||
let notes: String?
|
||||
let completed: Bool
|
||||
let due: String? // ISO8601 or nil
|
||||
let priority: Int // 0-9 (EKReminderPriority mapped)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case list_id
|
||||
case list_title
|
||||
case title
|
||||
case notes
|
||||
case completed
|
||||
case due
|
||||
case priority
|
||||
}
|
||||
}
|
||||
|
||||
struct ReminderCreateResult: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let list_id: String
|
||||
let list_title: String
|
||||
let title: String
|
||||
}
|
||||
|
||||
enum RemindersProviderError: Error, Equatable, Sendable {
|
||||
case permissionRequired
|
||||
case permissionDenied
|
||||
case unavailable(String)
|
||||
case invalidRequest(String)
|
||||
|
||||
var isPermission: Bool {
|
||||
if case .permissionRequired = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider protocols
|
||||
protocol RemindersListsProviding: Sendable {
|
||||
func listReminderLists() throws -> [ReminderListItem]
|
||||
}
|
||||
|
||||
protocol RemindersListProviding: Sendable {
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem]
|
||||
}
|
||||
|
||||
protocol RemindersCreateProviding: Sendable {
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult
|
||||
}
|
||||
|
||||
protocol FullRemindersProviding: RemindersListsProviding, RemindersListProviding, RemindersCreateProviding {}
|
||||
|
||||
// MARK: - Production EventKit provider (scoped read non-prompt; create requires explicit list)
|
||||
struct EventKitRemindersProvider: RemindersListsProviding, RemindersListProviding, RemindersCreateProviding {
|
||||
|
||||
// Shared auth check – only checks status, never prompts
|
||||
private func requireAuthorizedForRead() throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch status {
|
||||
case .fullAccess, .authorized:
|
||||
return
|
||||
case .writeOnly:
|
||||
// writeOnly does not permit listing reminders per EventKit; treat as permission_required for list operations
|
||||
throw RemindersProviderError.permissionRequired
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
private func requireAuthorizedForCreate() throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch status {
|
||||
case .fullAccess, .authorized, .writeOnly:
|
||||
return
|
||||
case .denied, .restricted, .notDetermined:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
@unknown default:
|
||||
throw RemindersProviderError.permissionRequired
|
||||
}
|
||||
}
|
||||
|
||||
private func allReminderCalendars(store: EKEventStore) -> [EKCalendar] {
|
||||
store.calendars(for: .reminder)
|
||||
}
|
||||
|
||||
private func resolveForList(store: EKEventStore, listId: String?, listTitle: String?) throws -> [EKCalendar] {
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
if let lid = listId, !lid.isEmpty {
|
||||
if let found = calendars.first(where: { $0.calendarIdentifier == lid }) {
|
||||
return [found]
|
||||
} else {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(lid)")
|
||||
}
|
||||
}
|
||||
if let title = listTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw RemindersProviderError.invalidRequest("Ambiguous reminders list title: \(title) matches \(matched.count) lists")
|
||||
}
|
||||
return matched
|
||||
}
|
||||
return calendars
|
||||
}
|
||||
|
||||
private func resolveForCreate(store: EKEventStore, listId: String?, listTitle: String?) throws -> EKCalendar {
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
if let lid = listId, !lid.isEmpty {
|
||||
guard let found = calendars.first(where: { $0.calendarIdentifier == lid }) else {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(lid)")
|
||||
}
|
||||
if !found.allowsContentModifications {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list is read-only: \(found.title)")
|
||||
}
|
||||
return found
|
||||
}
|
||||
if let title = listTitle, !title.isEmpty {
|
||||
let matched = calendars.filter { $0.title == title }
|
||||
if matched.isEmpty {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list not found: \(title)")
|
||||
}
|
||||
if matched.count > 1 {
|
||||
throw RemindersProviderError.invalidRequest("Ambiguous reminders list title: \(title) matches \(matched.count) lists")
|
||||
}
|
||||
let found = matched[0]
|
||||
if !found.allowsContentModifications {
|
||||
throw RemindersProviderError.invalidRequest("Reminders list is read-only: \(found.title)")
|
||||
}
|
||||
return found
|
||||
}
|
||||
throw RemindersProviderError.invalidRequest("Reminders list must be specified by id or exact unique title")
|
||||
}
|
||||
|
||||
// MARK: - lists
|
||||
func listReminderLists() throws -> [ReminderListItem] {
|
||||
try requireAuthorizedForRead()
|
||||
let store = EKEventStore()
|
||||
let calendars = allReminderCalendars(store: store)
|
||||
return calendars.map { cal in
|
||||
let sourceTitle = cal.source.title
|
||||
let typeString: String
|
||||
switch cal.type {
|
||||
case .local: typeString = "local"
|
||||
case .calDAV: typeString = "caldav"
|
||||
case .exchange: typeString = "exchange"
|
||||
case .subscription: typeString = "subscription"
|
||||
case .birthday: typeString = "birthday"
|
||||
@unknown default: typeString = "unknown"
|
||||
}
|
||||
return ReminderListItem(id: cal.calendarIdentifier, title: cal.title, source: sourceTitle, type: typeString)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - list reminders
|
||||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] {
|
||||
try requireAuthorizedForRead()
|
||||
let store = EKEventStore()
|
||||
let targetCalendars: [EKCalendar]
|
||||
do {
|
||||
targetCalendars = try resolveForList(store: store, listId: listId, listTitle: listTitle)
|
||||
} catch let e as RemindersProviderError {
|
||||
throw e
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Reminders lookup failed")
|
||||
}
|
||||
if targetCalendars.isEmpty { return [] }
|
||||
|
||||
let predicate = store.predicateForReminders(in: targetCalendars)
|
||||
var fetched: [EKReminder] = []
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var fetchError: Error? = nil
|
||||
store.fetchReminders(matching: predicate) { rems in
|
||||
fetched = rems ?? []
|
||||
sem.signal()
|
||||
}
|
||||
// fetchReminders is async on newer APIs? In EventKit even on macOS 13 fetchReminders matching is async via completion.
|
||||
// Wait bounded 10s
|
||||
let waitRes = sem.wait(timeout: .now() + 10)
|
||||
if waitRes == .timedOut {
|
||||
throw RemindersProviderError.unavailable("Reminders fetch timed out")
|
||||
}
|
||||
if let err = fetchError {
|
||||
throw RemindersProviderError.unavailable(err.localizedDescription)
|
||||
}
|
||||
|
||||
var items: [ReminderItem] = []
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
|
||||
for rem in fetched {
|
||||
let isCompleted = rem.isCompleted
|
||||
if let filterCompleted = completed, filterCompleted != isCompleted { continue }
|
||||
guard let cal = rem.calendar else { continue }
|
||||
let dueStr: String?
|
||||
if let comps = rem.dueDateComponents, let d = Calendar.current.date(from: comps) {
|
||||
dueStr = iso.string(from: d)
|
||||
} else {
|
||||
dueStr = nil
|
||||
}
|
||||
let item = ReminderItem(
|
||||
id: rem.calendarItemIdentifier,
|
||||
list_id: cal.calendarIdentifier,
|
||||
list_title: cal.title,
|
||||
title: rem.title ?? "",
|
||||
notes: rem.notes,
|
||||
completed: isCompleted,
|
||||
due: dueStr,
|
||||
priority: rem.priority
|
||||
)
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
// Deterministic sort: due, title, id
|
||||
items.sort {
|
||||
let due0 = $0.due ?? ""
|
||||
let due1 = $1.due ?? ""
|
||||
if due0 != due1 { return due0 < due1 }
|
||||
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: - create
|
||||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
|
||||
try requireAuthorizedForCreate()
|
||||
guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw RemindersProviderError.invalidRequest("Missing required argument: title")
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let destination: EKCalendar
|
||||
do {
|
||||
destination = try resolveForCreate(store: store, listId: listId, listTitle: listTitle)
|
||||
} catch let e as RemindersProviderError {
|
||||
throw e
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Reminders list lookup failed")
|
||||
}
|
||||
|
||||
let rem = EKReminder(eventStore: store)
|
||||
rem.title = title
|
||||
rem.calendar = destination
|
||||
rem.notes = notes
|
||||
if let p = priority {
|
||||
rem.priority = p
|
||||
}
|
||||
if let dueDate = due {
|
||||
let comps = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: dueDate)
|
||||
rem.dueDateComponents = comps
|
||||
}
|
||||
|
||||
do {
|
||||
try store.save(rem, commit: true)
|
||||
} catch {
|
||||
throw RemindersProviderError.unavailable("Failed to save reminder: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
return ReminderCreateResult(
|
||||
id: rem.calendarItemIdentifier,
|
||||
list_id: destination.calendarIdentifier,
|
||||
list_title: destination.title,
|
||||
title: rem.title ?? title
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user