Files
oai-swift/oAI/Services/LocationMapsService.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

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)
}
}