Files
oai-swift/oAI/Services/EventKitService.swift
T
rune 40c5f25517 Add personal data tools, 2nd Brain trust toggle, and research agents
Personal Data Tools: native Calendar, Reminders, Contacts (hidden pending
Apple TCC fix in beta), and Location & Maps access via EventKit, Contacts
framework, and MapKit. Write actions (create event/reminder, complete
reminder) gate through an approval sheet. Four hardened-runtime entitlements
added to oAI.entitlements; Info.plist usage strings added for all services.
Personal Data section shows a β badge while Contacts is hidden.

2nd Brain always-trust: inline toggle on the Agent Skills row for the skill
named "2nd Brain" skips the bash approval dialog when the command contains
.brain_helper.py, gated by three runtime checks in MCPService.

Research agents: spawn_research_agents tool runs up to 5 concurrent read-only
sub-agents (read_file, list_directory, search_files, web_search — no write,
no bash, no nesting). Bounded by maxConcurrentAgents setting (default 3) and
a hard ceiling of 8 tasks. Added items field to Tool.Function.Parameters.Property
for JSON Schema array support; wired into AnthropicProvider.convertParametersToDict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:04:47 +02:00

594 lines
25 KiB
Swift

//
// EventKitService.swift
// oAI
//
// Calendar and Reminders integration via EventKit
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
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
}
}