@elizaos/capacitor-location 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,391 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import CoreLocation
4
+
5
+ /// Native iOS implementation of the ElizaLocation Capacitor plugin.
6
+ ///
7
+ /// Bridges CLLocationManager to the TypeScript LocationPlugin interface, providing:
8
+ /// - getCurrentPosition (one-shot with accuracy, maxAge cache, timeout)
9
+ /// - watchPosition (continuous updates with minDistance + minInterval throttle)
10
+ /// - clearWatch (stop a running watch)
11
+ /// - checkPermissions / requestPermissions (whenInUse or always)
12
+ /// - Events: locationChange, error
13
+ @objc(ElizaLocationPlugin)
14
+ public class ElizaLocationPlugin: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelegate {
15
+ public let identifier = "ElizaLocationPlugin"
16
+ public let jsName = "ElizaLocation"
17
+ public let pluginMethods: [CAPPluginMethod] = [
18
+ CAPPluginMethod(name: "getCurrentPosition", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "watchPosition", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "clearWatch", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
23
+ ]
24
+
25
+ // MARK: - State
26
+
27
+ /// Primary manager used for permission requests and cached-location reads.
28
+ private var locationManager: CLLocationManager!
29
+
30
+ /// Retained manager for an in-flight one-shot location request.
31
+ /// Must be kept alive until the delegate fires or the timeout expires.
32
+ private var singleRequestManager: CLLocationManager?
33
+ private var singleRequestTimer: DispatchWorkItem?
34
+
35
+ /// Active watch sessions keyed by watch ID.
36
+ private var watches: [String: WatchState] = [:]
37
+
38
+ /// A pending plugin call waiting for authorization before it can proceed.
39
+ private var pendingCall: CAPPluginCall?
40
+ private var pendingAction: PendingAction?
41
+
42
+ private enum PendingAction {
43
+ case getCurrentPosition
44
+ case watchPosition
45
+ case requestPermissions
46
+ case singleLocation
47
+ }
48
+
49
+ /// Per-watch bookkeeping.
50
+ private struct WatchState {
51
+ let manager: CLLocationManager
52
+ /// Minimum interval (seconds) between emitted events. 0 = no throttle.
53
+ let minInterval: TimeInterval
54
+ /// Timestamp of the last emitted locationChange for this watch.
55
+ var lastEmitted: Date?
56
+ }
57
+
58
+ // MARK: - Lifecycle
59
+
60
+ public override func load() {
61
+ locationManager = CLLocationManager()
62
+ locationManager.delegate = self
63
+ }
64
+
65
+ // MARK: - getCurrentPosition
66
+
67
+ @objc func getCurrentPosition(_ call: CAPPluginCall) {
68
+ guard CLLocationManager.locationServicesEnabled() else {
69
+ call.reject("Location services disabled", "POSITION_UNAVAILABLE")
70
+ return
71
+ }
72
+
73
+ let status = currentAuthStatus()
74
+
75
+ if status == .notDetermined {
76
+ pendingCall = call
77
+ pendingAction = .getCurrentPosition
78
+ locationManager.requestWhenInUseAuthorization()
79
+ return
80
+ }
81
+
82
+ guard status == .authorizedWhenInUse || status == .authorizedAlways else {
83
+ call.reject("Location permission denied", "PERMISSION_DENIED")
84
+ return
85
+ }
86
+
87
+ getCurrentPositionInternal(call)
88
+ }
89
+
90
+ private func getCurrentPositionInternal(_ call: CAPPluginCall) {
91
+ let accuracy = call.getString("accuracy") ?? "high"
92
+ let timeout = call.getDouble("timeout") ?? 10000
93
+ let maxAge = call.getDouble("maxAge") ?? 0
94
+
95
+ // Return a cached location if fresh enough (mirrors classic LocationService).
96
+ if maxAge > 0, let cached = locationManager.location {
97
+ let ageMs = Date().timeIntervalSince(cached.timestamp) * 1000
98
+ if ageMs <= maxAge {
99
+ call.resolve(buildLocationResult(from: cached, cached: true))
100
+ return
101
+ }
102
+ }
103
+
104
+ // Spin up a dedicated manager so desiredAccuracy is isolated.
105
+ let manager = CLLocationManager()
106
+ manager.delegate = self
107
+ manager.desiredAccuracy = clAccuracy(from: accuracy)
108
+
109
+ // Retain to prevent dealloc before the delegate fires.
110
+ singleRequestManager = manager
111
+ pendingCall = call
112
+ pendingAction = .singleLocation
113
+
114
+ manager.requestLocation()
115
+
116
+ // Timeout guard.
117
+ let timer = DispatchWorkItem { [weak self] in
118
+ guard let self, self.pendingAction == .singleLocation else { return }
119
+ self.cleanupSingleRequest()?.reject("Location request timed out", "TIMEOUT")
120
+ }
121
+ singleRequestTimer = timer
122
+ DispatchQueue.main.asyncAfter(deadline: .now() + timeout / 1000, execute: timer)
123
+ }
124
+
125
+ /// Cancel in-flight single-request state and return the call (if any) for the caller to resolve/reject.
126
+ @discardableResult
127
+ private func cleanupSingleRequest() -> CAPPluginCall? {
128
+ singleRequestTimer?.cancel()
129
+ singleRequestTimer = nil
130
+ singleRequestManager?.delegate = nil
131
+ singleRequestManager = nil
132
+ let call = pendingCall
133
+ pendingCall = nil
134
+ pendingAction = nil
135
+ return call
136
+ }
137
+
138
+ // MARK: - watchPosition
139
+
140
+ @objc func watchPosition(_ call: CAPPluginCall) {
141
+ guard CLLocationManager.locationServicesEnabled() else {
142
+ call.reject("Location services disabled", "POSITION_UNAVAILABLE")
143
+ return
144
+ }
145
+
146
+ let status = currentAuthStatus()
147
+
148
+ if status == .notDetermined {
149
+ pendingCall = call
150
+ pendingAction = .watchPosition
151
+ locationManager.requestWhenInUseAuthorization()
152
+ return
153
+ }
154
+
155
+ guard status == .authorizedWhenInUse || status == .authorizedAlways else {
156
+ call.reject("Location permission denied", "PERMISSION_DENIED")
157
+ return
158
+ }
159
+
160
+ watchPositionInternal(call)
161
+ }
162
+
163
+ private func watchPositionInternal(_ call: CAPPluginCall) {
164
+ let accuracy = call.getString("accuracy") ?? "high"
165
+ let minDistance = call.getDouble("minDistance") ?? 0
166
+ let minInterval = call.getDouble("minInterval") ?? 0
167
+
168
+ let watchId = UUID().uuidString
169
+ let manager = CLLocationManager()
170
+ manager.delegate = self
171
+ manager.desiredAccuracy = clAccuracy(from: accuracy)
172
+ manager.distanceFilter = minDistance > 0 ? minDistance : kCLDistanceFilterNone
173
+
174
+ watches[watchId] = WatchState(
175
+ manager: manager,
176
+ minInterval: minInterval / 1000, // ms → seconds
177
+ lastEmitted: nil
178
+ )
179
+
180
+ manager.startUpdatingLocation()
181
+
182
+ call.resolve(["watchId": watchId])
183
+ }
184
+
185
+ // MARK: - clearWatch
186
+
187
+ @objc func clearWatch(_ call: CAPPluginCall) {
188
+ guard let watchId = call.getString("watchId") else {
189
+ call.reject("Missing watchId")
190
+ return
191
+ }
192
+
193
+ if let state = watches.removeValue(forKey: watchId) {
194
+ state.manager.stopUpdatingLocation()
195
+ state.manager.delegate = nil
196
+ }
197
+ call.resolve()
198
+ }
199
+
200
+ // MARK: - Permissions
201
+
202
+ @objc public override func checkPermissions(_ call: CAPPluginCall) {
203
+ call.resolve(buildPermissionResult())
204
+ }
205
+
206
+ @objc public override func requestPermissions(_ call: CAPPluginCall) {
207
+ guard CLLocationManager.locationServicesEnabled() else {
208
+ call.reject("Location services disabled", "POSITION_UNAVAILABLE")
209
+ return
210
+ }
211
+
212
+ let status = currentAuthStatus()
213
+ let level = call.getString("level") ?? "whenInUse"
214
+
215
+ if status == .notDetermined {
216
+ pendingCall = call
217
+ pendingAction = .requestPermissions
218
+ if level == "always" {
219
+ locationManager.requestAlwaysAuthorization()
220
+ } else {
221
+ locationManager.requestWhenInUseAuthorization()
222
+ }
223
+ return
224
+ }
225
+
226
+ // Escalate whenInUse → always if requested (mirrors classic ensureAuthorization).
227
+ if level == "always" && status == .authorizedWhenInUse {
228
+ pendingCall = call
229
+ pendingAction = .requestPermissions
230
+ locationManager.requestAlwaysAuthorization()
231
+ return
232
+ }
233
+
234
+ // Already determined — return current state.
235
+ call.resolve(buildPermissionResult())
236
+ }
237
+
238
+ // MARK: - CLLocationManagerDelegate
239
+
240
+ public func locationManager(
241
+ _ manager: CLLocationManager,
242
+ didUpdateLocations locations: [CLLocation]
243
+ ) {
244
+ guard let location = locations.last else { return }
245
+
246
+ // One-shot request?
247
+ if pendingAction == .singleLocation, manager === singleRequestManager {
248
+ cleanupSingleRequest()?.resolve(buildLocationResult(from: location, cached: false))
249
+ return
250
+ }
251
+
252
+ // Watch update — find the matching watch and apply minInterval throttle.
253
+ for (watchId, var state) in watches where state.manager === manager {
254
+ if state.minInterval > 0, let last = state.lastEmitted,
255
+ Date().timeIntervalSince(last) < state.minInterval
256
+ {
257
+ return // throttled
258
+ }
259
+ state.lastEmitted = Date()
260
+ watches[watchId] = state
261
+ notifyListeners("locationChange", data: buildLocationResult(from: location, cached: false))
262
+ return
263
+ }
264
+ }
265
+
266
+ public func locationManager(
267
+ _ manager: CLLocationManager,
268
+ didFailWithError error: Error
269
+ ) {
270
+ // One-shot request?
271
+ if pendingAction == .singleLocation, manager === singleRequestManager {
272
+ cleanupSingleRequest()?.reject(
273
+ "Location error: \(error.localizedDescription)", "POSITION_UNAVAILABLE"
274
+ )
275
+ return
276
+ }
277
+
278
+ // Watch error — emit event.
279
+ for (_, state) in watches where state.manager === manager {
280
+ notifyListeners("error", data: [
281
+ "code": "POSITION_UNAVAILABLE",
282
+ "message": error.localizedDescription,
283
+ ])
284
+ return
285
+ }
286
+ }
287
+
288
+ public func locationManager(
289
+ _ manager: CLLocationManager,
290
+ didChangeAuthorization status: CLAuthorizationStatus
291
+ ) {
292
+ guard let call = pendingCall, let action = pendingAction else { return }
293
+
294
+ // Still waiting for user to decide.
295
+ if status == .notDetermined { return }
296
+
297
+ // Clear pending state *before* calling internal methods — they may set new values.
298
+ pendingCall = nil
299
+ pendingAction = nil
300
+
301
+ switch action {
302
+ case .getCurrentPosition:
303
+ if status == .authorizedWhenInUse || status == .authorizedAlways {
304
+ getCurrentPositionInternal(call)
305
+ } else {
306
+ call.reject("Location permission denied", "PERMISSION_DENIED")
307
+ }
308
+
309
+ case .watchPosition:
310
+ if status == .authorizedWhenInUse || status == .authorizedAlways {
311
+ watchPositionInternal(call)
312
+ } else {
313
+ call.reject("Location permission denied", "PERMISSION_DENIED")
314
+ }
315
+
316
+ case .requestPermissions:
317
+ call.resolve(buildPermissionResult())
318
+
319
+ case .singleLocation:
320
+ // Shouldn't happen — singleLocation is set after auth is granted.
321
+ break
322
+ }
323
+ }
324
+
325
+ // MARK: - Helpers
326
+
327
+ private func currentAuthStatus() -> CLAuthorizationStatus {
328
+ CLLocationManager.authorizationStatus()
329
+ }
330
+
331
+ /// Map the TypeScript LocationAccuracy string to a CLLocationAccuracy constant.
332
+ private func clAccuracy(from accuracy: String) -> CLLocationAccuracy {
333
+ switch accuracy {
334
+ case "best": return kCLLocationAccuracyBest
335
+ case "high": return kCLLocationAccuracyNearestTenMeters
336
+ case "medium": return kCLLocationAccuracyHundredMeters
337
+ case "low": return kCLLocationAccuracyKilometer
338
+ case "passive": return kCLLocationAccuracyThreeKilometers
339
+ default: return kCLLocationAccuracyNearestTenMeters
340
+ }
341
+ }
342
+
343
+ /// Build the permission result matching LocationPermissionStatus in definitions.ts.
344
+ private func buildPermissionResult() -> JSObject {
345
+ let status = currentAuthStatus()
346
+ let location: String
347
+ let background: String
348
+
349
+ switch status {
350
+ case .authorizedAlways:
351
+ location = "granted"
352
+ background = "granted"
353
+ case .authorizedWhenInUse:
354
+ location = "granted"
355
+ background = "prompt"
356
+ case .denied, .restricted:
357
+ location = "denied"
358
+ background = "denied"
359
+ default:
360
+ location = "prompt"
361
+ background = "prompt"
362
+ }
363
+
364
+ return ["location": location, "background": background]
365
+ }
366
+
367
+ /// Build a LocationResult matching the TypeScript interface:
368
+ /// `{ coords: LocationCoordinates, cached: boolean }`
369
+ private func buildLocationResult(from location: CLLocation, cached: Bool) -> JSObject {
370
+ var coords: JSObject = [
371
+ "latitude": location.coordinate.latitude,
372
+ "longitude": location.coordinate.longitude,
373
+ "accuracy": location.horizontalAccuracy,
374
+ "timestamp": location.timestamp.timeIntervalSince1970 * 1000,
375
+ ]
376
+
377
+ // Altitude data is valid when verticalAccuracy >= 0.
378
+ if location.verticalAccuracy >= 0 {
379
+ coords["altitude"] = location.altitude
380
+ coords["altitudeAccuracy"] = location.verticalAccuracy
381
+ }
382
+ if location.speed >= 0 {
383
+ coords["speed"] = location.speed
384
+ }
385
+ if location.course >= 0 {
386
+ coords["heading"] = location.course
387
+ }
388
+
389
+ return ["coords": coords, "cached": cached]
390
+ }
391
+ }
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@elizaos/capacitor-location",
3
+ "version": "1.0.0",
4
+ "description": "Reads current location, watches movement, and manages geolocation permissions.",
5
+ "keywords": [
6
+ "location",
7
+ "geolocation",
8
+ "device",
9
+ "permissions"
10
+ ],
11
+ "main": "./dist/plugin.cjs.js",
12
+ "module": "./dist/esm/index.js",
13
+ "types": "./dist/esm/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/esm/index.d.ts",
17
+ "import": "./dist/esm/index.js",
18
+ "require": "./dist/plugin.cjs.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "unpkg": "dist/plugin.js",
23
+ "files": [
24
+ "android/src/main/",
25
+ "android/build.gradle",
26
+ "dist/",
27
+ "ios/Sources/",
28
+ "*.podspec"
29
+ ],
30
+ "scripts": {
31
+ "build": "npm run clean && tsc && rollup -c rollup.config.mjs",
32
+ "build:docs": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
33
+ "clean": "rimraf ./dist",
34
+ "prepublishOnly": "npm run build",
35
+ "docgen": "docgen --api LocationPlugin --output-readme README.md --output-json dist/docs.json"
36
+ },
37
+ "author": "elizaOS",
38
+ "license": "MIT",
39
+ "dependencies": {
40
+ "@elizaos/app-core": "2.0.0-alpha.537"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/elizaOS/eliza.git",
45
+ "directory": "apps/app/plugins/location"
46
+ },
47
+ "capacitor": {
48
+ "ios": {
49
+ "src": "ios",
50
+ "podName": "ElizaCapacitorLocation"
51
+ },
52
+ "android": {
53
+ "src": "android"
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "@capacitor/cli": "^8.0.0",
58
+ "@capacitor/core": "^8.3.1",
59
+ "@capacitor/docgen": "^0.3.0",
60
+ "rimraf": "^6.0.0",
61
+ "rollup": "^4.60.2",
62
+ "typescript": "^6.0.0"
63
+ },
64
+ "peerDependencies": {
65
+ "@capacitor/core": "^8.3.1"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
70
+ "elizaos": {
71
+ "platforms": [
72
+ "browser",
73
+ "node"
74
+ ],
75
+ "runtime": "both",
76
+ "platformDetails": {
77
+ "browser": "Full support via Geolocation API (current position, watch position, permissions)",
78
+ "node": "Full support via Electrobun with native geolocation services",
79
+ "ios": true,
80
+ "android": true
81
+ }
82
+ }
83
+ }