40c5f25517
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>
344 lines
14 KiB
Swift
344 lines
14 KiB
Swift
//
|
|
// LocationMapsService.swift
|
|
// oAI
|
|
//
|
|
// Read-only Location and Maps integration via CoreLocation and MapKit
|
|
//
|
|
// 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 CoreLocation
|
|
import Foundation
|
|
import MapKit
|
|
import os
|
|
|
|
@Observable
|
|
class LocationMapsService: NSObject, CLLocationManagerDelegate {
|
|
static let shared = LocationMapsService()
|
|
|
|
private let locationManager = CLLocationManager()
|
|
|
|
private var authContinuation: CheckedContinuation<Bool, Never>?
|
|
private var locationContinuation: CheckedContinuation<CLLocation?, Never>?
|
|
|
|
private override init() {
|
|
super.init()
|
|
locationManager.delegate = self
|
|
}
|
|
|
|
// MARK: - Authorization
|
|
|
|
var authStatus: CLAuthorizationStatus {
|
|
locationManager.authorizationStatus
|
|
}
|
|
|
|
var authorized: Bool {
|
|
authStatus == .authorizedAlways || authStatus == .authorized
|
|
}
|
|
|
|
var accessState: PersonalDataAccessState {
|
|
let status = authStatus
|
|
Log.mcp.debug("LocationMapsService.accessState -> status=\(Self.describe(status)) (raw=\(status.rawValue))")
|
|
switch status {
|
|
case .authorizedAlways, .authorized: return .granted
|
|
case .notDetermined: return .notDetermined
|
|
default: return .denied
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func requestAccess() async -> Bool {
|
|
let before = locationManager.authorizationStatus
|
|
Log.mcp.info("LocationMapsService.requestAccess: status before = \(Self.describe(before)) (raw=\(before.rawValue))")
|
|
if before != .notDetermined {
|
|
Log.mcp.info("LocationMapsService.requestAccess: skipping OS prompt (not notDetermined)")
|
|
return authorized
|
|
}
|
|
return await withCheckedContinuation { continuation in
|
|
self.authContinuation = continuation
|
|
locationManager.requestWhenInUseAuthorization()
|
|
}
|
|
}
|
|
|
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
let status = manager.authorizationStatus
|
|
Log.mcp.info("LocationMapsService: authorization changed -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
|
authContinuation?.resume(returning: authorized)
|
|
authContinuation = nil
|
|
}
|
|
|
|
nonisolated static func describe(_ status: CLAuthorizationStatus) -> String {
|
|
switch status {
|
|
case .notDetermined: return "notDetermined"
|
|
case .restricted: return "restricted"
|
|
case .denied: return "denied"
|
|
case .authorizedAlways: return "authorizedAlways"
|
|
case .authorized: return "authorized"
|
|
@unknown default: return "unknown"
|
|
}
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
locationContinuation?.resume(returning: locations.last)
|
|
locationContinuation = nil
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
Log.mcp.error("Location request failed: \(error.localizedDescription)")
|
|
locationContinuation?.resume(returning: nil)
|
|
locationContinuation = nil
|
|
}
|
|
|
|
private func currentLocation() async -> CLLocation? {
|
|
await withCheckedContinuation { continuation in
|
|
self.locationContinuation = continuation
|
|
locationManager.requestLocation()
|
|
}
|
|
}
|
|
|
|
// MARK: - Tool Schemas
|
|
|
|
func getToolSchemas() -> [Tool] {
|
|
[
|
|
makeTool(
|
|
name: "location_get_current",
|
|
description: "Get the device's current location, including a human-readable address.",
|
|
properties: [:],
|
|
required: []
|
|
),
|
|
makeTool(
|
|
name: "maps_search_places",
|
|
description: "Search for places (businesses, landmarks, addresses) by name or category.",
|
|
properties: [
|
|
"query": prop("string", "What to search for, e.g. 'coffee shops' or 'Eiffel Tower'"),
|
|
"near": prop("string", "Optional: an address or 'latitude,longitude' to search near")
|
|
],
|
|
required: ["query"]
|
|
),
|
|
makeTool(
|
|
name: "maps_geocode",
|
|
description: "Convert an address into geographic coordinates and a formatted address.",
|
|
properties: [
|
|
"address": prop("string", "The address to geocode")
|
|
],
|
|
required: ["address"]
|
|
),
|
|
makeTool(
|
|
name: "maps_get_directions",
|
|
description: "Get distance and estimated travel time between two locations.",
|
|
properties: [
|
|
"origin": prop("string", "Starting address or 'latitude,longitude'"),
|
|
"destination": prop("string", "Destination address or 'latitude,longitude'"),
|
|
"transport_type": prop("string", "Mode of transport", enumValues: ["driving", "walking", "transit"])
|
|
],
|
|
required: ["origin", "destination"]
|
|
)
|
|
]
|
|
}
|
|
|
|
// MARK: - Tool Execution
|
|
|
|
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
|
Log.mcp.info("Executing LocationMaps tool: \(name)")
|
|
let args = parseArgs(arguments)
|
|
|
|
switch name {
|
|
case "location_get_current":
|
|
guard authorized else { return ["error": "Location permission not granted. Grant access in Settings > MCP."] }
|
|
return await getCurrentLocation()
|
|
|
|
case "maps_search_places":
|
|
guard let query = args["query"] as? String, !query.isEmpty else {
|
|
return ["error": "Missing required parameter: query"]
|
|
}
|
|
let near = args["near"] as? String
|
|
return await searchPlaces(query: query, near: near)
|
|
|
|
case "maps_geocode":
|
|
guard let address = args["address"] as? String, !address.isEmpty else {
|
|
return ["error": "Missing required parameter: address"]
|
|
}
|
|
return await geocode(address: address)
|
|
|
|
case "maps_get_directions":
|
|
guard let origin = args["origin"] as? String, let destination = args["destination"] as? String else {
|
|
return ["error": "Missing required parameter: origin and/or destination"]
|
|
}
|
|
let transportType = args["transport_type"] as? String ?? "driving"
|
|
return await getDirections(origin: origin, destination: destination, transportType: transportType)
|
|
|
|
default:
|
|
return ["error": "Unknown LocationMaps tool: \(name)"]
|
|
}
|
|
}
|
|
|
|
// MARK: - Implementations
|
|
|
|
private func getCurrentLocation() async -> [String: Any] {
|
|
guard let location = await currentLocation() else {
|
|
return ["error": "Could not determine current location"]
|
|
}
|
|
var result: [String: Any] = [
|
|
"latitude": location.coordinate.latitude,
|
|
"longitude": location.coordinate.longitude
|
|
]
|
|
if let mapItem = await reverseGeocode(location), let address = addressString(for: mapItem) {
|
|
result["address"] = address
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func searchPlaces(query: String, near: String?) async -> [String: Any] {
|
|
let request = MKLocalSearch.Request()
|
|
request.naturalLanguageQuery = query
|
|
if let near, let coordinate = await coordinate(for: near) {
|
|
request.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 20_000, longitudinalMeters: 20_000)
|
|
}
|
|
|
|
do {
|
|
let response = try await MKLocalSearch(request: request).start()
|
|
let places = response.mapItems.prefix(15).map { item -> [String: Any] in
|
|
var place: [String: Any] = ["name": item.name ?? "Unknown"]
|
|
place["latitude"] = item.location.coordinate.latitude
|
|
place["longitude"] = item.location.coordinate.longitude
|
|
if let address = addressString(for: item) {
|
|
place["address"] = address
|
|
}
|
|
if let phone = item.phoneNumber { place["phone"] = phone }
|
|
return place
|
|
}
|
|
return ["count": places.count, "places": Array(places)]
|
|
} catch {
|
|
return ["error": "Search failed: \(error.localizedDescription)"]
|
|
}
|
|
}
|
|
|
|
private func geocode(address: String) async -> [String: Any] {
|
|
guard let request = MKGeocodingRequest(addressString: address) else {
|
|
return ["error": "Invalid address: \(address)"]
|
|
}
|
|
do {
|
|
guard let mapItem = try await request.mapItems.first else {
|
|
return ["error": "No results found for address: \(address)"]
|
|
}
|
|
var result: [String: Any] = [
|
|
"latitude": mapItem.location.coordinate.latitude,
|
|
"longitude": mapItem.location.coordinate.longitude
|
|
]
|
|
if let formatted = addressString(for: mapItem) {
|
|
result["formatted_address"] = formatted
|
|
}
|
|
return result
|
|
} catch {
|
|
return ["error": "Geocoding failed: \(error.localizedDescription)"]
|
|
}
|
|
}
|
|
|
|
private func getDirections(origin: String, destination: String, transportType: String) async -> [String: Any] {
|
|
guard let originCoordinate = await coordinate(for: origin) else {
|
|
return ["error": "Could not resolve origin: \(origin)"]
|
|
}
|
|
guard let destinationCoordinate = await coordinate(for: destination) else {
|
|
return ["error": "Could not resolve destination: \(destination)"]
|
|
}
|
|
|
|
let request = MKDirections.Request()
|
|
request.source = MKMapItem(location: CLLocation(latitude: originCoordinate.latitude, longitude: originCoordinate.longitude), address: nil)
|
|
request.destination = MKMapItem(location: CLLocation(latitude: destinationCoordinate.latitude, longitude: destinationCoordinate.longitude), address: nil)
|
|
switch transportType {
|
|
case "walking": request.transportType = .walking
|
|
case "transit": request.transportType = .transit
|
|
default: request.transportType = .automobile
|
|
}
|
|
|
|
do {
|
|
let response = try await MKDirections(request: request).calculate()
|
|
guard let route = response.routes.first else {
|
|
return ["error": "No route found"]
|
|
}
|
|
let distanceFormatter = MKDistanceFormatter()
|
|
let durationFormatter = DateComponentsFormatter()
|
|
durationFormatter.allowedUnits = [.hour, .minute]
|
|
durationFormatter.unitsStyle = .short
|
|
|
|
return [
|
|
"distance_meters": route.distance,
|
|
"distance_text": distanceFormatter.string(fromDistance: route.distance),
|
|
"duration_seconds": route.expectedTravelTime,
|
|
"duration_text": durationFormatter.string(from: route.expectedTravelTime) ?? "",
|
|
"transport_type": transportType
|
|
]
|
|
} catch {
|
|
return ["error": "Directions failed: \(error.localizedDescription)"]
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func coordinate(for text: String) async -> CLLocationCoordinate2D? {
|
|
let parts = text.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
if parts.count == 2, let lat = Double(parts[0]), let lon = Double(parts[1]) {
|
|
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
|
}
|
|
guard let request = MKGeocodingRequest(addressString: text) else { return nil }
|
|
if let mapItems = try? await request.mapItems, let mapItem = mapItems.first {
|
|
return mapItem.location.coordinate
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func reverseGeocode(_ location: CLLocation) async -> MKMapItem? {
|
|
guard let request = MKReverseGeocodingRequest(location: location) else { return nil }
|
|
let mapItems = try? await request.mapItems
|
|
return mapItems?.first
|
|
}
|
|
|
|
private func addressString(for mapItem: MKMapItem) -> String? {
|
|
mapItem.address?.fullAddress
|
|
?? mapItem.addressRepresentations?.fullAddress(includingRegion: true, singleLine: true)
|
|
}
|
|
|
|
private 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|