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

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,149 @@
1
+ import CoreLocation
2
+ import Foundation
3
+
4
+ /// How long the Worker's listeners have for an event, within the few seconds
5
+ /// the system gives an app it launches for one.
6
+ private let eventDeadline: TimeInterval = 8
7
+
8
+ /// The listener methods, which receive the Worker's events.
9
+ private let listenerMethods: Set = [
10
+ "onLocationUpdate", "onRegionTransition", "onVisit", "onLocationError",
11
+ ]
12
+
13
+ /// Delivers the plugin's events to the Worker's listeners and the page's. Use
14
+ /// it on the main thread.
15
+ final class TokamakLocationEvents {
16
+ private let host: TokamakHost
17
+ private var listeners: [String: [UUID: TokamakPluginReply]] = [:]
18
+ /// Positions that arrived while the Worker handled the previous update.
19
+ private var positions: [[String: Any]] = []
20
+ private var stationary: Bool?
21
+ private var updating = false
22
+ /// The messages of the problems last reported, by region id, or nil for
23
+ /// updates.
24
+ private var problems: [String?: Set<String>] = [:]
25
+
26
+ init(host: TokamakHost) {
27
+ self.host = host
28
+ }
29
+
30
+ /// Adds a page's or Worker's listener of `method`, returning its removal.
31
+ func listen(_ method: String, _ reply: @escaping TokamakPluginReply) -> (() -> Void)? {
32
+ guard listenerMethods.contains(method) else { return nil }
33
+ let key = UUID()
34
+ listeners[method, default: [:]][key] = reply
35
+ return { [weak self] in
36
+ self?.listeners[method]?.removeValue(forKey: key)
37
+ }
38
+ }
39
+
40
+ /// Delivers `location`, with Apple's `stationary` flag when the system
41
+ /// reports it. Positions that arrive while the Worker handles an update
42
+ /// reach it together as the next.
43
+ func update(_ location: CLLocation?, stationary: Bool? = nil) {
44
+ positions += location.map { [Self.position($0)] } ?? []
45
+ if let stationary {
46
+ self.stationary = stationary
47
+ }
48
+ if !updating {
49
+ sendUpdate()
50
+ }
51
+ }
52
+
53
+ private func sendUpdate() {
54
+ guard !positions.isEmpty || stationary == true else {
55
+ updating = false
56
+ return
57
+ }
58
+ var event: [String: Any] = ["positions": positions]
59
+ event["stationary"] = stationary
60
+ positions = []
61
+ stationary = nil
62
+ updating = true
63
+ send("onLocationUpdate", "update", event) { self.sendUpdate() }
64
+ }
65
+
66
+ func transition(_ id: String, entered: Bool, at date: Date) {
67
+ let event: [String: Any] = [
68
+ "id": id, "transition": entered ? "enter" : "exit", "timestamp": Self.milliseconds(date),
69
+ ]
70
+ send("onRegionTransition", "region", event)
71
+ }
72
+
73
+ func visit(_ visit: CLVisit) {
74
+ let event: [String: Any] = [
75
+ "latitude": visit.coordinate.latitude,
76
+ "longitude": visit.coordinate.longitude,
77
+ "accuracy": visit.horizontalAccuracy,
78
+ "arrival": visit.arrivalDate == .distantPast
79
+ ? NSNull() : Self.milliseconds(visit.arrivalDate),
80
+ "departure": visit.departureDate == .distantFuture
81
+ ? NSNull() : Self.milliseconds(visit.departureDate),
82
+ ]
83
+ send("onVisit", "visit", event)
84
+ }
85
+
86
+ /// Reports each of `problems` that updates, or the region `id`, did not
87
+ /// report last time.
88
+ func report(_ problems: [TokamakPluginError], region id: String? = nil) {
89
+ let previous = self.problems[id] ?? []
90
+ for problem in problems where !previous.contains(problem.message) {
91
+ fail(problem, region: id)
92
+ }
93
+ self.problems[id] = Set(problems.map(\.message))
94
+ }
95
+
96
+ /// Reports a background service's failure, which concerns the region `id`
97
+ /// when given.
98
+ func fail(_ error: TokamakPluginError, region id: String? = nil) {
99
+ var event: [String: Any] = ["name": error.name, "message": error.message]
100
+ event["id"] = id
101
+ send("onLocationError", "error", event)
102
+ }
103
+
104
+ private func send(
105
+ _ method: String, _ name: String, _ event: [String: Any], completion: @escaping () -> Void = {}
106
+ ) {
107
+ for reply in (listeners[method] ?? [:]).values {
108
+ reply(.success(event))
109
+ }
110
+ host.emit(name, event: event, timeout: eventDeadline) { result in
111
+ if case .failure(let error) = result {
112
+ print("location \(name) failed: \(error)")
113
+ }
114
+ completion()
115
+ }
116
+ }
117
+
118
+ /// `location` as the page's `Position`.
119
+ static func position(_ location: CLLocation) -> [String: Any] {
120
+ let coordinates = location.coordinate
121
+ return [
122
+ "coords": [
123
+ "latitude": coordinates.latitude,
124
+ "longitude": coordinates.longitude,
125
+ "accuracy": location.horizontalAccuracy,
126
+ "altitude": nullable(location.verticalAccuracy >= 0, location.altitude),
127
+ "altitudeAccuracy": nullable(location.verticalAccuracy >= 0, location.verticalAccuracy),
128
+ "heading": nullable(location.course >= 0, location.course),
129
+ "speed": nullable(location.speed >= 0, location.speed),
130
+ ],
131
+ "timestamp": milliseconds(location.timestamp),
132
+ ]
133
+ }
134
+
135
+ private static func nullable(_ available: Bool, _ value: Double) -> Any {
136
+ available ? value : NSNull()
137
+ }
138
+
139
+ private static func milliseconds(_ date: Date) -> Double {
140
+ date.timeIntervalSince1970 * 1000
141
+ }
142
+ }
143
+
144
+ extension TokamakPluginError {
145
+ /// Location could not be found, as `message` says.
146
+ static func locationUnavailable(_ message: String) -> Self {
147
+ Self(name: "NotReadableError", message: message)
148
+ }
149
+ }
@@ -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,37 @@ 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
+ /// Core Location delivers each visit to every manager's delegate.
243
+ func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
244
+ if manager === monitoring {
245
+ events.visit(visit)
246
+ }
247
+ }
248
+
249
+ /// Starts the significant changes and visits `config` names, and stops the
250
+ /// others. After a launch for one of their events, starting them again
251
+ /// delivers it.
252
+ private func apply(_ config: [String: Any]) {
253
+ if config["significantChanges"] as? Bool == true {
254
+ monitoring.startMonitoringSignificantLocationChanges()
255
+ } else {
256
+ monitoring.stopMonitoringSignificantLocationChanges()
257
+ }
258
+ if config["visits"] as? Bool == true {
259
+ monitoring.startMonitoringVisits()
260
+ } else {
261
+ monitoring.stopMonitoringVisits()
108
262
  }
109
263
  }
110
264
 
@@ -118,17 +272,17 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
118
272
 
119
273
  private func start(servicesEnabled: Bool) {
120
274
  guard servicesEnabled else {
121
- fail(unavailable("Location services are disabled"))
275
+ fail(.locationUnavailable("Location services are disabled"))
122
276
  return
123
277
  }
124
- let status = updates.authorizationStatus
278
+ let status = watching.authorizationStatus
125
279
  guard status == .notDetermined else {
126
280
  serve(status)
127
281
  return
128
282
  }
129
283
  do throws(TokamakPluginError) {
130
284
  try host.requireUI()
131
- updates.requestWhenInUseAuthorization()
285
+ watching.requestWhenInUseAuthorization()
132
286
  } catch {
133
287
  fail(error)
134
288
  }
@@ -140,7 +294,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
140
294
  switch status {
141
295
  case .authorizedAlways, .authorizedWhenInUse:
142
296
  if !watchers.isEmpty {
143
- updates.startUpdatingLocation()
297
+ watching.startUpdatingLocation()
144
298
  }
145
299
  locate()
146
300
  case .denied, .restricted:
@@ -157,7 +311,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
157
311
  private func locate() {
158
312
  for (key, request) in current where request.timer == nil {
159
313
  if let fix = fixes.location, -fix.timestamp.timeIntervalSinceNow <= request.maximumAge {
160
- finish(key, .success(position(fix)))
314
+ finish(key, .success(TokamakLocationEvents.position(fix)))
161
315
  continue
162
316
  }
163
317
  let timer = DispatchWorkItem { [weak self] in self?.finish(key, .failure(Self.timedOut)) }
@@ -185,7 +339,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
185
339
 
186
340
  private func fail(_ error: TokamakPluginError) {
187
341
  deliver(.failure(error), from: fixes)
188
- deliver(.failure(error), from: updates)
342
+ deliver(.failure(error), from: watching)
189
343
  }
190
344
 
191
345
  private func finish(_ key: UUID, _ result: Result<Any?, TokamakPluginError>) {
@@ -197,40 +351,180 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
197
351
  }
198
352
  }
199
353
 
200
- private func locationError(_ error: Error) -> TokamakPluginError {
201
- if (error as NSError).code == CLError.Code.denied.rawValue {
202
- return Self.permissionDenied
354
+ /// The app's in-use and background permission, and the precision it gets.
355
+ private func permission() -> [String: Any] {
356
+ let status = watching.authorizationStatus
357
+ return [
358
+ "inUse": Self.state(status),
359
+ "background": backgroundPermission(status),
360
+ "precision": watching.accuracyAuthorization == .reducedAccuracy ? "approximate" : "precise",
361
+ ]
362
+ }
363
+
364
+ private func backgroundPermission(_ status: CLAuthorizationStatus) -> String {
365
+ if status == .authorizedAlways { return "granted" }
366
+ let asks = status == .notDetermined ? Self.declaresAlways : canUpgradeToAlways(status)
367
+ return asks ? "prompt" : "denied"
368
+ }
369
+
370
+ /// Whether asking for Always shows a prompt: once, from When In Use, on iOS.
371
+ private func canUpgradeToAlways(_ status: CLAuthorizationStatus) -> Bool {
372
+ #if os(iOS)
373
+ status == .authorizedWhenInUse && Self.declaresAlways && !askedForAlways
374
+ #else
375
+ false
376
+ #endif
377
+ }
378
+
379
+ private var hasPermission: Bool {
380
+ ![.notDetermined, .denied, .restricted].contains(watching.authorizationStatus)
381
+ }
382
+
383
+ /// Throws `NotAllowedError` unless the app has location permission.
384
+ private func requirePermission() throws(TokamakPluginError) {
385
+ guard hasPermission else {
386
+ throw TokamakPluginError(
387
+ name: "NotAllowedError", message: "Location permission has not been granted")
203
388
  }
204
- return unavailable(error.localizedDescription)
205
389
  }
206
390
 
207
- private func unavailable(_ message: String) -> TokamakPluginError {
208
- TokamakPluginError(name: "NotReadableError", message: message)
391
+ private func holdAlwaysSession() {
392
+ #if os(iOS)
393
+ guard #available(iOS 18, *) else { return }
394
+ let always = watching.authorizationStatus == .authorizedAlways
395
+ if always && alwaysSession == nil {
396
+ alwaysSession = CLServiceSession(authorization: .always)
397
+ } else if !always {
398
+ (alwaysSession as? CLServiceSession)?.invalidate()
399
+ alwaysSession = nil
400
+ }
401
+ #endif
209
402
  }
210
403
 
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
- ]
404
+ private static func state(_ status: CLAuthorizationStatus) -> String {
405
+ switch status {
406
+ case .notDetermined: "prompt"
407
+ case .denied, .restricted: "denied"
408
+ default: "granted"
409
+ }
410
+ }
411
+
412
+ /// Whether the Info.plist declares Always permission, as the `background`
413
+ /// build setting does on iOS and every macOS build does.
414
+ private static let declaresAlways =
415
+ Bundle.main.object(forInfoDictionaryKey: "NSLocationAlwaysAndWhenInUseUsageDescription") != nil
416
+
417
+ private var askedForAlways: Bool {
418
+ defaults.bool(forKey: askedForAlwaysKey)
231
419
  }
232
420
 
233
- private func nullable(_ available: Bool, _ value: Double) -> Any {
234
- available ? value : NSNull()
421
+ /// Asks for in-use permission while the user has not answered, or for
422
+ /// Always permission once from When In Use, then for temporary precise
423
+ /// location when `options` name a purpose.
424
+ private func requestPermission(
425
+ _ options: [String: Any], _ reply: @escaping TokamakPluginReply
426
+ ) throws(TokamakPluginError) {
427
+ let always = options["background"] as? Bool == true
428
+ if always && !Self.declaresAlways {
429
+ throw .notSupported(
430
+ "Background location needs the location plugin's background build setting")
431
+ }
432
+ let prompt = Prompt(purpose: try Self.purpose(options), reply: reply)
433
+ let status = watching.authorizationStatus
434
+ let upgrades = always && canUpgradeToAlways(status)
435
+ guard status == .notDetermined || upgrades else {
436
+ respond(to: prompt)
437
+ return
438
+ }
439
+ try host.requireUI()
440
+ prompts[UUID()] = prompt
441
+ if always {
442
+ defaults.set(true, forKey: askedForAlwaysKey)
443
+ watching.requestAlwaysAuthorization()
444
+ } else {
445
+ watching.requestWhenInUseAuthorization()
446
+ }
447
+ }
448
+
449
+ /// The purpose key of the temporary precise location `options` ask for.
450
+ private static func purpose(_ options: [String: Any]) throws(TokamakPluginError) -> String? {
451
+ guard options["precision"] as? String == "precise" else { return nil }
452
+ let purposes =
453
+ Bundle.main.object(forInfoDictionaryKey: "NSLocationTemporaryUsageDescriptionDictionary")
454
+ as? [String: Any]
455
+ guard let purpose = options["purpose"] as? String, purposes?[purpose] != nil else {
456
+ throw TokamakPluginError(
457
+ name: "TypeError",
458
+ message: "precise location needs a purpose from the accuracy-purposes build setting")
459
+ }
460
+ return purpose
461
+ }
462
+
463
+ #if os(iOS)
464
+ @objc private func willResignActive() {
465
+ shown = Array(prompts.keys)
466
+ }
467
+
468
+ /// Answers the prompts that were showing, as the system reports no change
469
+ /// when the user keeps When In Use.
470
+ @objc private func didBecomeActive() {
471
+ let answered = shown
472
+ shown = []
473
+ guard !answered.isEmpty else { return }
474
+ DispatchQueue.main.asyncAfter(deadline: .now() + authorizationChangeDelay) {
475
+ self.answer(answered)
476
+ }
477
+ }
478
+ #endif
479
+
480
+ /// Answers the prompts `keys` that still wait.
481
+ private func answer(_ keys: [UUID]) {
482
+ for key in keys {
483
+ if let prompt = prompts.removeValue(forKey: key) {
484
+ respond(to: prompt)
485
+ }
486
+ }
487
+ }
488
+
489
+ /// Replies to `prompt` with the app's permission, first asking for temporary
490
+ /// precise location for its purpose when the app has approximate location.
491
+ private func respond(to prompt: Prompt) {
492
+ guard let purpose = prompt.purpose, hasPermission,
493
+ watching.accuracyAuthorization == .reducedAccuracy
494
+ else {
495
+ prompt.reply(.success(permission()))
496
+ return
497
+ }
498
+ do throws(TokamakPluginError) {
499
+ try host.requireUI()
500
+ } catch {
501
+ prompt.reply(.failure(error))
502
+ return
503
+ }
504
+ watching.requestTemporaryFullAccuracyAuthorization(withPurposeKey: purpose) { _ in
505
+ DispatchQueue.main.async { prompt.reply(.success(self.permission())) }
506
+ }
507
+ }
508
+
509
+ /// Opens the app's page of the system settings on iOS, and Location Services on macOS.
510
+ private func openSettings(_ reply: @escaping TokamakPluginReply) {
511
+ let notOpened = TokamakPluginError(
512
+ name: "OperationError", message: "The location settings could not be opened")
513
+ #if os(iOS)
514
+ UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) { opened in
515
+ reply(opened ? .success(nil) : .failure(notOpened))
516
+ }
517
+ #else
518
+ let page = "com.apple.preference.security?Privacy_LocationServices"
519
+ let opened = URL(string: "x-apple.systempreferences:\(page)").map(NSWorkspace.shared.open)
520
+ reply(opened == true ? .success(nil) : .failure(notOpened))
521
+ #endif
522
+ }
523
+
524
+ private func locationError(_ error: Error) -> TokamakPluginError {
525
+ if (error as NSError).code == CLError.Code.denied.rawValue {
526
+ return Self.permissionDenied
527
+ }
528
+ return .locationUnavailable(error.localizedDescription)
235
529
  }
236
530
  }