@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.
@@ -0,0 +1,137 @@
1
+ import CoreLocation
2
+ import Foundation
3
+
4
+ /// The JSON of the running updates' options, as `startUpdates` received them.
5
+ private let updatesKey = "tokamak.location.updates"
6
+
7
+ /// Continuous updates from `CLLocationUpdate.liveUpdates`. iOS launches an app
8
+ /// it ended while they ran for the next update, so the options persist until
9
+ /// `stop`, and `resume` starts them again at launch. Use it on the main thread.
10
+ final class TokamakLocationUpdates {
11
+ private let events: TokamakLocationEvents
12
+ private let defaults = UserDefaults.standard
13
+ private var task: Task<Void, Never>?
14
+ #if os(iOS)
15
+ /// Keeps the app in use while updates run off screen, with the location
16
+ /// indicator.
17
+ private var session: CLBackgroundActivitySession?
18
+ #endif
19
+
20
+ init(events: TokamakLocationEvents) {
21
+ self.events = events
22
+ }
23
+
24
+ /// The options of the running updates, or nil.
25
+ var options: [String: Any]? {
26
+ defaults.data(forKey: updatesKey)
27
+ .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }
28
+ }
29
+
30
+ /// Starts updates with `options`, replacing any running.
31
+ func start(_ options: [String: Any]) throws(TokamakPluginError) {
32
+ let configuration = try Self.configuration(options)
33
+ stop()
34
+ defaults.set(try? JSONSerialization.data(withJSONObject: options), forKey: updatesKey)
35
+ run(configuration)
36
+ }
37
+
38
+ /// Starts the updates that ran when the app last ended. The system keeps
39
+ /// their session for only a few seconds after it launches the app, so call
40
+ /// it before launch finishes.
41
+ func resume() {
42
+ guard let options, let configuration = try? Self.configuration(options) else { return }
43
+ run(configuration)
44
+ }
45
+
46
+ func stop() {
47
+ defaults.removeObject(forKey: updatesKey)
48
+ task?.cancel()
49
+ task = nil
50
+ #if os(iOS)
51
+ session?.invalidate()
52
+ session = nil
53
+ #endif
54
+ events.report([])
55
+ }
56
+
57
+ private func run(_ configuration: CLLocationUpdate.LiveConfiguration) {
58
+ #if os(iOS)
59
+ if Self.declaresBackgroundMode {
60
+ session = CLBackgroundActivitySession()
61
+ }
62
+ #endif
63
+ task = Task { @MainActor [weak self] in
64
+ do {
65
+ for try await update in CLLocationUpdate.liveUpdates(configuration) {
66
+ self?.receive(update)
67
+ }
68
+ } catch {
69
+ if !Task.isCancelled {
70
+ self?.events.fail(
71
+ .locationUnavailable(error.localizedDescription))
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ private func receive(_ update: CLLocationUpdate) {
78
+ guard #available(iOS 18, macOS 15, *) else {
79
+ if let location = update.location {
80
+ events.update(location)
81
+ }
82
+ return
83
+ }
84
+ events.report(Self.problems(update))
85
+ if update.location != nil || update.stationary {
86
+ events.update(update.location, stationary: update.stationary)
87
+ }
88
+ }
89
+
90
+ /// The problems that suspend updates, which `update` names.
91
+ @available(iOS 18, macOS 15, *)
92
+ private static func problems(_ update: CLLocationUpdate) -> [TokamakPluginError] {
93
+ let flags = [
94
+ (update.authorizationDenied, "NotAllowedError", "Location permission was denied"),
95
+ (update.authorizationDeniedGlobally, "NotAllowedError", "Location services are off"),
96
+ (update.authorizationRestricted, "NotAllowedError", "Location is restricted on this device"),
97
+ (
98
+ update.insufficientlyInUse, "NotAllowedError",
99
+ "Updates need the app in use, or Always permission"
100
+ ),
101
+ (update.accuracyLimited, "NotAllowedError", "Approximate location limits updates"),
102
+ (update.locationUnavailable, "NotReadableError", "Location is unavailable"),
103
+ ]
104
+ return flags.filter(\.0).map { TokamakPluginError(name: $0.1, message: $0.2) }
105
+ }
106
+
107
+ /// The live update configuration `options` name as their `activity`.
108
+ private static func configuration(
109
+ _ options: [String: Any]
110
+ ) throws(TokamakPluginError) -> CLLocationUpdate.LiveConfiguration {
111
+ switch options["activity"] as? String ?? "default" {
112
+ case "default": return .default
113
+ case "automotiveNavigation": return .automotiveNavigation
114
+ case "otherNavigation": return .otherNavigation
115
+ case "fitness": return .fitness
116
+ case "airborne": return .airborne
117
+ case "maritime":
118
+ // The SDKs that have `maritime` ship with Swift 6.4.
119
+ #if compiler(>=6.4)
120
+ if #available(iOS 27, macOS 27, *) {
121
+ return .maritime
122
+ }
123
+ #endif
124
+ throw .notSupported("The maritime activity needs iOS 27 or macOS 27")
125
+ default:
126
+ throw TokamakPluginError(name: "TypeError", message: "activity is not a live configuration")
127
+ }
128
+ }
129
+
130
+ #if os(iOS)
131
+ /// Whether the app declares the `location` background mode, as the
132
+ /// `background-updates` build setting does.
133
+ private static let declaresBackgroundMode =
134
+ (Bundle.main.object(forInfoDictionaryKey: "UIBackgroundModes") as? [String])?
135
+ .contains("location") == true
136
+ #endif
137
+ }
@@ -0,0 +1,19 @@
1
+ /** The location plugin's build settings, with their defaults applied. */
2
+ export interface Settings {
3
+ background: boolean;
4
+ "background-updates": boolean;
5
+ precision: "precise" | "approximate";
6
+ "when-in-use-description"?: string;
7
+ "always-description"?: string;
8
+ "accuracy-purposes"?: Record<string, string>;
9
+ }
10
+
11
+ /** The file that declares what the settings ask for, the plugin section key naming it, and notices. */
12
+ export interface Declarations {
13
+ key: "plist" | "manifest";
14
+ name: string;
15
+ contents: string;
16
+ notices: string[];
17
+ }
18
+
19
+ export function declarations(platform: string, settings: Settings): Declarations | { problems: string[] };
@@ -0,0 +1,105 @@
1
+ // The location plugin's declarations, from its build settings: the Info.plist on Apple platforms
2
+ // and the Android manifest.
3
+
4
+ const WHEN_IN_USE = "This app uses your location when you request location features.";
5
+ const ALWAYS = "This app uses your location in the background for location features you turn on.";
6
+
7
+ /**
8
+ * The file that declares what `settings` ask for on `platform`, the plugin section key that names
9
+ * it, and notices for the build; or the problems with the settings.
10
+ */
11
+ export function declarations(platform, settings) {
12
+ if (settings["always-description"] !== undefined && !settings.background) {
13
+ return { problems: ["always-description needs background, which declares the permission it describes"] };
14
+ }
15
+ if (platform === "android") {
16
+ return { key: "manifest", name: "AndroidManifest.xml", contents: androidManifest(settings), notices: [] };
17
+ }
18
+ const notices =
19
+ platform === "macos" && settings.precision === "approximate"
20
+ ? ["macOS has no declaration of approximate location, so precision approximate applies to iOS and Android only"]
21
+ : [];
22
+ return { key: "plist", name: "Info.plist", contents: plist(appleKeys(platform, settings)), notices };
23
+ }
24
+
25
+ /** The Info.plist keys of `settings` on the Apple `platform`. */
26
+ function appleKeys(platform, settings) {
27
+ const whenInUse = settings["when-in-use-description"] ?? WHEN_IN_USE;
28
+ const purposes = settings["accuracy-purposes"] ?? {};
29
+ const temporary = Object.keys(purposes).length > 0 ? { NSLocationTemporaryUsageDescriptionDictionary: purposes } : {};
30
+ if (platform === "macos") {
31
+ // macOS reads NSLocationUsageDescription, and its SDK headers name the iOS keys.
32
+ return {
33
+ NSLocationUsageDescription: whenInUse,
34
+ NSLocationWhenInUseUsageDescription: whenInUse,
35
+ NSLocationAlwaysAndWhenInUseUsageDescription: whenInUse,
36
+ ...temporary,
37
+ };
38
+ }
39
+ const keys = { NSLocationWhenInUseUsageDescription: whenInUse };
40
+ if (settings.background) keys.NSLocationAlwaysAndWhenInUseUsageDescription = settings["always-description"] ?? ALWAYS;
41
+ if (settings["background-updates"]) keys.UIBackgroundModes = ["location"];
42
+ if (settings.precision === "approximate") keys.NSLocationDefaultAccuracyReduced = true;
43
+ return { ...keys, ...temporary };
44
+ }
45
+
46
+ /** The Android manifest of `settings`. */
47
+ function androidManifest(settings) {
48
+ const permissions = ["ACCESS_COARSE_LOCATION", "RECEIVE_BOOT_COMPLETED"];
49
+ if (settings.precision !== "approximate") permissions.push("ACCESS_FINE_LOCATION");
50
+ if (settings.background) permissions.push("ACCESS_BACKGROUND_LOCATION");
51
+ if (settings["background-updates"]) permissions.push("FOREGROUND_SERVICE", "FOREGROUND_SERVICE_LOCATION");
52
+ const service = settings["background-updates"]
53
+ ? [
54
+ ' <service android:name="com.tokamak.plugins.location.TokamakLocationService" android:exported="false"',
55
+ ' android:foregroundServiceType="location" />',
56
+ ]
57
+ : [];
58
+ return [
59
+ '<manifest xmlns:android="http://schemas.android.com/apk/res/android">',
60
+ ...permissions.map((name) => ` <uses-permission android:name="android.permission.${name}" />`),
61
+ " <application>",
62
+ ...service,
63
+ ' <receiver android:name="com.tokamak.plugins.location.TokamakLocationReceiver" android:exported="false" />',
64
+ ' <receiver android:name="com.tokamak.plugins.location.TokamakLocationBootReceiver" android:exported="false">',
65
+ " <intent-filter>",
66
+ ' <action android:name="android.intent.action.BOOT_COMPLETED" />',
67
+ ' <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />',
68
+ " </intent-filter>",
69
+ " </receiver>",
70
+ " </application>",
71
+ "</manifest>",
72
+ "",
73
+ ].join("\n");
74
+ }
75
+
76
+ /** `keys` as an XML property list. */
77
+ function plist(keys) {
78
+ return [
79
+ '<?xml version="1.0" encoding="UTF-8"?>',
80
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
81
+ '<plist version="1.0">',
82
+ ...element(keys, ""),
83
+ "</plist>",
84
+ "",
85
+ ].join("\n");
86
+ }
87
+
88
+ /** The lines of `value` as a property list element, indented by `indent`. */
89
+ function element(value, indent) {
90
+ if (typeof value === "boolean") return [`${indent}<${String(value)}/>`];
91
+ if (typeof value === "string") return [`${indent}<string>${escaped(value)}</string>`];
92
+ const inner = `${indent}\t`;
93
+ if (Array.isArray(value)) {
94
+ return [`${indent}<array>`, ...value.flatMap((item) => element(item, inner)), `${indent}</array>`];
95
+ }
96
+ const entries = Object.entries(value).flatMap(([key, item]) => [
97
+ `${inner}<key>${escaped(key)}</key>`,
98
+ ...element(item, inner),
99
+ ]);
100
+ return [`${indent}<dict>`, ...entries, `${indent}</dict>`];
101
+ }
102
+
103
+ function escaped(text) {
104
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
105
+ }
@@ -0,0 +1,17 @@
1
+ // The location plugin's build script: tok runs it for each platform with the plugin's resolved
2
+ // build settings on stdin. It writes the plugin's Info.plist or Android manifest into `output`
3
+ // and prints the key that names it.
4
+ import { readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { declarations } from "./declarations.mjs";
7
+
8
+ const { platform, settings, output } = JSON.parse(readFileSync(0, "utf8"));
9
+ const { key, name, contents, notices, problems } = declarations(platform, settings);
10
+ if (problems) {
11
+ console.error(problems.join("\n"));
12
+ process.exit(1);
13
+ }
14
+ for (const notice of notices) console.error(notice);
15
+ const path = join(output, name);
16
+ writeFileSync(path, contents);
17
+ console.log(JSON.stringify({ [key]: path }));
@@ -0,0 +1,18 @@
1
+ import { defineEvent } from "@tokamakdev/plugin/events";
2
+
3
+ import type { LocationErrorEvent, LocationUpdate, RegionTransition, Visit } from "../src/index.js";
4
+
5
+ /**
6
+ * Receives the positions of continuous updates and significant changes in native builds: on
7
+ * screen, off screen, or after the system launches the app for them.
8
+ */
9
+ export const onLocationUpdate = defineEvent<LocationUpdate>("location.update");
10
+
11
+ /** Receives each region entry or exit, including one that launches the app. */
12
+ export const onRegionTransition = defineEvent<RegionTransition>("location.region");
13
+
14
+ /** Apple only: receives each arrival at or departure from a place, including one that launches the app. */
15
+ export const onVisit = defineEvent<Visit>("location.visit");
16
+
17
+ /** Receives failures of background location services, such as a region the system cannot monitor. */
18
+ export const onLocationError = defineEvent<LocationErrorEvent>("location.error");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/plugin-location",
3
- "version": "0.1.0-beta.102",
3
+ "version": "0.1.0-beta.103",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mantty/tokamak.git",
@@ -8,14 +8,17 @@
8
8
  },
9
9
  "type": "module",
10
10
  "exports": {
11
- ".": "./src/index.ts"
11
+ ".": "./src/index.ts",
12
+ "./events": "./events/index.ts"
12
13
  },
13
14
  "peerDependencies": {
14
- "@tokamakdev/plugin": "0.1.0-beta.102"
15
+ "@tokamakdev/plugin": "0.1.0-beta.103"
15
16
  },
16
17
  "files": [
17
18
  "android",
18
19
  "apple",
20
+ "build",
21
+ "events",
19
22
  "src",
20
23
  "web",
21
24
  "tokamak-plugin.json",
package/src/checks.ts ADDED
@@ -0,0 +1,65 @@
1
+ /** Checks of the values the plugin's methods take, which throw `TypeError` naming the value. */
2
+ export type Check = (value: unknown, path: string) => void;
3
+
4
+ /** Checks that `value` is an object, then each field `checks` names that it has or `required` lists. */
5
+ export function object(checks: Readonly<Record<string, Check>>, required: readonly string[] = []): Check {
6
+ return (value, path) => {
7
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
8
+ throw new TypeError(`${path} must be an object`);
9
+ }
10
+ for (const [key, check] of Object.entries(checks)) {
11
+ const field = (value as Record<string, unknown>)[key];
12
+ if (field !== undefined || required.includes(key)) check(field, `${path}.${key}`);
13
+ }
14
+ };
15
+ }
16
+
17
+ export function flag(value: unknown, path: string): void {
18
+ if (typeof value !== "boolean") throw new TypeError(`${path} must be a boolean`);
19
+ }
20
+
21
+ export function text(value: unknown, path: string): void {
22
+ if (typeof value !== "string") throw new TypeError(`${path} must be a string`);
23
+ }
24
+
25
+ export function nonEmptyText(value: unknown, path: string): void {
26
+ if (typeof value !== "string" || value === "") throw new TypeError(`${path} must be a non-empty string`);
27
+ }
28
+
29
+ export function oneOf(...allowed: readonly string[]): Check {
30
+ return (value, path) => {
31
+ if (!allowed.includes(value as string)) {
32
+ throw new TypeError(`${path} must be one of ${allowed.map((name) => `"${name}"`).join(", ")}`);
33
+ }
34
+ };
35
+ }
36
+
37
+ /** A finite number of at least 0. */
38
+ export function atLeastZero(value: unknown, path: string): void {
39
+ if (!(typeof value === "number" && Number.isFinite(value) && value >= 0)) {
40
+ throw new TypeError(`${path} must be a finite number of at least 0`);
41
+ }
42
+ }
43
+
44
+ /** A whole number of at least 0. */
45
+ export function wholeAtLeastZero(value: unknown, path: string): void {
46
+ if (!(Number.isSafeInteger(value) && (value as number) >= 0)) {
47
+ throw new TypeError(`${path} must be a whole number of at least 0`);
48
+ }
49
+ }
50
+
51
+ /** A number from `min` to `max`. */
52
+ export function between(min: number, max: number): Check {
53
+ return (value, path) => {
54
+ if (!(typeof value === "number" && value >= min && value <= max)) {
55
+ throw new TypeError(`${path} must be a number from ${String(min)} to ${String(max)}`);
56
+ }
57
+ };
58
+ }
59
+
60
+ /** A finite number greater than 0. */
61
+ export function positive(value: unknown, path: string): void {
62
+ if (!(typeof value === "number" && Number.isFinite(value) && value > 0)) {
63
+ throw new TypeError(`${path} must be a finite number greater than 0`);
64
+ }
65
+ }
package/src/index.ts CHANGED
@@ -1,37 +1,43 @@
1
1
  import { Plugin } from "@tokamakdev/plugin";
2
2
  import * as web from "../web/index.js";
3
+ import { nonEmptyText } from "./checks.js";
4
+ import { monitoredRegion, validatePermissionRequest, validateSetup, validateUpdateOptions } from "./options.js";
5
+ import type {
6
+ ErrorListener,
7
+ Listener,
8
+ LocationErrorEvent,
9
+ LocationPermission,
10
+ LocationUpdate,
11
+ MonitoredRegion,
12
+ PermissionRequest,
13
+ Position,
14
+ PositionCallback,
15
+ PositionErrorCallback,
16
+ PositionOptions,
17
+ Region,
18
+ RegionTransition,
19
+ SetupConfig,
20
+ UpdateOptions,
21
+ Visit,
22
+ } from "./types.js";
3
23
 
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;
24
+ export type * from "./types.js";
29
25
 
30
26
  class Location extends Plugin {
31
27
  constructor() {
32
28
  super("location");
33
29
  }
34
30
 
31
+ /**
32
+ * Applies what runs at every launch, replacing the previous configuration, which the plugin keeps
33
+ * and applies again at each start. Rejects with `TypeError`, changing nothing, when a value is
34
+ * invalid.
35
+ */
36
+ async setup(config: SetupConfig): Promise<void> {
37
+ validateSetup(config, "config");
38
+ return this.hasNativeTransport ? this.call("setup", config) : web.unsupported();
39
+ }
40
+
35
41
  getCurrentPosition(options: PositionOptions = {}): Promise<Position> {
36
42
  if (!this.hasNativeTransport) return web.getCurrentPosition(options);
37
43
  return this.call("getCurrentPosition", {
@@ -44,6 +50,97 @@ class Location extends Plugin {
44
50
  if (!this.hasNativeTransport) return web.watchPosition(next, error);
45
51
  return this.listen("watchPosition", next, error);
46
52
  }
53
+
54
+ /** The app's in-use and background permission, and the precision it gets. Never asks. */
55
+ permission(): Promise<LocationPermission> {
56
+ return this.hasNativeTransport ? this.call("permission") : web.permission();
57
+ }
58
+
59
+ /**
60
+ * Asks for in-use permission, and for what `options` names, where the user has not answered.
61
+ * Android asks for background permission once in-use permission is granted. Rejects with
62
+ * `NeedsUIError` when a prompt would show off screen.
63
+ */
64
+ async requestPermission(options: PermissionRequest = {}): Promise<LocationPermission> {
65
+ validatePermissionRequest(options, "options");
66
+ return this.hasNativeTransport ? this.call("requestPermission", options) : web.unsupported();
67
+ }
68
+
69
+ /** Whether location services are on for the device. While they are off, nothing locates. */
70
+ servicesEnabled(): Promise<boolean> {
71
+ return this.hasNativeTransport ? this.call("servicesEnabled") : web.unsupported();
72
+ }
73
+
74
+ /** Opens the app's page in the system settings, where the user changes its location permission. */
75
+ openSettings(): Promise<void> {
76
+ return this.hasNativeTransport ? this.call("openSettings") : web.unsupported();
77
+ }
78
+
79
+ /**
80
+ * Starts continuous updates, replacing any running, until `stopUpdates`. Their positions reach
81
+ * `onLocationUpdate`, including after the system ended the app. Rejects with `NotAllowedError`
82
+ * without in-use permission, and on iOS with `NeedsUIError` off screen. Android's
83
+ * `foregroundService` rejects with `NotSupportedError` without the `background-updates` build
84
+ * setting, and with `NeedsUIError` when Android does not allow the service to start off screen.
85
+ */
86
+ async startUpdates(options: UpdateOptions = {}): Promise<void> {
87
+ validateUpdateOptions(options, "options");
88
+ return this.hasNativeTransport ? this.call("startUpdates", options) : web.unsupported();
89
+ }
90
+
91
+ stopUpdates(): Promise<void> {
92
+ return this.hasNativeTransport ? this.call("stopUpdates") : web.unsupported();
93
+ }
94
+
95
+ /** The options of the running updates, or null when none are running. */
96
+ getUpdates(): Promise<UpdateOptions | null> {
97
+ return this.hasNativeTransport ? this.call("getUpdates") : web.unsupported();
98
+ }
99
+
100
+ /**
101
+ * Monitors a circular region, replacing one with the same `id`, until `removeRegion`. Its entries
102
+ * and exits reach `onRegionTransition`, including when the system launches the app for one. A
103
+ * region starts outside, so a device already inside reports an entry. Rejects with
104
+ * `NotAllowedError` without precise location, and with `QuotaExceededError` beyond Apple's 20.
105
+ */
106
+ async addRegion(region: Region): Promise<void> {
107
+ const monitored = monitoredRegion(region);
108
+ return this.hasNativeTransport ? this.call("addRegion", monitored) : web.unsupported();
109
+ }
110
+
111
+ /** The monitored regions. */
112
+ getRegions(): Promise<MonitoredRegion[]> {
113
+ return this.hasNativeTransport ? this.call("getRegions") : web.unsupported();
114
+ }
115
+
116
+ async removeRegion(id: string): Promise<void> {
117
+ nonEmptyText(id, "id");
118
+ return this.hasNativeTransport ? this.call("removeRegion", { id }) : web.unsupported();
119
+ }
120
+
121
+ /** Receives the updates the Worker's `onLocationUpdate` receives, while the listening page or Worker runs. */
122
+ onLocationUpdate(listener: Listener<LocationUpdate>, error: ErrorListener = report): () => void {
123
+ return this.hasNativeTransport ? this.listen("onLocationUpdate", listener, error) : () => undefined;
124
+ }
125
+
126
+ /** Receives the transitions the Worker's `onRegionTransition` receives, while the listening page or Worker runs. */
127
+ onRegionTransition(listener: Listener<RegionTransition>, error: ErrorListener = report): () => void {
128
+ return this.hasNativeTransport ? this.listen("onRegionTransition", listener, error) : () => undefined;
129
+ }
130
+
131
+ /** Apple only: receives the visits the Worker's `onVisit` receives, while the listening page or Worker runs. */
132
+ onVisit(listener: Listener<Visit>, error: ErrorListener = report): () => void {
133
+ return this.hasNativeTransport ? this.listen("onVisit", listener, error) : () => undefined;
134
+ }
135
+
136
+ /** Receives the failures the Worker's `onLocationError` receives, while the listening page or Worker runs. */
137
+ onLocationError(listener: Listener<LocationErrorEvent>, error: ErrorListener = report): () => void {
138
+ return this.hasNativeTransport ? this.listen("onLocationError", listener, error) : () => undefined;
139
+ }
140
+ }
141
+
142
+ function report(error: DOMException): void {
143
+ console.error(error);
47
144
  }
48
145
 
49
146
  const unlimited = 0xffffffff;
package/src/options.ts ADDED
@@ -0,0 +1,54 @@
1
+ import {
2
+ atLeastZero,
3
+ between,
4
+ flag,
5
+ nonEmptyText,
6
+ object,
7
+ oneOf,
8
+ positive,
9
+ text,
10
+ wholeAtLeastZero,
11
+ } from "./checks.js";
12
+ import type { Region } from "./types.js";
13
+
14
+ /** Throws `TypeError` unless `config` is a configuration `setup` takes. */
15
+ export const validateSetup = object({ significantChanges: flag, visits: flag });
16
+
17
+ /** Throws `TypeError` unless `options` are options `requestPermission` takes. */
18
+ export const validatePermissionRequest = object({
19
+ background: flag,
20
+ precision: oneOf("precise", "approximate"),
21
+ purpose: nonEmptyText,
22
+ });
23
+
24
+ /** Throws `TypeError` unless `options` are options `startUpdates` takes. */
25
+ export const validateUpdateOptions = object({
26
+ activity: oneOf("default", "automotiveNavigation", "otherNavigation", "fitness", "airborne", "maritime"),
27
+ accuracy: oneOf("high", "balanced", "low"),
28
+ distance: atLeastZero,
29
+ interval: wholeAtLeastZero,
30
+ foregroundService: object(
31
+ { title: nonEmptyText, body: text, channel: nonEmptyText },
32
+ ["title", "body", "channel"],
33
+ ),
34
+ });
35
+
36
+ const REGION = object(
37
+ {
38
+ id: nonEmptyText,
39
+ latitude: between(-90, 90),
40
+ longitude: between(-180, 180),
41
+ radius: positive,
42
+ onEntry: flag,
43
+ onExit: flag,
44
+ },
45
+ ["id", "latitude", "longitude", "radius"],
46
+ );
47
+
48
+ /** `region`, with `onEntry` and `onExit` given. Throws `TypeError` unless it is a region to monitor. */
49
+ export function monitoredRegion(region: Region): Required<Region> {
50
+ REGION(region, "region");
51
+ const { id, latitude, longitude, radius, onEntry = true, onExit = true } = region;
52
+ if (!onEntry && !onExit) throw new TypeError("region must report entry, exit or both");
53
+ return { id, latitude, longitude, radius, onEntry, onExit };
54
+ }