Files
oai-swift/oAI/Services/EventKitService.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

592 lines
25 KiB
Swift

//
// EventKitService.swift
// oAI
//
// Calendar and Reminders integration via EventKit
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import EventKit
import Foundation
import os
/// Shared tri-state authorization status for the Settings UI across all Personal Data services.
/// `.denied` also covers `.restricted` and (for Calendar) `.writeOnly` — states where the OS
/// will not show a request dialog again; the user must go to System Settings manually.
enum PersonalDataAccessState {
case notDetermined
case denied
case granted
}
@Observable
class EventKitService {
static let shared = EventKitService()
private let store = EKEventStore()
private init() {}
// MARK: - Authorization
var calendarAuthStatus: EKAuthorizationStatus {
let status = EKEventStore.authorizationStatus(for: .event)
Log.mcp.debug("EventKitService.calendarAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
return status
}
var reminderAuthStatus: EKAuthorizationStatus {
let status = EKEventStore.authorizationStatus(for: .reminder)
Log.mcp.debug("EventKitService.reminderAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
return status
}
var calendarAuthorized: Bool {
calendarAuthStatus == .fullAccess
}
var reminderAuthorized: Bool {
reminderAuthStatus == .fullAccess
}
var calendarAccessState: PersonalDataAccessState {
switch calendarAuthStatus {
case .fullAccess: return .granted
case .notDetermined: return .notDetermined
default: return .denied // .denied, .restricted, .writeOnly (no read access for our tools)
}
}
var reminderAccessState: PersonalDataAccessState {
switch reminderAuthStatus {
case .fullAccess: return .granted
case .notDetermined: return .notDetermined
default: return .denied
}
}
@discardableResult
func requestCalendarAccess() async -> Bool {
let before = EKEventStore.authorizationStatus(for: .event)
Log.mcp.info("requestCalendarAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
do {
let granted = try await store.requestFullAccessToEvents()
let after = EKEventStore.authorizationStatus(for: .event)
Log.mcp.info("requestCalendarAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
return granted
} catch {
let after = EKEventStore.authorizationStatus(for: .event)
Log.mcp.error("requestCalendarAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
return false
}
}
@discardableResult
func requestReminderAccess() async -> Bool {
let before = EKEventStore.authorizationStatus(for: .reminder)
Log.mcp.info("requestReminderAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
do {
let granted = try await store.requestFullAccessToReminders()
let after = EKEventStore.authorizationStatus(for: .reminder)
Log.mcp.info("requestReminderAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
return granted
} catch {
let after = EKEventStore.authorizationStatus(for: .reminder)
Log.mcp.error("requestReminderAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
return false
}
}
nonisolated static func describe(_ status: EKAuthorizationStatus) -> String {
switch status {
case .notDetermined: return "notDetermined"
case .restricted: return "restricted"
case .denied: return "denied"
case .fullAccess: return "fullAccess"
case .writeOnly: return "writeOnly"
@unknown default: return "unknown"
}
}
// MARK: - Tool Schemas
func getToolSchemas(calendarEnabled: Bool, remindersEnabled: Bool) -> [Tool] {
var tools: [Tool] = []
if calendarEnabled {
tools.append(makeTool(
name: "calendar_list_calendars",
description: "List all calendars available on this Mac (e.g. iCloud, Work, Home).",
properties: [:],
required: []
))
tools.append(makeTool(
name: "calendar_list_events",
description: "List calendar events within a date range. Dates are ISO8601 (e.g. '2026-06-20T00:00:00' or '2026-06-20'). Range is limited to 1 year. For open-ended queries like 'next appointment' or 'upcoming events', do NOT limit the range to just today — use a generous forward-looking window (e.g. today through +90 days) so you don't miss events further out.",
properties: [
"start_date": prop("string", "Start of the date range (ISO8601)"),
"end_date": prop("string", "End of the date range (ISO8601)"),
"calendar_name": prop("string", "Optional: only list events from this calendar")
],
required: ["start_date", "end_date"]
))
tools.append(makeTool(
name: "calendar_create_event",
description: "Create a new calendar event. Requires user approval before it is actually created.",
properties: [
"title": prop("string", "Event title"),
"start_date": prop("string", "Start date/time (ISO8601, e.g. '2026-06-20T14:00:00')"),
"end_date": prop("string", "End date/time (ISO8601)"),
"calendar_name": prop("string", "Optional: calendar to add the event to (defaults to the system default calendar)"),
"location": prop("string", "Optional: event location text"),
"notes": prop("string", "Optional: event notes"),
"all_day": prop("boolean", "Optional: whether this is an all-day event (default: false)"),
"alarm_minutes_before": prop("number", "Optional: minutes before the start time to show an alert")
],
required: ["title", "start_date", "end_date"]
))
}
if remindersEnabled {
tools.append(makeTool(
name: "reminders_list_lists",
description: "List all reminder lists available on this Mac.",
properties: [:],
required: []
))
tools.append(makeTool(
name: "reminders_list",
description: "List reminders. Omit list_name to search across ALL reminder lists in a single call — prefer this over calling once per list. Incomplete reminders only unless include_completed is true.",
properties: [
"list_name": prop("string", "Optional: only list reminders from this one list (omit to search all lists at once)"),
"include_completed": prop("boolean", "Optional: include completed reminders (default: false)")
],
required: []
))
tools.append(makeTool(
name: "reminders_create",
description: "Create a new reminder. Requires user approval before it is actually created.",
properties: [
"title": prop("string", "Reminder title"),
"list_name": prop("string", "Optional: reminder list to add to (defaults to the system default list)"),
"due_date": prop("string", "Optional: due date/time (ISO8601)"),
"priority": prop("string", "Optional: priority level", enumValues: ["none", "low", "medium", "high"]),
"notes": prop("string", "Optional: reminder notes")
],
required: ["title"]
))
tools.append(makeTool(
name: "reminders_complete",
description: "Mark a reminder as completed. Requires user approval. Use reminders_list to find the reminder_id first.",
properties: [
"reminder_id": prop("string", "The reminder's identifier, from reminders_list")
],
required: ["reminder_id"]
))
}
return tools
}
// MARK: - Read Tool Execution
func executeTool(name: String, arguments: String) async -> [String: Any] {
Log.mcp.info("Executing EventKit tool: \(name)")
let args = Self.parseArgs(arguments)
switch name {
case "calendar_list_calendars":
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
return listCalendars()
case "calendar_list_events":
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
return ["error": "Missing or invalid parameter: start_date"]
}
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
return ["error": "Missing or invalid parameter: end_date"]
}
let calendarName = args["calendar_name"] as? String
return listEvents(start: start, end: end, calendarName: calendarName)
case "reminders_list_lists":
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
return listReminderLists()
case "reminders_list":
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
let listName = args["list_name"] as? String
let includeCompleted = args["include_completed"] as? Bool ?? false
return await listReminders(listName: listName, includeCompleted: includeCompleted)
default:
return ["error": "Unknown EventKit tool: \(name)"]
}
}
// MARK: - Write Tool Execution (called only after approval)
func executeWriteTool(name: String, arguments: String) async -> [String: Any] {
Log.mcp.info("Executing EventKit write tool: \(name)")
let args = Self.parseArgs(arguments)
switch name {
case "calendar_create_event":
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
return createEvent(args: args)
case "reminders_create":
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
return createReminder(args: args)
case "reminders_complete":
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
guard let reminderId = args["reminder_id"] as? String else {
return ["error": "Missing required parameter: reminder_id"]
}
return completeReminder(reminderId: reminderId)
default:
return ["error": "Unknown EventKit write tool: \(name)"]
}
}
// MARK: - Approval Summary
/// Human-readable description shown in the approval sheet before a write tool runs.
func approvalSummary(forTool name: String, arguments: String) -> String {
let args = Self.parseArgs(arguments)
switch name {
case "calendar_create_event":
let title = args["title"] as? String ?? "Untitled event"
let start = (args["start_date"] as? String).flatMap(Self.parseDate) ?? Date()
let end = (args["end_date"] as? String).flatMap(Self.parseDate) ?? start
return "Create calendar event \"\(title)\" from \(Self.displayFormatter.string(from: start)) to \(Self.displayFormatter.string(from: end))"
case "reminders_create":
let title = args["title"] as? String ?? "Untitled reminder"
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
return "Create reminder \"\(title)\" due \(Self.displayFormatter.string(from: due))"
}
return "Create reminder \"\(title)\""
case "reminders_complete":
return "Mark reminder as completed"
default:
return "Perform action: \(name)"
}
}
// MARK: - Calendar Read Implementations
private func listCalendars() -> [String: Any] {
let calendars = store.calendars(for: .event).map { cal -> [String: Any] in
[
"name": cal.title,
"type": calendarTypeDescription(cal),
"allows_modifications": cal.allowsContentModifications
]
}
return ["calendars": calendars]
}
private func listEvents(start: Date, end: Date, calendarName: String?) -> [String: Any] {
guard end > start else { return ["error": "end_date must be after start_date"] }
guard end.timeIntervalSince(start) <= 366 * 24 * 60 * 60 else {
return ["error": "Date range too large — limit to 1 year or less"]
}
var calendars = store.calendars(for: .event)
if let calendarName {
calendars = calendars.filter { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }
if calendars.isEmpty {
return ["error": "No calendar found named '\(calendarName)'"]
}
}
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
let events = store.events(matching: predicate)
.sorted { $0.startDate < $1.startDate }
.prefix(200)
.map { event -> [String: Any] in
var item: [String: Any] = [
"id": event.eventIdentifier ?? "",
"title": event.title ?? "Untitled",
"start": Self.isoFormatter.string(from: event.startDate),
"end": Self.isoFormatter.string(from: event.endDate),
"all_day": event.isAllDay,
"calendar": event.calendar?.title ?? ""
]
if let location = event.location, !location.isEmpty {
item["location"] = location
}
if let notes = event.notes, !notes.isEmpty {
item["notes"] = String(notes.prefix(500))
}
return item
}
return ["count": events.count, "events": Array(events)]
}
private func createEvent(args: [String: Any]) -> [String: Any] {
guard let title = args["title"] as? String, !title.isEmpty else {
return ["error": "Missing required parameter: title"]
}
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
return ["error": "Missing or invalid parameter: start_date"]
}
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
return ["error": "Missing or invalid parameter: end_date"]
}
guard end >= start else {
return ["error": "end_date must not be before start_date"]
}
let event = EKEvent(eventStore: store)
event.title = title
event.startDate = start
event.endDate = end
event.isAllDay = args["all_day"] as? Bool ?? false
if let calendarName = args["calendar_name"] as? String,
let calendar = store.calendars(for: .event).first(where: { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }) {
event.calendar = calendar
} else if let defaultCalendar = store.defaultCalendarForNewEvents {
event.calendar = defaultCalendar
} else {
guard let fallback = store.calendars(for: .event).first(where: { $0.allowsContentModifications }) else {
return ["error": "No writable calendar available"]
}
event.calendar = fallback
}
if let location = args["location"] as? String { event.location = location }
if let notes = args["notes"] as? String { event.notes = notes }
if let minutesBefore = (args["alarm_minutes_before"] as? Double) ?? (args["alarm_minutes_before"] as? Int).map(Double.init) {
event.addAlarm(EKAlarm(relativeOffset: -(minutesBefore * 60)))
}
do {
try store.save(event, span: .thisEvent, commit: true)
return ["success": true, "event_id": event.eventIdentifier ?? "", "calendar": event.calendar?.title ?? ""]
} catch {
Log.mcp.error("calendar_create_event failed: \(error.localizedDescription)")
return ["error": "Failed to create event: \(error.localizedDescription)"]
}
}
// MARK: - Reminders Read Implementations
private func listReminderLists() -> [String: Any] {
let lists = store.calendars(for: .reminder).map { cal -> [String: Any] in
["name": cal.title, "allows_modifications": cal.allowsContentModifications]
}
return ["lists": lists]
}
private func listReminders(listName: String?, includeCompleted: Bool) async -> [String: Any] {
var lists = store.calendars(for: .reminder)
if let listName {
lists = lists.filter { $0.title.caseInsensitiveCompare(listName) == .orderedSame }
if lists.isEmpty {
return ["error": "No reminder list found named '\(listName)'"]
}
}
let predicate = store.predicateForReminders(in: lists)
let reminders: [EKReminder] = await withCheckedContinuation { continuation in
store.fetchReminders(matching: predicate) { results in
continuation.resume(returning: results ?? [])
}
}
let filtered = reminders
.filter { includeCompleted || !$0.isCompleted }
.sorted { lhs, rhs in
let l = lhs.dueDateComponents?.date ?? .distantFuture
let r = rhs.dueDateComponents?.date ?? .distantFuture
return l < r
}
.prefix(200)
.map { reminder -> [String: Any] in
var item: [String: Any] = [
"id": reminder.calendarItemIdentifier,
"title": reminder.title ?? "Untitled",
"completed": reminder.isCompleted,
"list": reminder.calendar?.title ?? ""
]
if let due = reminder.dueDateComponents?.date {
item["due"] = Self.isoFormatter.string(from: due)
}
if reminder.priority > 0 {
item["priority"] = priorityDescription(reminder.priority)
}
if let notes = reminder.notes, !notes.isEmpty {
item["notes"] = String(notes.prefix(500))
}
return item
}
return ["count": filtered.count, "reminders": Array(filtered)]
}
private func createReminder(args: [String: Any]) -> [String: Any] {
guard let title = args["title"] as? String, !title.isEmpty else {
return ["error": "Missing required parameter: title"]
}
let reminder = EKReminder(eventStore: store)
reminder.title = title
if let listName = args["list_name"] as? String,
let list = store.calendars(for: .reminder).first(where: { $0.title.caseInsensitiveCompare(listName) == .orderedSame }) {
reminder.calendar = list
} else if let defaultList = store.defaultCalendarForNewReminders() {
reminder.calendar = defaultList
} else {
guard let fallback = store.calendars(for: .reminder).first(where: { $0.allowsContentModifications }) else {
return ["error": "No writable reminder list available"]
}
reminder.calendar = fallback
}
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: due)
}
if let notes = args["notes"] as? String { reminder.notes = notes }
if let priority = args["priority"] as? String { reminder.priority = priorityValue(priority) }
do {
try store.save(reminder, commit: true)
return ["success": true, "reminder_id": reminder.calendarItemIdentifier, "list": reminder.calendar?.title ?? ""]
} catch {
Log.mcp.error("reminders_create failed: \(error.localizedDescription)")
return ["error": "Failed to create reminder: \(error.localizedDescription)"]
}
}
private func completeReminder(reminderId: String) -> [String: Any] {
guard let item = store.calendarItem(withIdentifier: reminderId) as? EKReminder else {
return ["error": "No reminder found with id '\(reminderId)'"]
}
item.isCompleted = true
item.completionDate = Date()
do {
try store.save(item, commit: true)
return ["success": true, "reminder_id": reminderId, "title": item.title ?? ""]
} catch {
Log.mcp.error("reminders_complete failed: \(error.localizedDescription)")
return ["error": "Failed to complete reminder: \(error.localizedDescription)"]
}
}
// MARK: - Helpers
private func calendarTypeDescription(_ cal: EKCalendar) -> String {
switch cal.type {
case .local: return "local"
case .calDAV: return "caldav"
case .exchange: return "exchange"
case .subscription: return "subscription"
case .birthday: return "birthday"
@unknown default: return "unknown"
}
}
private func priorityDescription(_ value: Int) -> String {
switch value {
case 1...4: return "high"
case 5: return "medium"
case 6...9: return "low"
default: return "none"
}
}
private func priorityValue(_ description: String) -> Int {
switch description.lowercased() {
case "high": return 1
case "medium": return 5
case "low": return 9
default: return 0
}
}
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
Tool(
type: "function",
function: Tool.Function(
name: name,
description: description,
parameters: Tool.Function.Parameters(
type: "object",
properties: properties,
required: required
)
)
)
}
private func prop(_ type: String, _ description: String, enumValues: [String]? = nil) -> Tool.Function.Parameters.Property {
Tool.Function.Parameters.Property(type: type, description: description, enum: enumValues)
}
nonisolated static func parseArgs(_ arguments: String) -> [String: Any] {
guard let data = arguments.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return [:]
}
return dict
}
nonisolated static func permissionError(domain: String) -> [String: Any] {
["error": "\(domain) permission not granted. Grant access in Settings > MCP."]
}
nonisolated(unsafe) static let isoFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
nonisolated static let displayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
nonisolated static func parseDate(_ string: String) -> Date? {
if let date = isoFormatter.date(from: string) { return date }
let isoNoTimezone = ISO8601DateFormatter()
isoNoTimezone.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = isoNoTimezone.date(from: string) { return date }
let localDateTime = DateFormatter()
localDateTime.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
if let date = localDateTime.date(from: string) { return date }
let dateOnly = DateFormatter()
dateOnly.dateFormat = "yyyy-MM-dd"
if let date = dateOnly.date(from: string) { return date }
return nil
}
}