Files
clawdbot/apps/macos/Sources/Clawdbot/NodeMode/MacNodeLocationService.swift
2026-01-04 17:57:53 +01:00

140 lines
4.3 KiB
Swift

import ClawdbotKit
import CoreLocation
import Foundation
@MainActor
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
enum Error: Swift.Error {
case timeout
case unavailable
}
private let manager = CLLocationManager()
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
override init() {
super.init()
self.manager.delegate = self
self.manager.desiredAccuracy = kCLLocationAccuracyBest
}
func authorizationStatus() -> CLAuthorizationStatus {
self.manager.authorizationStatus
}
func accuracyAuthorization() -> CLAccuracyAuthorization {
if #available(macOS 11.0, *) {
return self.manager.accuracyAuthorization
}
return .fullAccuracy
}
func currentLocation(
desiredAccuracy: ClawdbotLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
guard CLLocationManager.locationServicesEnabled() else {
throw Error.unavailable
}
let now = Date()
if let maxAgeMs,
let cached = self.manager.location,
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
{
return cached
}
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10000)
return try await self.withTimeout(timeoutMs: timeout) {
try await self.requestLocation()
}
}
private func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { cont in
self.locationContinuation = cont
self.manager.requestLocation()
}
}
private func withTimeout<T: Sendable>(
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
if timeoutMs == 0 {
return try await operation()
}
return try await withCheckedThrowingContinuation { continuation in
var didFinish = false
func finish(returning value: T) {
guard !didFinish else { return }
didFinish = true
continuation.resume(returning: value)
}
func finish(throwing error: Swift.Error) {
guard !didFinish else { return }
didFinish = true
continuation.resume(throwing: error)
}
let timeoutItem = DispatchWorkItem {
finish(throwing: Error.timeout)
}
DispatchQueue.main.asyncAfter(
deadline: .now() + .milliseconds(timeoutMs),
execute: timeoutItem)
Task { @MainActor in
do {
let value = try await operation()
timeoutItem.cancel()
finish(returning: value)
} catch {
timeoutItem.cancel()
finish(throwing: error)
}
}
}
}
private static func accuracyValue(_ accuracy: ClawdbotLocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .coarse:
kCLLocationAccuracyKilometer
case .balanced:
kCLLocationAccuracyHundredMeters
case .precise:
kCLLocationAccuracyBest
}
}
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
let errorCopy = error // Capture error for Sendable compliance
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: errorCopy)
}
}
}