@tokamakdev/plugin-location 0.1.0-beta.100

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.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @tokamakdev/plugin-location
2
+
3
+ Location for Tokamak applications and the web.
4
+
5
+ Add it to your project's dependencies:
6
+
7
+ ```sh
8
+ npm install @tokamakdev/plugin-location
9
+ ```
10
+
11
+ ```ts
12
+ import { location } from "@tokamakdev/plugin-location";
13
+
14
+ const position = await location.getCurrentPosition();
15
+
16
+ const stop = location.watchPosition(
17
+ (next) => console.log(next.coords.latitude, next.coords.longitude),
18
+ console.error,
19
+ );
20
+ ```
21
+
22
+ `getCurrentPosition` takes the Geolocation API's `maximumAge` and `timeout`
23
+ options. It resolves with a recent position no older than `maximumAge`
24
+ milliseconds, 0 by default, or takes a new one, and rejects with
25
+ `TimeoutError` when none arrives within `timeout` milliseconds, unlimited by
26
+ default. The timeout starts once the user allows location.
27
+
28
+ ## Off screen
29
+
30
+ The plugin asks for location while the app is in use only. While the app is
31
+ off screen on iOS or Android, as when Worker code runs in the background, a
32
+ call that would ask the user for permission rejects with `NeedsUIError`. With
33
+ permission granted, the platforms stop locating an app that is off screen
34
+ without background location permission, which the plugin does not request, so
35
+ `getCurrentPosition` rejects with `NotAllowedError` there. iOS still locates
36
+ the app for a few seconds after it leaves the screen.
37
+
38
+ ## Platforms
39
+
40
+ The web implementation uses `navigator.geolocation`. Native Tokamak builds use
41
+ Core Location on Apple platforms, `LocationManager` on Android, and WebView2's
42
+ location implementation on Windows. Calling the API on a platform without
43
+ location support throws `NotSupportedError`.
@@ -0,0 +1,4 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
3
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
4
+ </manifest>
@@ -0,0 +1,191 @@
1
+ package com.tokamak.plugins.location
2
+
3
+ import android.Manifest
4
+ import android.content.Context
5
+ import android.content.pm.PackageManager
6
+ import android.location.Location
7
+ import android.location.LocationListener
8
+ import android.location.LocationManager
9
+ import android.os.Bundle
10
+ import android.os.CancellationSignal
11
+ import android.os.Handler
12
+ import android.os.Looper
13
+ import android.os.SystemClock
14
+ import com.tokamak.runtime.TokamakHost
15
+ import com.tokamak.runtime.TokamakPlugin
16
+ import com.tokamak.runtime.TokamakPluginError
17
+ import com.tokamak.runtime.TokamakPluginReply
18
+ import org.json.JSONObject
19
+
20
+ class TokamakLocationPlugin(
21
+ private val host: TokamakHost,
22
+ ) : TokamakPlugin {
23
+ override val id = "location"
24
+
25
+ private val context = host.context
26
+ private val manager =
27
+ context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
28
+ private val handler = Handler(Looper.getMainLooper())
29
+
30
+ override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
31
+ if (method != "getCurrentPosition") {
32
+ super.call(method, arguments, reply)
33
+ return
34
+ }
35
+ val options = arguments as? JSONObject
36
+ withPermission { granted ->
37
+ if (granted) currentPosition(options, reply) else reply(permissionDenied())
38
+ }
39
+ }
40
+
41
+ override fun subscribe(
42
+ method: String,
43
+ arguments: Any?,
44
+ reply: TokamakPluginReply,
45
+ ): () -> Unit {
46
+ if (method != "watchPosition") return super.subscribe(method, arguments, reply)
47
+ var cancelled = false
48
+ var stop = {}
49
+ withPermission { granted ->
50
+ if (!granted) {
51
+ reply(permissionDenied())
52
+ } else if (!cancelled) {
53
+ stop = watchPosition(reply)
54
+ }
55
+ }
56
+ return {
57
+ cancelled = true
58
+ stop()
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Passes [action] whether the app may read fine or coarse location, asking when it may not.
64
+ * Throws `NeedsUIError` when asking would show UI while the app is in the background.
65
+ */
66
+ private fun withPermission(action: (Boolean) -> Unit) {
67
+ if (PERMISSIONS.any { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) {
68
+ action(true)
69
+ return
70
+ }
71
+ host.requestPermissions(PERMISSIONS) { granted -> action(granted.containsValue(true)) }
72
+ }
73
+
74
+ /**
75
+ * Replies with a fix no older than the `maximumAge` option, taking a new one when there is
76
+ * none, or fails after the `timeout` option.
77
+ */
78
+ private fun currentPosition(options: JSONObject?, reply: TokamakPluginReply) {
79
+ val provider = availableProvider(reply) ?: return
80
+ val maximumAge = options?.optLong("maximumAge", 0) ?: 0
81
+ val recent = runCatching { manager.getLastKnownLocation(provider) }.getOrNull()
82
+ if (recent != null && ageMillis(recent) <= maximumAge) {
83
+ reply(Result.success(position(recent)))
84
+ return
85
+ }
86
+ val cancellation = CancellationSignal()
87
+ val timeout = Runnable {
88
+ cancellation.cancel()
89
+ reply(Result.failure(TokamakPluginError("TimeoutError", "Location was not found in time")))
90
+ }
91
+ handler.postDelayed(timeout, options?.optLong("timeout", UNLIMITED) ?: UNLIMITED)
92
+ runCatching {
93
+ manager.getCurrentLocation(
94
+ provider,
95
+ cancellation,
96
+ context.mainExecutor,
97
+ ) { location ->
98
+ handler.removeCallbacks(timeout)
99
+ reply(if (location == null) missingFix() else Result.success(position(location)))
100
+ }
101
+ }.onFailure {
102
+ handler.removeCallbacks(timeout)
103
+ reply(locationFailure(it))
104
+ }
105
+ }
106
+
107
+ private fun ageMillis(location: Location): Long =
108
+ (SystemClock.elapsedRealtimeNanos() - location.elapsedRealtimeNanos) / 1_000_000
109
+
110
+ private fun watchPosition(reply: TokamakPluginReply): () -> Unit {
111
+ val provider = availableProvider(reply) ?: return {}
112
+ val listener = object : LocationListener {
113
+ override fun onLocationChanged(location: Location) {
114
+ reply(Result.success(position(location)))
115
+ }
116
+
117
+ override fun onProviderDisabled(provider: String) {
118
+ reply(unavailable("Location provider is unavailable"))
119
+ }
120
+
121
+ override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
122
+ }
123
+ runCatching {
124
+ manager.requestLocationUpdates(provider, 1000, 0F, listener, Looper.getMainLooper())
125
+ }.onFailure {
126
+ reply(locationFailure(it))
127
+ return {}
128
+ }
129
+ return { manager.removeUpdates(listener) }
130
+ }
131
+
132
+ /** The provider to use, or null once [reply] has the reason there is none. */
133
+ private fun availableProvider(reply: TokamakPluginReply): String? {
134
+ val provider = runCatching(::provider).getOrElse {
135
+ reply(locationFailure(it))
136
+ return null
137
+ }
138
+ if (provider == null) reply(unavailable("No location provider is available"))
139
+ return provider
140
+ }
141
+
142
+ private fun provider(): String? =
143
+ listOf(
144
+ LocationManager.GPS_PROVIDER,
145
+ LocationManager.NETWORK_PROVIDER,
146
+ LocationManager.FUSED_PROVIDER,
147
+ ).firstOrNull(manager::isProviderEnabled)
148
+ ?: manager.getProviders(true).firstOrNull()
149
+
150
+ private fun position(location: Location): Map<String, Any?> =
151
+ mapOf(
152
+ "coords" to
153
+ mapOf(
154
+ "latitude" to location.latitude,
155
+ "longitude" to location.longitude,
156
+ "accuracy" to location.accuracy.toDouble(),
157
+ "altitude" to location.altitude.takeIf { location.hasAltitude() },
158
+ "altitudeAccuracy" to
159
+ location.verticalAccuracyMeters.toDouble()
160
+ .takeIf { location.hasVerticalAccuracy() },
161
+ "heading" to location.bearing.toDouble().takeIf { location.hasBearing() },
162
+ "speed" to location.speed.toDouble().takeIf { location.hasSpeed() },
163
+ ),
164
+ "timestamp" to location.time,
165
+ )
166
+
167
+ private fun permissionDenied(): Result<Any?> =
168
+ Result.failure(TokamakPluginError("NotAllowedError", "Location permission was denied"))
169
+
170
+ /** Why no fix came: Android gives none in the background without background location permission. */
171
+ private fun missingFix(): Result<Any?> =
172
+ if (host.isInForeground || context.checkSelfPermission(BACKGROUND) == PackageManager.PERMISSION_GRANTED) {
173
+ unavailable("Location is unavailable")
174
+ } else {
175
+ Result.failure(TokamakPluginError("NotAllowedError", "Location permission does not cover the background"))
176
+ }
177
+
178
+ private fun unavailable(message: String): Result<Any?> =
179
+ Result.failure(TokamakPluginError("NotReadableError", message))
180
+
181
+ private fun locationFailure(error: Throwable): Result<Any?> =
182
+ if (error is SecurityException) permissionDenied()
183
+ else unavailable(error.message ?: "Location is unavailable")
184
+
185
+ private companion object {
186
+ /** The `timeout` that never expires, as the page sends it. */
187
+ const val UNLIMITED = 0xffffffffL
188
+ val PERMISSIONS = setOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
189
+ const val BACKGROUND = Manifest.permission.ACCESS_BACKGROUND_LOCATION
190
+ }
191
+ }
@@ -0,0 +1,236 @@
1
+ import CoreLocation
2
+ import Foundation
3
+
4
+ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
5
+ CLLocationManagerDelegate
6
+ {
7
+ let id = "location"
8
+
9
+ private static let permissionDenied = TokamakPluginError(
10
+ name: "NotAllowedError",
11
+ message: "Location permission was denied"
12
+ )
13
+ private static let timedOut = TokamakPluginError(
14
+ name: "TimeoutError",
15
+ message: "Location was not found in time"
16
+ )
17
+
18
+ /// A current position request, timed from when locating starts.
19
+ private struct Request {
20
+ let reply: TokamakPluginReply
21
+ let maximumAge: TimeInterval
22
+ let timeout: TimeInterval
23
+ var timer: DispatchWorkItem?
24
+ }
25
+
26
+ private let host: TokamakHost
27
+ /// Updates watchers.
28
+ private let updates = CLLocationManager()
29
+ /// Takes single fixes for current position requests, apart from watching.
30
+ private let fixes = CLLocationManager()
31
+ private var current: [UUID: Request] = [:]
32
+ private var watchers: [UUID: TokamakPluginReply] = [:]
33
+
34
+ init(host: TokamakHost) {
35
+ self.host = host
36
+ super.init()
37
+ for manager in [updates, fixes] {
38
+ manager.delegate = self
39
+ manager.desiredAccuracy = kCLLocationAccuracyBest
40
+ }
41
+ }
42
+
43
+ func call(
44
+ method: String,
45
+ arguments: Any,
46
+ reply: @escaping TokamakPluginReply
47
+ ) {
48
+ guard method == "getCurrentPosition" else {
49
+ reply(.failure(.notSupported("\(id).\(method) is not supported")))
50
+ return
51
+ }
52
+ 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()
59
+ }
60
+
61
+ func subscribe(
62
+ method: String,
63
+ arguments: Any,
64
+ reply: @escaping TokamakPluginReply
65
+ ) throws(TokamakPluginError) -> (() -> Void) {
66
+ guard method == "watchPosition" else {
67
+ throw .notSupported("\(id).\(method) is not supported")
68
+ }
69
+ let subscription = UUID()
70
+ watchers[subscription] = reply
71
+ start()
72
+ return { [weak self] in
73
+ self?.watchers.removeValue(forKey: subscription)
74
+ if self?.watchers.isEmpty == true {
75
+ self?.updates.stopUpdatingLocation()
76
+ }
77
+ }
78
+ }
79
+
80
+ /// Also called when each manager is created, before anything asks for a
81
+ /// position. Both managers report the app's one authorization.
82
+ func locationManagerDidChangeAuthorization(
83
+ _ manager: CLLocationManager
84
+ ) {
85
+ guard manager === updates, !current.isEmpty || !watchers.isEmpty else { return }
86
+ serve(manager.authorizationStatus)
87
+ }
88
+
89
+ func locationManager(
90
+ _ manager: CLLocationManager,
91
+ didUpdateLocations locations: [CLLocation]
92
+ ) {
93
+ guard let location = locations.last else { return }
94
+ deliver(.success(position(location)), from: manager)
95
+ }
96
+
97
+ /// A fix that is not yet available is taken again; updates continue.
98
+ func locationManager(
99
+ _ manager: CLLocationManager,
100
+ didFailWithError error: Error
101
+ ) {
102
+ guard (error as NSError).code == CLError.Code.locationUnknown.rawValue else {
103
+ deliver(.failure(locationError(error)), from: manager)
104
+ return
105
+ }
106
+ if manager === fixes && !current.isEmpty {
107
+ fixes.requestLocation()
108
+ }
109
+ }
110
+
111
+ /// Checks location services off the main thread, which the check can block.
112
+ private func start() {
113
+ DispatchQueue.global(qos: .userInitiated).async {
114
+ let enabled = CLLocationManager.locationServicesEnabled()
115
+ DispatchQueue.main.async { self.start(servicesEnabled: enabled) }
116
+ }
117
+ }
118
+
119
+ private func start(servicesEnabled: Bool) {
120
+ guard servicesEnabled else {
121
+ fail(unavailable("Location services are disabled"))
122
+ return
123
+ }
124
+ let status = updates.authorizationStatus
125
+ guard status == .notDetermined else {
126
+ serve(status)
127
+ return
128
+ }
129
+ do throws(TokamakPluginError) {
130
+ try host.requireUI()
131
+ updates.requestWhenInUseAuthorization()
132
+ } catch {
133
+ fail(error)
134
+ }
135
+ }
136
+
137
+ /// Locates for the waiting requests and watchers when `status` allows it,
138
+ /// and fails them when it forbids it.
139
+ private func serve(_ status: CLAuthorizationStatus) {
140
+ switch status {
141
+ case .authorizedAlways, .authorizedWhenInUse:
142
+ if !watchers.isEmpty {
143
+ updates.startUpdatingLocation()
144
+ }
145
+ locate()
146
+ case .denied, .restricted:
147
+ fail(Self.permissionDenied)
148
+ case .notDetermined:
149
+ break
150
+ @unknown default:
151
+ fail(.notSupported("Location authorization is not supported"))
152
+ }
153
+ }
154
+
155
+ /// Answers each new request with a fix no older than its `maximumAge`, and
156
+ /// takes a fix for the rest, each failing after its `timeout`.
157
+ private func locate() {
158
+ for (key, request) in current where request.timer == nil {
159
+ if let fix = fixes.location, -fix.timestamp.timeIntervalSinceNow <= request.maximumAge {
160
+ finish(key, .success(position(fix)))
161
+ continue
162
+ }
163
+ let timer = DispatchWorkItem { [weak self] in self?.finish(key, .failure(Self.timedOut)) }
164
+ current[key]?.timer = timer
165
+ DispatchQueue.main.asyncAfter(deadline: .now() + request.timeout, execute: timer)
166
+ }
167
+ if !current.isEmpty {
168
+ fixes.requestLocation()
169
+ }
170
+ }
171
+
172
+ /// Answers the waiting requests with a fix, or the watchers with an update.
173
+ private func deliver(_ result: Result<Any?, TokamakPluginError>, from manager: CLLocationManager)
174
+ {
175
+ guard manager === fixes else {
176
+ for reply in watchers.values {
177
+ reply(result)
178
+ }
179
+ return
180
+ }
181
+ for key in current.keys {
182
+ finish(key, result)
183
+ }
184
+ }
185
+
186
+ private func fail(_ error: TokamakPluginError) {
187
+ deliver(.failure(error), from: fixes)
188
+ deliver(.failure(error), from: updates)
189
+ }
190
+
191
+ private func finish(_ key: UUID, _ result: Result<Any?, TokamakPluginError>) {
192
+ guard let request = current.removeValue(forKey: key) else { return }
193
+ request.timer?.cancel()
194
+ request.reply(result)
195
+ if current.isEmpty {
196
+ fixes.stopUpdatingLocation()
197
+ }
198
+ }
199
+
200
+ private func locationError(_ error: Error) -> TokamakPluginError {
201
+ if (error as NSError).code == CLError.Code.denied.rawValue {
202
+ return Self.permissionDenied
203
+ }
204
+ return unavailable(error.localizedDescription)
205
+ }
206
+
207
+ private func unavailable(_ message: String) -> TokamakPluginError {
208
+ TokamakPluginError(name: "NotReadableError", message: message)
209
+ }
210
+
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
+ ]
231
+ }
232
+
233
+ private func nullable(_ available: Bool, _ value: Double) -> Any {
234
+ available ? value : NSNull()
235
+ }
236
+ }
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>NSLocationWhenInUseUsageDescription</key>
6
+ <string>This app uses your location when you request location features.</string>
7
+ </dict>
8
+ </plist>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>NSLocationUsageDescription</key>
6
+ <string>This app uses your location when you request location features.</string>
7
+ </dict>
8
+ </plist>
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@tokamakdev/plugin-location",
3
+ "version": "0.1.0-beta.100",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/mantty/tokamak.git",
7
+ "directory": "plugins/location"
8
+ },
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "peerDependencies": {
14
+ "@tokamakdev/plugin": "0.1.0-beta.100"
15
+ },
16
+ "files": [
17
+ "android",
18
+ "apple",
19
+ "src",
20
+ "web",
21
+ "tokamak-plugin.json",
22
+ "README.md"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { Plugin } from "@tokamakdev/plugin";
2
+ import * as web from "../web/index.js";
3
+
4
+ export interface Coordinates {
5
+ readonly latitude: number;
6
+ readonly longitude: number;
7
+ readonly accuracy: number;
8
+ readonly altitude: number | null;
9
+ readonly altitudeAccuracy: number | null;
10
+ readonly heading: number | null;
11
+ readonly speed: number | null;
12
+ }
13
+
14
+ export interface Position {
15
+ readonly coords: Coordinates;
16
+ readonly timestamp: number;
17
+ }
18
+
19
+ /** Options for `getCurrentPosition`, as the Geolocation API reads them. */
20
+ export interface PositionOptions {
21
+ /** The age in milliseconds of a recent position to accept instead of a new one; 0 by default. */
22
+ readonly maximumAge?: number;
23
+ /** Milliseconds to wait for a position before rejecting with `TimeoutError`; unlimited by default. */
24
+ readonly timeout?: number;
25
+ }
26
+
27
+ export type PositionCallback = (position: Position) => void;
28
+ export type PositionErrorCallback = (error: DOMException) => void;
29
+
30
+ class Location extends Plugin {
31
+ constructor() {
32
+ super("location");
33
+ }
34
+
35
+ getCurrentPosition(options: PositionOptions = {}): Promise<Position> {
36
+ if (!this.hasNativeTransport) return web.getCurrentPosition(options);
37
+ return this.call("getCurrentPosition", {
38
+ maximumAge: milliseconds(options.maximumAge ?? 0),
39
+ timeout: milliseconds(options.timeout ?? unlimited),
40
+ });
41
+ }
42
+
43
+ watchPosition(next: PositionCallback, error: PositionErrorCallback): () => void {
44
+ if (!this.hasNativeTransport) return web.watchPosition(next, error);
45
+ return this.listen("watchPosition", next, error);
46
+ }
47
+ }
48
+
49
+ const unlimited = 0xffffffff;
50
+
51
+ /** `value` clamped to whole milliseconds, as the Geolocation API reads its options. */
52
+ function milliseconds(value: number): number {
53
+ return Number.isNaN(value) ? 0 : Math.min(Math.max(Math.round(value), 0), unlimited);
54
+ }
55
+
56
+ export const location = new Location();
@@ -0,0 +1,21 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "location",
4
+ "platforms": {
5
+ "macos": {
6
+ "class": "TokamakLocationPlugin",
7
+ "sources": ["apple/LocationPlugin.swift"],
8
+ "plist": "apple/macos/Info.plist"
9
+ },
10
+ "ios": {
11
+ "class": "TokamakLocationPlugin",
12
+ "sources": ["apple/LocationPlugin.swift"],
13
+ "plist": "apple/ios/Info.plist"
14
+ },
15
+ "android": {
16
+ "class": "com.tokamak.plugins.location.TokamakLocationPlugin",
17
+ "sources": ["android/LocationPlugin.kt"],
18
+ "manifest": "android/AndroidManifest.xml"
19
+ }
20
+ }
21
+ }
package/web/index.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type {
2
+ Position,
3
+ PositionCallback,
4
+ PositionErrorCallback,
5
+ PositionOptions,
6
+ } from "../src/index.js";
7
+
8
+ export function getCurrentPosition(options: PositionOptions): Promise<Position> {
9
+ return new Promise((resolve, reject) => {
10
+ const browserLocation = browserLocationApi();
11
+ browserLocation.getCurrentPosition(
12
+ (position) => {
13
+ resolve(copyPosition(position));
14
+ },
15
+ (error) => {
16
+ reject(positionError(error));
17
+ },
18
+ options,
19
+ );
20
+ });
21
+ }
22
+
23
+ export function watchPosition(
24
+ next: PositionCallback,
25
+ error: PositionErrorCallback,
26
+ ): () => void {
27
+ const browserLocation = browserLocationApi();
28
+ const id = browserLocation.watchPosition(
29
+ (position) => {
30
+ next(copyPosition(position));
31
+ },
32
+ (failure) => {
33
+ error(positionError(failure));
34
+ },
35
+ );
36
+ return () => {
37
+ browserLocation.clearWatch(id);
38
+ };
39
+ }
40
+
41
+ function browserLocationApi(): Geolocation {
42
+ const root: { navigator?: { geolocation?: Geolocation } } = globalThis;
43
+ const browserLocation = root.navigator?.geolocation;
44
+ if (browserLocation) return browserLocation;
45
+ throw new DOMException("Location is unavailable", "NotSupportedError");
46
+ }
47
+
48
+ function copyPosition(position: GeolocationPosition): Position {
49
+ const { coords } = position;
50
+ return {
51
+ coords: {
52
+ latitude: coords.latitude,
53
+ longitude: coords.longitude,
54
+ accuracy: coords.accuracy,
55
+ altitude: coords.altitude,
56
+ altitudeAccuracy: coords.altitudeAccuracy,
57
+ heading: coords.heading,
58
+ speed: coords.speed,
59
+ },
60
+ timestamp: position.timestamp,
61
+ };
62
+ }
63
+
64
+ function positionError(error: GeolocationPositionError): DOMException {
65
+ const name = {
66
+ [error.PERMISSION_DENIED]: "NotAllowedError",
67
+ [error.POSITION_UNAVAILABLE]: "NotReadableError",
68
+ [error.TIMEOUT]: "TimeoutError",
69
+ }[error.code];
70
+ return new DOMException(error.message, name ?? "UnknownError");
71
+ }