9fd04b0ce4
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.
291 lines
11 KiB
Swift
291 lines
11 KiB
Swift
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
|
||
)
|
||
}
|
||
}
|