Files
reyna-cli/native/ReynaCLIHost/Sources/ReynaCLIHostCore/RemindersProvider.swift
T
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

291 lines
11 KiB
Swift
Raw 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
// 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
)
}
}