@tokamakdev/plugin-location 0.1.0-beta.102 → 0.1.0-beta.103

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.
@@ -1,6 +1,19 @@
1
1
  import CoreLocation
2
2
  import Foundation
3
3
 
4
+ #if os(iOS)
5
+ import UIKit
6
+ #else
7
+ import AppKit
8
+ #endif
9
+
10
+ /// The JSON of the configuration `setup` last applied.
11
+ private let setupKey = "tokamak.location.setup"
12
+ /// Whether the plugin asked for Always permission, which iOS asks once.
13
+ private let askedForAlwaysKey = "tokamak.location.askedForAlways"
14
+ /// How long an authorization change may take to arrive after the app is active again.
15
+ private let authorizationChangeDelay: TimeInterval = 1
16
+
4
17
  final class TokamakLocationPlugin: NSObject, TokamakPlugin,
5
18
  CLLocationManagerDelegate
6
19
  {
@@ -23,21 +36,62 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
23
36
  var timer: DispatchWorkItem?
24
37
  }
25
38
 
39
+ /// A `requestPermission` waiting for the user to answer a prompt.
40
+ private struct Prompt {
41
+ /// The purpose key of the temporary precise location to ask for next.
42
+ let purpose: String?
43
+ let reply: TokamakPluginReply
44
+ }
45
+
26
46
  private let host: TokamakHost
27
- /// Updates watchers.
28
- private let updates = CLLocationManager()
47
+ private let defaults = UserDefaults.standard
48
+ /// Locates for watchers, and asks for permission.
49
+ private let watching = CLLocationManager()
29
50
  /// Takes single fixes for current position requests, apart from watching.
30
51
  private let fixes = CLLocationManager()
52
+ /// Delivers the significant changes and visits `setup` names.
53
+ private let monitoring = CLLocationManager()
31
54
  private var current: [UUID: Request] = [:]
32
55
  private var watchers: [UUID: TokamakPluginReply] = [:]
56
+ private var prompts: [UUID: Prompt] = [:]
57
+ /// The prompts waiting when the app resigned active, as a prompt showing
58
+ /// makes it.
59
+ private var shown: [UUID] = []
60
+ private let events: TokamakLocationEvents
61
+ private let updates: TokamakLocationUpdates
62
+ private let regions: TokamakLocationRegions
63
+ #if os(iOS)
64
+ /// The `CLServiceSession` iOS 18 needs for updates and regions to reach an
65
+ /// app that is not in use, held while the app has Always permission.
66
+ private var alwaysSession: AnyObject?
67
+ #endif
33
68
 
69
+ /// Resumes what ran when the app last ended, before launch finishes.
34
70
  init(host: TokamakHost) {
35
71
  self.host = host
72
+ events = TokamakLocationEvents(host: host)
73
+ updates = TokamakLocationUpdates(events: events)
74
+ regions = TokamakLocationRegions(events: events)
36
75
  super.init()
37
- for manager in [updates, fixes] {
76
+ for manager in [watching, fixes] {
38
77
  manager.delegate = self
39
78
  manager.desiredAccuracy = kCLLocationAccuracyBest
40
79
  }
80
+ monitoring.delegate = self
81
+ if let stored = defaults.data(forKey: setupKey),
82
+ let config = try? JSONSerialization.jsonObject(with: stored) as? [String: Any]
83
+ {
84
+ apply(config)
85
+ }
86
+ #if os(iOS)
87
+ NotificationCenter.default.addObserver(
88
+ self, selector: #selector(willResignActive),
89
+ name: UIApplication.willResignActiveNotification, object: nil)
90
+ NotificationCenter.default.addObserver(
91
+ self, selector: #selector(didBecomeActive),
92
+ name: UIApplication.didBecomeActiveNotification, object: nil)
93
+ #endif
94
+ updates.resume()
41
95
  }
42
96
 
43
97
  func call(
@@ -45,17 +99,77 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
45
99
  arguments: Any,
46
100
  reply: @escaping TokamakPluginReply
47
101
  ) {
48
- guard method == "getCurrentPosition" else {
49
- reply(.failure(.notSupported("\(id).\(method) is not supported")))
50
- return
51
- }
52
102
  let options = arguments as? [String: Any] ?? [:]
53
- current[UUID()] = Request(
54
- reply: reply,
55
- maximumAge: (options["maximumAge"] as? Double ?? 0) / 1000,
56
- timeout: (options["timeout"] as? Double ?? Double(UInt32.max)) / 1000
57
- )
58
- start()
103
+ do throws(TokamakPluginError) {
104
+ switch method {
105
+ case "setup":
106
+ if options["significantChanges"] as? Bool == true
107
+ && !CLLocationManager.significantLocationChangeMonitoringAvailable()
108
+ {
109
+ throw .notSupported("The device cannot monitor significant changes of location")
110
+ }
111
+ apply(options)
112
+ defaults.set(try? JSONSerialization.data(withJSONObject: options), forKey: setupKey)
113
+ reply(.success(nil))
114
+ case "getCurrentPosition":
115
+ current[UUID()] = Request(
116
+ reply: reply,
117
+ maximumAge: (options["maximumAge"] as? Double ?? 0) / 1000,
118
+ timeout: (options["timeout"] as? Double ?? Double(UInt32.max)) / 1000
119
+ )
120
+ start()
121
+ case "permission":
122
+ reply(.success(permission()))
123
+ case "requestPermission":
124
+ try requestPermission(options, reply)
125
+ case "servicesEnabled":
126
+ DispatchQueue.global(qos: .userInitiated).async {
127
+ reply(.success(CLLocationManager.locationServicesEnabled()))
128
+ }
129
+ case "openSettings":
130
+ try host.requireUI()
131
+ openSettings(reply)
132
+ case "startUpdates":
133
+ // A new background activity session starts only on screen.
134
+ try host.requireUI()
135
+ try requirePermission()
136
+ try updates.start(options)
137
+ reply(.success(nil))
138
+ case "stopUpdates":
139
+ updates.stop()
140
+ reply(.success(nil))
141
+ case "getUpdates":
142
+ reply(.success(updates.options))
143
+ case "addRegion":
144
+ try requirePermission()
145
+ guard watching.accuracyAuthorization == .fullAccuracy else {
146
+ throw TokamakPluginError(
147
+ name: "NotAllowedError", message: "Regions need precise location")
148
+ }
149
+ Task { @MainActor in
150
+ do throws(TokamakPluginError) {
151
+ try await regions.add(options)
152
+ reply(.success(nil))
153
+ } catch {
154
+ reply(.failure(error))
155
+ }
156
+ }
157
+ case "getRegions":
158
+ Task { @MainActor in reply(.success(await regions.all())) }
159
+ case "removeRegion":
160
+ guard let id = options["id"] as? String else {
161
+ throw TokamakPluginError(name: "TypeError", message: "id must be a string")
162
+ }
163
+ Task { @MainActor in
164
+ await regions.remove(id)
165
+ reply(.success(nil))
166
+ }
167
+ default:
168
+ throw .notSupported("\(id).\(method) is not supported")
169
+ }
170
+ } catch {
171
+ reply(.failure(error))
172
+ }
59
173
  }
60
174
 
61
175
  func subscribe(
@@ -63,6 +177,9 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
63
177
  arguments: Any,
64
178
  reply: @escaping TokamakPluginReply
65
179
  ) throws(TokamakPluginError) -> (() -> Void) {
180
+ if let remove = events.listen(method, reply) {
181
+ return remove
182
+ }
66
183
  guard method == "watchPosition" else {
67
184
  throw .notSupported("\(id).\(method) is not supported")
68
185
  }
@@ -72,26 +189,38 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
72
189
  return { [weak self] in
73
190
  self?.watchers.removeValue(forKey: subscription)
74
191
  if self?.watchers.isEmpty == true {
75
- self?.updates.stopUpdatingLocation()
192
+ self?.watching.stopUpdatingLocation()
76
193
  }
77
194
  }
78
195
  }
79
196
 
80
197
  /// Also called when each manager is created, before anything asks for a
81
- /// position. Both managers report the app's one authorization.
198
+ /// position. Every manager reports the app's one authorization, which
199
+ /// `watching`'s calls handle.
82
200
  func locationManagerDidChangeAuthorization(
83
201
  _ manager: CLLocationManager
84
202
  ) {
85
- guard manager === updates, !current.isEmpty || !watchers.isEmpty else { return }
86
- serve(manager.authorizationStatus)
203
+ guard manager === watching else { return }
204
+ holdAlwaysSession()
205
+ guard manager.authorizationStatus != .notDetermined else { return }
206
+ answer(Array(prompts.keys))
207
+ if !current.isEmpty || !watchers.isEmpty {
208
+ serve(manager.authorizationStatus)
209
+ }
87
210
  }
88
211
 
89
212
  func locationManager(
90
213
  _ manager: CLLocationManager,
91
214
  didUpdateLocations locations: [CLLocation]
92
215
  ) {
216
+ if manager === monitoring {
217
+ for location in locations {
218
+ events.update(location)
219
+ }
220
+ return
221
+ }
93
222
  guard let location = locations.last else { return }
94
- deliver(.success(position(location)), from: manager)
223
+ deliver(.success(TokamakLocationEvents.position(location)), from: manager)
95
224
  }
96
225
 
97
226
  /// A fix that is not yet available is taken again; updates continue.
@@ -99,12 +228,34 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
99
228
  _ manager: CLLocationManager,
100
229
  didFailWithError error: Error
101
230
  ) {
102
- guard (error as NSError).code == CLError.Code.locationUnknown.rawValue else {
231
+ if (error as NSError).code == CLError.Code.locationUnknown.rawValue {
232
+ if manager === fixes && !current.isEmpty {
233
+ fixes.requestLocation()
234
+ }
235
+ } else if manager === monitoring {
236
+ events.fail(locationError(error))
237
+ } else {
103
238
  deliver(.failure(locationError(error)), from: manager)
104
- return
105
239
  }
106
- if manager === fixes && !current.isEmpty {
107
- fixes.requestLocation()
240
+ }
241
+
242
+ func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
243
+ events.visit(visit)
244
+ }
245
+
246
+ /// Starts the significant changes and visits `config` names, and stops the
247
+ /// others. After a launch for one of their events, starting them again
248
+ /// delivers it.
249
+ private func apply(_ config: [String: Any]) {
250
+ if config["significantChanges"] as? Bool == true {
251
+ monitoring.startMonitoringSignificantLocationChanges()
252
+ } else {
253
+ monitoring.stopMonitoringSignificantLocationChanges()
254
+ }
255
+ if config["visits"] as? Bool == true {
256
+ monitoring.startMonitoringVisits()
257
+ } else {
258
+ monitoring.stopMonitoringVisits()
108
259
  }
109
260
  }
110
261
 
@@ -118,17 +269,17 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
118
269
 
119
270
  private func start(servicesEnabled: Bool) {
120
271
  guard servicesEnabled else {
121
- fail(unavailable("Location services are disabled"))
272
+ fail(.locationUnavailable("Location services are disabled"))
122
273
  return
123
274
  }
124
- let status = updates.authorizationStatus
275
+ let status = watching.authorizationStatus
125
276
  guard status == .notDetermined else {
126
277
  serve(status)
127
278
  return
128
279
  }
129
280
  do throws(TokamakPluginError) {
130
281
  try host.requireUI()
131
- updates.requestWhenInUseAuthorization()
282
+ watching.requestWhenInUseAuthorization()
132
283
  } catch {
133
284
  fail(error)
134
285
  }
@@ -140,7 +291,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
140
291
  switch status {
141
292
  case .authorizedAlways, .authorizedWhenInUse:
142
293
  if !watchers.isEmpty {
143
- updates.startUpdatingLocation()
294
+ watching.startUpdatingLocation()
144
295
  }
145
296
  locate()
146
297
  case .denied, .restricted:
@@ -157,7 +308,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
157
308
  private func locate() {
158
309
  for (key, request) in current where request.timer == nil {
159
310
  if let fix = fixes.location, -fix.timestamp.timeIntervalSinceNow <= request.maximumAge {
160
- finish(key, .success(position(fix)))
311
+ finish(key, .success(TokamakLocationEvents.position(fix)))
161
312
  continue
162
313
  }
163
314
  let timer = DispatchWorkItem { [weak self] in self?.finish(key, .failure(Self.timedOut)) }
@@ -185,7 +336,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
185
336
 
186
337
  private func fail(_ error: TokamakPluginError) {
187
338
  deliver(.failure(error), from: fixes)
188
- deliver(.failure(error), from: updates)
339
+ deliver(.failure(error), from: watching)
189
340
  }
190
341
 
191
342
  private func finish(_ key: UUID, _ result: Result<Any?, TokamakPluginError>) {
@@ -197,40 +348,180 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
197
348
  }
198
349
  }
199
350
 
200
- private func locationError(_ error: Error) -> TokamakPluginError {
201
- if (error as NSError).code == CLError.Code.denied.rawValue {
202
- return Self.permissionDenied
351
+ /// The app's in-use and background permission, and the precision it gets.
352
+ private func permission() -> [String: Any] {
353
+ let status = watching.authorizationStatus
354
+ return [
355
+ "inUse": Self.state(status),
356
+ "background": backgroundPermission(status),
357
+ "precision": watching.accuracyAuthorization == .reducedAccuracy ? "approximate" : "precise",
358
+ ]
359
+ }
360
+
361
+ private func backgroundPermission(_ status: CLAuthorizationStatus) -> String {
362
+ if status == .authorizedAlways { return "granted" }
363
+ let asks = status == .notDetermined ? Self.declaresAlways : canUpgradeToAlways(status)
364
+ return asks ? "prompt" : "denied"
365
+ }
366
+
367
+ /// Whether asking for Always shows a prompt: once, from When In Use, on iOS.
368
+ private func canUpgradeToAlways(_ status: CLAuthorizationStatus) -> Bool {
369
+ #if os(iOS)
370
+ status == .authorizedWhenInUse && Self.declaresAlways && !askedForAlways
371
+ #else
372
+ false
373
+ #endif
374
+ }
375
+
376
+ private var hasPermission: Bool {
377
+ ![.notDetermined, .denied, .restricted].contains(watching.authorizationStatus)
378
+ }
379
+
380
+ /// Throws `NotAllowedError` unless the app has location permission.
381
+ private func requirePermission() throws(TokamakPluginError) {
382
+ guard hasPermission else {
383
+ throw TokamakPluginError(
384
+ name: "NotAllowedError", message: "Location permission has not been granted")
203
385
  }
204
- return unavailable(error.localizedDescription)
205
386
  }
206
387
 
207
- private func unavailable(_ message: String) -> TokamakPluginError {
208
- TokamakPluginError(name: "NotReadableError", message: message)
388
+ private func holdAlwaysSession() {
389
+ #if os(iOS)
390
+ guard #available(iOS 18, *) else { return }
391
+ let always = watching.authorizationStatus == .authorizedAlways
392
+ if always && alwaysSession == nil {
393
+ alwaysSession = CLServiceSession(authorization: .always)
394
+ } else if !always {
395
+ (alwaysSession as? CLServiceSession)?.invalidate()
396
+ alwaysSession = nil
397
+ }
398
+ #endif
209
399
  }
210
400
 
211
- private func position(_ location: CLLocation) -> [String: Any] {
212
- let coordinates = location.coordinate
213
- return [
214
- "coords": [
215
- "latitude": coordinates.latitude,
216
- "longitude": coordinates.longitude,
217
- "accuracy": location.horizontalAccuracy,
218
- "altitude": nullable(
219
- location.verticalAccuracy >= 0,
220
- location.altitude
221
- ),
222
- "altitudeAccuracy": nullable(
223
- location.verticalAccuracy >= 0,
224
- location.verticalAccuracy
225
- ),
226
- "heading": nullable(location.course >= 0, location.course),
227
- "speed": nullable(location.speed >= 0, location.speed),
228
- ],
229
- "timestamp": location.timestamp.timeIntervalSince1970 * 1000,
230
- ]
401
+ private static func state(_ status: CLAuthorizationStatus) -> String {
402
+ switch status {
403
+ case .notDetermined: "prompt"
404
+ case .denied, .restricted: "denied"
405
+ default: "granted"
406
+ }
407
+ }
408
+
409
+ /// Whether the Info.plist declares Always permission, as the `background`
410
+ /// build setting does on iOS and every macOS build does.
411
+ private static let declaresAlways =
412
+ Bundle.main.object(forInfoDictionaryKey: "NSLocationAlwaysAndWhenInUseUsageDescription") != nil
413
+
414
+ private var askedForAlways: Bool {
415
+ defaults.bool(forKey: askedForAlwaysKey)
231
416
  }
232
417
 
233
- private func nullable(_ available: Bool, _ value: Double) -> Any {
234
- available ? value : NSNull()
418
+ /// Asks for in-use permission while the user has not answered, or for
419
+ /// Always permission once from When In Use, then for temporary precise
420
+ /// location when `options` name a purpose.
421
+ private func requestPermission(
422
+ _ options: [String: Any], _ reply: @escaping TokamakPluginReply
423
+ ) throws(TokamakPluginError) {
424
+ let always = options["background"] as? Bool == true
425
+ if always && !Self.declaresAlways {
426
+ throw .notSupported(
427
+ "Background location needs the location plugin's background build setting")
428
+ }
429
+ let prompt = Prompt(purpose: try Self.purpose(options), reply: reply)
430
+ let status = watching.authorizationStatus
431
+ let upgrades = always && canUpgradeToAlways(status)
432
+ guard status == .notDetermined || upgrades else {
433
+ respond(to: prompt)
434
+ return
435
+ }
436
+ try host.requireUI()
437
+ prompts[UUID()] = prompt
438
+ if always {
439
+ defaults.set(true, forKey: askedForAlwaysKey)
440
+ watching.requestAlwaysAuthorization()
441
+ } else {
442
+ watching.requestWhenInUseAuthorization()
443
+ }
444
+ }
445
+
446
+ /// The purpose key of the temporary precise location `options` ask for.
447
+ private static func purpose(_ options: [String: Any]) throws(TokamakPluginError) -> String? {
448
+ guard options["precision"] as? String == "precise" else { return nil }
449
+ let purposes =
450
+ Bundle.main.object(forInfoDictionaryKey: "NSLocationTemporaryUsageDescriptionDictionary")
451
+ as? [String: Any]
452
+ guard let purpose = options["purpose"] as? String, purposes?[purpose] != nil else {
453
+ throw TokamakPluginError(
454
+ name: "TypeError",
455
+ message: "precise location needs a purpose from the accuracy-purposes build setting")
456
+ }
457
+ return purpose
458
+ }
459
+
460
+ #if os(iOS)
461
+ @objc private func willResignActive() {
462
+ shown = Array(prompts.keys)
463
+ }
464
+
465
+ /// Answers the prompts that were showing, as the system reports no change
466
+ /// when the user keeps When In Use.
467
+ @objc private func didBecomeActive() {
468
+ let answered = shown
469
+ shown = []
470
+ guard !answered.isEmpty else { return }
471
+ DispatchQueue.main.asyncAfter(deadline: .now() + authorizationChangeDelay) {
472
+ self.answer(answered)
473
+ }
474
+ }
475
+ #endif
476
+
477
+ /// Answers the prompts `keys` that still wait.
478
+ private func answer(_ keys: [UUID]) {
479
+ for key in keys {
480
+ if let prompt = prompts.removeValue(forKey: key) {
481
+ respond(to: prompt)
482
+ }
483
+ }
484
+ }
485
+
486
+ /// Replies to `prompt` with the app's permission, first asking for temporary
487
+ /// precise location for its purpose when the app has approximate location.
488
+ private func respond(to prompt: Prompt) {
489
+ guard let purpose = prompt.purpose, hasPermission,
490
+ watching.accuracyAuthorization == .reducedAccuracy
491
+ else {
492
+ prompt.reply(.success(permission()))
493
+ return
494
+ }
495
+ do throws(TokamakPluginError) {
496
+ try host.requireUI()
497
+ } catch {
498
+ prompt.reply(.failure(error))
499
+ return
500
+ }
501
+ watching.requestTemporaryFullAccuracyAuthorization(withPurposeKey: purpose) { _ in
502
+ DispatchQueue.main.async { prompt.reply(.success(self.permission())) }
503
+ }
504
+ }
505
+
506
+ /// Opens the app's page of the system settings on iOS, and Location Services on macOS.
507
+ private func openSettings(_ reply: @escaping TokamakPluginReply) {
508
+ let notOpened = TokamakPluginError(
509
+ name: "OperationError", message: "The location settings could not be opened")
510
+ #if os(iOS)
511
+ UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) { opened in
512
+ reply(opened ? .success(nil) : .failure(notOpened))
513
+ }
514
+ #else
515
+ let page = "com.apple.preference.security?Privacy_LocationServices"
516
+ let opened = URL(string: "x-apple.systempreferences:\(page)").map(NSWorkspace.shared.open)
517
+ reply(opened == true ? .success(nil) : .failure(notOpened))
518
+ #endif
519
+ }
520
+
521
+ private func locationError(_ error: Error) -> TokamakPluginError {
522
+ if (error as NSError).code == CLError.Code.denied.rawValue {
523
+ return Self.permissionDenied
524
+ }
525
+ return .locationUnavailable(error.localizedDescription)
235
526
  }
236
527
  }
@@ -0,0 +1,154 @@
1
+ import CoreLocation
2
+ import Foundation
3
+
4
+ #if os(iOS)
5
+ import UIKit
6
+ #endif
7
+
8
+ /// The JSON of each monitored region, as `addRegion` received it, by id.
9
+ private let regionsKey = "tokamak.location.regions"
10
+ /// `CLMonitor` monitors at most 20 conditions for an app, and reports no
11
+ /// error beyond them.
12
+ private let regionLimit = 20
13
+ /// The monitor's name, which must be alphanumeric.
14
+ private let monitorName = "TokamakLocation"
15
+
16
+ /// Circular regions in one `CLMonitor`, which the plugin creates at launch
17
+ /// and reads events from for the life of the process. The system launches the
18
+ /// app for their events on iOS. Use it on the main thread.
19
+ final class TokamakLocationRegions {
20
+ private let events: TokamakLocationEvents
21
+ private let defaults = UserDefaults.standard
22
+ private let monitor: Task<CLMonitor, Never>
23
+
24
+ init(events: TokamakLocationEvents) {
25
+ self.events = events
26
+ let monitor = Task { @MainActor in
27
+ await Self.protectedData()
28
+ return await CLMonitor(monitorName)
29
+ }
30
+ self.monitor = monitor
31
+ Task { @MainActor [weak self] in
32
+ do {
33
+ for try await event in await monitor.value.events {
34
+ self?.receive(event)
35
+ }
36
+ } catch {
37
+ self?.events.fail(
38
+ .locationUnavailable(error.localizedDescription))
39
+ }
40
+ }
41
+ }
42
+
43
+ /// The regions, by id.
44
+ private var regions: [String: [String: Any]] {
45
+ get {
46
+ defaults.data(forKey: regionsKey)
47
+ .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: [String: Any]] } ?? [:]
48
+ }
49
+ set {
50
+ defaults.set(try? JSONSerialization.data(withJSONObject: newValue), forKey: regionsKey)
51
+ }
52
+ }
53
+
54
+ /// Monitors the region `region` describes, replacing one with the same id.
55
+ /// It starts outside, so a device already inside reports an entry, as on
56
+ /// Android.
57
+ func add(_ region: [String: Any]) async throws(TokamakPluginError) {
58
+ guard let id = region["id"] as? String, let radius = region["radius"] as? Double,
59
+ let latitude = region["latitude"] as? Double, let longitude = region["longitude"] as? Double
60
+ else {
61
+ throw TokamakPluginError(
62
+ name: "TypeError", message: "region must have an id, latitude, longitude and radius")
63
+ }
64
+ let monitor = await monitor.value
65
+ let monitored = await monitor.identifiers
66
+ guard monitored.count < regionLimit || monitored.contains(id) else {
67
+ throw TokamakPluginError(
68
+ name: "QuotaExceededError",
69
+ message: "The app monitors \(regionLimit) regions, the most the system allows")
70
+ }
71
+ let center = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
72
+ let condition = CLMonitor.CircularGeographicCondition(center: center, radius: radius)
73
+ regions[id] = region
74
+ await monitor.add(condition, identifier: id, assuming: .unsatisfied)
75
+ }
76
+
77
+ func remove(_ id: String) async {
78
+ regions[id] = nil
79
+ events.report([], region: id)
80
+ await monitor.value.remove(id)
81
+ }
82
+
83
+ /// The monitored regions, each with whether the device is inside it.
84
+ func all() async -> [[String: Any]] {
85
+ let monitor = await monitor.value
86
+ let regions = regions
87
+ var all: [[String: Any]] = []
88
+ for id in await monitor.identifiers {
89
+ guard var region = regions[id] else { continue }
90
+ region["state"] =
91
+ switch await monitor.record(for: id)?.lastEvent.state {
92
+ case .satisfied: "inside"
93
+ case .unsatisfied: "outside"
94
+ default: "unknown"
95
+ }
96
+ all.append(region)
97
+ }
98
+ return all
99
+ }
100
+
101
+ private func receive(_ event: CLMonitor.Event) {
102
+ let id = event.identifier
103
+ if #available(iOS 18, macOS 15, *) {
104
+ events.report(Self.problems(event), region: id)
105
+ }
106
+ guard let region = regions[id] else { return }
107
+ switch event.state {
108
+ case .satisfied where region["onEntry"] as? Bool != false:
109
+ events.transition(id, entered: true, at: event.date)
110
+ case .unsatisfied where region["onExit"] as? Bool != false:
111
+ events.transition(id, entered: false, at: event.date)
112
+ default:
113
+ break
114
+ }
115
+ }
116
+
117
+ /// The problems that stop monitoring the region, which `event` names.
118
+ @available(iOS 18, macOS 15, *)
119
+ private static func problems(_ event: CLMonitor.Event) -> [TokamakPluginError] {
120
+ let flags = [
121
+ (event.authorizationDenied, "NotAllowedError", "Location permission was denied"),
122
+ (event.authorizationDeniedGlobally, "NotAllowedError", "Location services are off"),
123
+ (event.authorizationRestricted, "NotAllowedError", "Location is restricted on this device"),
124
+ (
125
+ event.insufficientlyInUse, "NotAllowedError",
126
+ "Regions need the app in use, or Always permission"
127
+ ),
128
+ (event.accuracyLimited, "NotAllowedError", "Regions need precise location"),
129
+ (event.conditionUnsupported, "NotSupportedError", "The device cannot monitor the region"),
130
+ (
131
+ event.conditionLimitExceeded, "QuotaExceededError",
132
+ "The app monitors the most regions the system allows"
133
+ ),
134
+ (
135
+ event.persistenceUnavailable, "NotReadableError",
136
+ "Regions are unavailable until the device is first unlocked"
137
+ ),
138
+ ]
139
+ return flags.filter(\.0).map { TokamakPluginError(name: $0.1, message: $0.2) }
140
+ }
141
+
142
+ /// Returns once protected data is available, which `CLMonitor` needs.
143
+ @MainActor
144
+ private static func protectedData() async {
145
+ #if os(iOS)
146
+ guard !UIApplication.shared.isProtectedDataAvailable else { return }
147
+ let available = NotificationCenter.default.notifications(
148
+ named: UIApplication.protectedDataDidBecomeAvailableNotification)
149
+ for await _ in available {
150
+ return
151
+ }
152
+ #endif
153
+ }
154
+ }