Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

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