426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
392 lines
14 KiB
Swift
392 lines
14 KiB
Swift
import Foundation
|
|
import Capacitor
|
|
import CoreLocation
|
|
|
|
/// Native iOS implementation of the ElizaLocation Capacitor plugin.
|
|
///
|
|
/// Bridges CLLocationManager to the TypeScript LocationPlugin interface, providing:
|
|
/// - getCurrentPosition (one-shot with accuracy, maxAge cache, timeout)
|
|
/// - watchPosition (continuous updates with minDistance + minInterval throttle)
|
|
/// - clearWatch (stop a running watch)
|
|
/// - checkPermissions / requestPermissions (whenInUse or always)
|
|
/// - Events: locationChange, error
|
|
@objc(ElizaLocationPlugin)
|
|
public class ElizaLocationPlugin: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelegate {
|
|
public let identifier = "ElizaLocationPlugin"
|
|
public let jsName = "ElizaLocation"
|
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
CAPPluginMethod(name: "getCurrentPosition", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "watchPosition", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "clearWatch", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
|
|
]
|
|
|
|
// MARK: - State
|
|
|
|
/// Primary manager used for permission requests and cached-location reads.
|
|
private var locationManager: CLLocationManager!
|
|
|
|
/// Retained manager for an in-flight one-shot location request.
|
|
/// Must be kept alive until the delegate fires or the timeout expires.
|
|
private var singleRequestManager: CLLocationManager?
|
|
private var singleRequestTimer: DispatchWorkItem?
|
|
|
|
/// Active watch sessions keyed by watch ID.
|
|
private var watches: [String: WatchState] = [:]
|
|
|
|
/// A pending plugin call waiting for authorization before it can proceed.
|
|
private var pendingCall: CAPPluginCall?
|
|
private var pendingAction: PendingAction?
|
|
|
|
private enum PendingAction {
|
|
case getCurrentPosition
|
|
case watchPosition
|
|
case requestPermissions
|
|
case singleLocation
|
|
}
|
|
|
|
/// Per-watch bookkeeping.
|
|
private struct WatchState {
|
|
let manager: CLLocationManager
|
|
/// Minimum interval (seconds) between emitted events. 0 = no throttle.
|
|
let minInterval: TimeInterval
|
|
/// Timestamp of the last emitted locationChange for this watch.
|
|
var lastEmitted: Date?
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
public override func load() {
|
|
locationManager = CLLocationManager()
|
|
locationManager.delegate = self
|
|
}
|
|
|
|
// MARK: - getCurrentPosition
|
|
|
|
@objc func getCurrentPosition(_ call: CAPPluginCall) {
|
|
guard CLLocationManager.locationServicesEnabled() else {
|
|
call.reject("Location services disabled", "POSITION_UNAVAILABLE")
|
|
return
|
|
}
|
|
|
|
let status = currentAuthStatus()
|
|
|
|
if status == .notDetermined {
|
|
pendingCall = call
|
|
pendingAction = .getCurrentPosition
|
|
locationManager.requestWhenInUseAuthorization()
|
|
return
|
|
}
|
|
|
|
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
|
|
call.reject("Location permission denied", "PERMISSION_DENIED")
|
|
return
|
|
}
|
|
|
|
getCurrentPositionInternal(call)
|
|
}
|
|
|
|
private func getCurrentPositionInternal(_ call: CAPPluginCall) {
|
|
let accuracy = call.getString("accuracy") ?? "high"
|
|
let timeout = call.getDouble("timeout") ?? 10000
|
|
let maxAge = call.getDouble("maxAge") ?? 0
|
|
|
|
// Return a cached location if fresh enough (mirrors classic LocationService).
|
|
if maxAge > 0, let cached = locationManager.location {
|
|
let ageMs = Date().timeIntervalSince(cached.timestamp) * 1000
|
|
if ageMs <= maxAge {
|
|
call.resolve(buildLocationResult(from: cached, cached: true))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Spin up a dedicated manager so desiredAccuracy is isolated.
|
|
let manager = CLLocationManager()
|
|
manager.delegate = self
|
|
manager.desiredAccuracy = clAccuracy(from: accuracy)
|
|
|
|
// Retain to prevent dealloc before the delegate fires.
|
|
singleRequestManager = manager
|
|
pendingCall = call
|
|
pendingAction = .singleLocation
|
|
|
|
manager.requestLocation()
|
|
|
|
// Timeout guard.
|
|
let timer = DispatchWorkItem { [weak self] in
|
|
guard let self, self.pendingAction == .singleLocation else { return }
|
|
self.cleanupSingleRequest()?.reject("Location request timed out", "TIMEOUT")
|
|
}
|
|
singleRequestTimer = timer
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + timeout / 1000, execute: timer)
|
|
}
|
|
|
|
/// Cancel in-flight single-request state and return the call (if any) for the caller to resolve/reject.
|
|
@discardableResult
|
|
private func cleanupSingleRequest() -> CAPPluginCall? {
|
|
singleRequestTimer?.cancel()
|
|
singleRequestTimer = nil
|
|
singleRequestManager?.delegate = nil
|
|
singleRequestManager = nil
|
|
let call = pendingCall
|
|
pendingCall = nil
|
|
pendingAction = nil
|
|
return call
|
|
}
|
|
|
|
// MARK: - watchPosition
|
|
|
|
@objc func watchPosition(_ call: CAPPluginCall) {
|
|
guard CLLocationManager.locationServicesEnabled() else {
|
|
call.reject("Location services disabled", "POSITION_UNAVAILABLE")
|
|
return
|
|
}
|
|
|
|
let status = currentAuthStatus()
|
|
|
|
if status == .notDetermined {
|
|
pendingCall = call
|
|
pendingAction = .watchPosition
|
|
locationManager.requestWhenInUseAuthorization()
|
|
return
|
|
}
|
|
|
|
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
|
|
call.reject("Location permission denied", "PERMISSION_DENIED")
|
|
return
|
|
}
|
|
|
|
watchPositionInternal(call)
|
|
}
|
|
|
|
private func watchPositionInternal(_ call: CAPPluginCall) {
|
|
let accuracy = call.getString("accuracy") ?? "high"
|
|
let minDistance = call.getDouble("minDistance") ?? 0
|
|
let minInterval = call.getDouble("minInterval") ?? 0
|
|
|
|
let watchId = UUID().uuidString
|
|
let manager = CLLocationManager()
|
|
manager.delegate = self
|
|
manager.desiredAccuracy = clAccuracy(from: accuracy)
|
|
manager.distanceFilter = minDistance > 0 ? minDistance : kCLDistanceFilterNone
|
|
|
|
watches[watchId] = WatchState(
|
|
manager: manager,
|
|
minInterval: minInterval / 1000, // ms → seconds
|
|
lastEmitted: nil
|
|
)
|
|
|
|
manager.startUpdatingLocation()
|
|
|
|
call.resolve(["watchId": watchId])
|
|
}
|
|
|
|
// MARK: - clearWatch
|
|
|
|
@objc func clearWatch(_ call: CAPPluginCall) {
|
|
guard let watchId = call.getString("watchId") else {
|
|
call.reject("Missing watchId")
|
|
return
|
|
}
|
|
|
|
if let state = watches.removeValue(forKey: watchId) {
|
|
state.manager.stopUpdatingLocation()
|
|
state.manager.delegate = nil
|
|
}
|
|
call.resolve()
|
|
}
|
|
|
|
// MARK: - Permissions
|
|
|
|
@objc public override func checkPermissions(_ call: CAPPluginCall) {
|
|
call.resolve(buildPermissionResult())
|
|
}
|
|
|
|
@objc public override func requestPermissions(_ call: CAPPluginCall) {
|
|
guard CLLocationManager.locationServicesEnabled() else {
|
|
call.reject("Location services disabled", "POSITION_UNAVAILABLE")
|
|
return
|
|
}
|
|
|
|
let status = currentAuthStatus()
|
|
let level = call.getString("level") ?? "whenInUse"
|
|
|
|
if status == .notDetermined {
|
|
pendingCall = call
|
|
pendingAction = .requestPermissions
|
|
if level == "always" {
|
|
locationManager.requestAlwaysAuthorization()
|
|
} else {
|
|
locationManager.requestWhenInUseAuthorization()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Escalate whenInUse → always if requested (mirrors classic ensureAuthorization).
|
|
if level == "always" && status == .authorizedWhenInUse {
|
|
pendingCall = call
|
|
pendingAction = .requestPermissions
|
|
locationManager.requestAlwaysAuthorization()
|
|
return
|
|
}
|
|
|
|
// Already determined — return current state.
|
|
call.resolve(buildPermissionResult())
|
|
}
|
|
|
|
// MARK: - CLLocationManagerDelegate
|
|
|
|
public func locationManager(
|
|
_ manager: CLLocationManager,
|
|
didUpdateLocations locations: [CLLocation]
|
|
) {
|
|
guard let location = locations.last else { return }
|
|
|
|
// One-shot request?
|
|
if pendingAction == .singleLocation, manager === singleRequestManager {
|
|
cleanupSingleRequest()?.resolve(buildLocationResult(from: location, cached: false))
|
|
return
|
|
}
|
|
|
|
// Watch update — find the matching watch and apply minInterval throttle.
|
|
for (watchId, var state) in watches where state.manager === manager {
|
|
if state.minInterval > 0, let last = state.lastEmitted,
|
|
Date().timeIntervalSince(last) < state.minInterval
|
|
{
|
|
return // throttled
|
|
}
|
|
state.lastEmitted = Date()
|
|
watches[watchId] = state
|
|
notifyListeners("locationChange", data: buildLocationResult(from: location, cached: false))
|
|
return
|
|
}
|
|
}
|
|
|
|
public func locationManager(
|
|
_ manager: CLLocationManager,
|
|
didFailWithError error: Error
|
|
) {
|
|
// One-shot request?
|
|
if pendingAction == .singleLocation, manager === singleRequestManager {
|
|
cleanupSingleRequest()?.reject(
|
|
"Location error: \(error.localizedDescription)", "POSITION_UNAVAILABLE"
|
|
)
|
|
return
|
|
}
|
|
|
|
// Watch error — emit event.
|
|
for (_, state) in watches where state.manager === manager {
|
|
notifyListeners("error", data: [
|
|
"code": "POSITION_UNAVAILABLE",
|
|
"message": error.localizedDescription,
|
|
])
|
|
return
|
|
}
|
|
}
|
|
|
|
public func locationManager(
|
|
_ manager: CLLocationManager,
|
|
didChangeAuthorization status: CLAuthorizationStatus
|
|
) {
|
|
guard let call = pendingCall, let action = pendingAction else { return }
|
|
|
|
// Still waiting for user to decide.
|
|
if status == .notDetermined { return }
|
|
|
|
// Clear pending state *before* calling internal methods — they may set new values.
|
|
pendingCall = nil
|
|
pendingAction = nil
|
|
|
|
switch action {
|
|
case .getCurrentPosition:
|
|
if status == .authorizedWhenInUse || status == .authorizedAlways {
|
|
getCurrentPositionInternal(call)
|
|
} else {
|
|
call.reject("Location permission denied", "PERMISSION_DENIED")
|
|
}
|
|
|
|
case .watchPosition:
|
|
if status == .authorizedWhenInUse || status == .authorizedAlways {
|
|
watchPositionInternal(call)
|
|
} else {
|
|
call.reject("Location permission denied", "PERMISSION_DENIED")
|
|
}
|
|
|
|
case .requestPermissions:
|
|
call.resolve(buildPermissionResult())
|
|
|
|
case .singleLocation:
|
|
// Shouldn't happen — singleLocation is set after auth is granted.
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func currentAuthStatus() -> CLAuthorizationStatus {
|
|
CLLocationManager.authorizationStatus()
|
|
}
|
|
|
|
/// Map the TypeScript LocationAccuracy string to a CLLocationAccuracy constant.
|
|
private func clAccuracy(from accuracy: String) -> CLLocationAccuracy {
|
|
switch accuracy {
|
|
case "best": return kCLLocationAccuracyBest
|
|
case "high": return kCLLocationAccuracyNearestTenMeters
|
|
case "medium": return kCLLocationAccuracyHundredMeters
|
|
case "low": return kCLLocationAccuracyKilometer
|
|
case "passive": return kCLLocationAccuracyThreeKilometers
|
|
default: return kCLLocationAccuracyNearestTenMeters
|
|
}
|
|
}
|
|
|
|
/// Build the permission result matching LocationPermissionStatus in definitions.ts.
|
|
private func buildPermissionResult() -> JSObject {
|
|
let status = currentAuthStatus()
|
|
let location: String
|
|
let background: String
|
|
|
|
switch status {
|
|
case .authorizedAlways:
|
|
location = "granted"
|
|
background = "granted"
|
|
case .authorizedWhenInUse:
|
|
location = "granted"
|
|
background = "prompt"
|
|
case .denied, .restricted:
|
|
location = "denied"
|
|
background = "denied"
|
|
default:
|
|
location = "prompt"
|
|
background = "prompt"
|
|
}
|
|
|
|
return ["location": location, "background": background]
|
|
}
|
|
|
|
/// Build a LocationResult matching the TypeScript interface:
|
|
/// `{ coords: LocationCoordinates, cached: boolean }`
|
|
private func buildLocationResult(from location: CLLocation, cached: Bool) -> JSObject {
|
|
var coords: JSObject = [
|
|
"latitude": location.coordinate.latitude,
|
|
"longitude": location.coordinate.longitude,
|
|
"accuracy": location.horizontalAccuracy,
|
|
"timestamp": location.timestamp.timeIntervalSince1970 * 1000,
|
|
]
|
|
|
|
// Altitude data is valid when verticalAccuracy >= 0.
|
|
if location.verticalAccuracy >= 0 {
|
|
coords["altitude"] = location.altitude
|
|
coords["altitudeAccuracy"] = location.verticalAccuracy
|
|
}
|
|
if location.speed >= 0 {
|
|
coords["speed"] = location.speed
|
|
}
|
|
if location.course >= 0 {
|
|
coords["heading"] = location.course
|
|
}
|
|
|
|
return ["coords": coords, "cached": cached]
|
|
}
|
|
}
|