@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.
package/src/index.ts CHANGED
@@ -1,37 +1,44 @@
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 long-term location awareness, 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, and on Apple platforms with `NotSupportedError` where the device cannot monitor
35
+ * significant changes.
36
+ */
37
+ async setup(config: SetupConfig): Promise<void> {
38
+ validateSetup(config, "config");
39
+ return this.hasNativeTransport ? this.call("setup", config) : web.unsupported();
40
+ }
41
+
35
42
  getCurrentPosition(options: PositionOptions = {}): Promise<Position> {
36
43
  if (!this.hasNativeTransport) return web.getCurrentPosition(options);
37
44
  return this.call("getCurrentPosition", {
@@ -44,6 +51,98 @@ class Location extends Plugin {
44
51
  if (!this.hasNativeTransport) return web.watchPosition(next, error);
45
52
  return this.listen("watchPosition", next, error);
46
53
  }
54
+
55
+ /** The app's in-use and background permission, and the precision it gets. Never asks. */
56
+ permission(): Promise<LocationPermission> {
57
+ return this.hasNativeTransport ? this.call("permission") : web.permission();
58
+ }
59
+
60
+ /**
61
+ * Asks for in-use permission, and for what `options` names, where the user has not answered.
62
+ * Android asks for background permission once in-use permission is granted. Rejects with
63
+ * `NeedsUIError` when a prompt would show off screen.
64
+ */
65
+ async requestPermission(options: PermissionRequest = {}): Promise<LocationPermission> {
66
+ validatePermissionRequest(options, "options");
67
+ return this.hasNativeTransport ? this.call("requestPermission", options) : web.unsupported();
68
+ }
69
+
70
+ /** Whether location services are on for the device. While they are off, nothing locates. */
71
+ servicesEnabled(): Promise<boolean> {
72
+ return this.hasNativeTransport ? this.call("servicesEnabled") : web.unsupported();
73
+ }
74
+
75
+ /** Opens the app's page in the system settings, where the user changes its location permission. */
76
+ openSettings(): Promise<void> {
77
+ return this.hasNativeTransport ? this.call("openSettings") : web.unsupported();
78
+ }
79
+
80
+ /**
81
+ * Starts continuous tracking, replacing any running, until `stopUpdates`; `setup` gives long-term
82
+ * awareness instead. The updates' positions reach `onLocationUpdate`, including after the system
83
+ * ended the app. Rejects with `NotAllowedError` without in-use permission, and on iOS with
84
+ * `NeedsUIError` off screen. Android's `foregroundService` rejects with `NotSupportedError`
85
+ * without the `background-updates` build setting, and with `NeedsUIError` when Android does not
86
+ * allow the service to start off screen.
87
+ */
88
+ async startUpdates(options: UpdateOptions = {}): Promise<void> {
89
+ validateUpdateOptions(options, "options");
90
+ return this.hasNativeTransport ? this.call("startUpdates", options) : web.unsupported();
91
+ }
92
+
93
+ stopUpdates(): Promise<void> {
94
+ return this.hasNativeTransport ? this.call("stopUpdates") : web.unsupported();
95
+ }
96
+
97
+ /** The options of the running updates, or null when none are running. */
98
+ getUpdates(): Promise<UpdateOptions | null> {
99
+ return this.hasNativeTransport ? this.call("getUpdates") : web.unsupported();
100
+ }
101
+
102
+ /**
103
+ * Monitors a circular region, replacing one with the same `id`, until `removeRegion`. Its entries
104
+ * and exits reach `onRegionTransition`, including when the system launches the app for one. A
105
+ * region starts outside, so a device already inside reports an entry. Rejects with
106
+ * `NotAllowedError` without precise location, and with `QuotaExceededError` beyond Apple's 20.
107
+ */
108
+ async addRegion(region: Region): Promise<void> {
109
+ const monitored = monitoredRegion(region);
110
+ return this.hasNativeTransport ? this.call("addRegion", monitored) : web.unsupported();
111
+ }
112
+
113
+ /** The monitored regions. */
114
+ getRegions(): Promise<MonitoredRegion[]> {
115
+ return this.hasNativeTransport ? this.call("getRegions") : web.unsupported();
116
+ }
117
+
118
+ async removeRegion(id: string): Promise<void> {
119
+ nonEmptyText(id, "id");
120
+ return this.hasNativeTransport ? this.call("removeRegion", { id }) : web.unsupported();
121
+ }
122
+
123
+ /** Receives the updates the Worker's `onLocationUpdate` receives, while the listening page or Worker runs. */
124
+ onLocationUpdate(listener: Listener<LocationUpdate>, error: ErrorListener = report): () => void {
125
+ return this.hasNativeTransport ? this.listen("onLocationUpdate", listener, error) : () => undefined;
126
+ }
127
+
128
+ /** Receives the transitions the Worker's `onRegionTransition` receives, while the listening page or Worker runs. */
129
+ onRegionTransition(listener: Listener<RegionTransition>, error: ErrorListener = report): () => void {
130
+ return this.hasNativeTransport ? this.listen("onRegionTransition", listener, error) : () => undefined;
131
+ }
132
+
133
+ /** Apple only: receives the visits the Worker's `onVisit` receives, while the listening page or Worker runs. */
134
+ onVisit(listener: Listener<Visit>, error: ErrorListener = report): () => void {
135
+ return this.hasNativeTransport ? this.listen("onVisit", listener, error) : () => undefined;
136
+ }
137
+
138
+ /** Receives the failures the Worker's `onLocationError` receives, while the listening page or Worker runs. */
139
+ onLocationError(listener: Listener<LocationErrorEvent>, error: ErrorListener = report): () => void {
140
+ return this.hasNativeTransport ? this.listen("onLocationError", listener, error) : () => undefined;
141
+ }
142
+ }
143
+
144
+ function report(error: DOMException): void {
145
+ console.error(error);
47
146
  }
48
147
 
49
148
  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
+ }
package/src/types.ts ADDED
@@ -0,0 +1,173 @@
1
+ export interface Coordinates {
2
+ readonly latitude: number;
3
+ readonly longitude: number;
4
+ readonly accuracy: number;
5
+ readonly altitude: number | null;
6
+ readonly altitudeAccuracy: number | null;
7
+ readonly heading: number | null;
8
+ readonly speed: number | null;
9
+ }
10
+
11
+ export interface Position {
12
+ readonly coords: Coordinates;
13
+ readonly timestamp: number;
14
+ }
15
+
16
+ /** Options for `getCurrentPosition`, as the Geolocation API reads them. */
17
+ export interface PositionOptions {
18
+ /** The age in milliseconds of a recent position to accept instead of a new one; 0 by default. */
19
+ readonly maximumAge?: number;
20
+ /** Milliseconds to wait for a position before rejecting with `TimeoutError`; unlimited by default. */
21
+ readonly timeout?: number;
22
+ }
23
+
24
+ export type PositionCallback = (position: Position) => void;
25
+ export type PositionErrorCallback = (error: DOMException) => void;
26
+
27
+ /**
28
+ * Long-term location awareness, which runs across launches and restarts, uses little power and shows
29
+ * no indicator: `setup` starts what it names and stops what it leaves out. Off screen it needs
30
+ * background permission, with which the system launches the app for its events.
31
+ */
32
+ export interface SetupConfig {
33
+ /**
34
+ * Delivers moves of 500 metres or more, at most every 5 minutes, to `onLocationUpdate`, located
35
+ * from cellular and Wi-Fi rather than GPS: Apple's significant-change service, and on Android a
36
+ * low-power request with the same thresholds, which Android limits to a few an hour off screen.
37
+ */
38
+ readonly significantChanges?: boolean;
39
+ /** Apple only: delivers arrivals at and departures from places to `onVisit`. */
40
+ readonly visits?: boolean;
41
+ }
42
+
43
+ /** Whether the user granted a permission, denied it, or has not been asked. */
44
+ export type Permission = "granted" | "denied" | "prompt";
45
+
46
+ export type Precision = "precise" | "approximate";
47
+
48
+ /** The app's location permission. */
49
+ export interface LocationPermission {
50
+ /** Location while the app is in use. */
51
+ readonly inUse: Permission;
52
+ /** Location while it is not: Apple's Always, Android's "Allow all the time". The same as `inUse` on macOS. */
53
+ readonly background: Permission;
54
+ /** The precision the app gets, or would get once allowed. */
55
+ readonly precision: Precision;
56
+ }
57
+
58
+ /** What `requestPermission` asks for besides in-use permission. */
59
+ export interface PermissionRequest {
60
+ /** Background permission, which needs the `background` build setting. */
61
+ readonly background?: boolean;
62
+ /**
63
+ * `"precise"` asks for precise location where the user allowed approximate: on Apple platforms
64
+ * temporarily, for `purpose`. `"approximate"` asks Android for approximate location only.
65
+ */
66
+ readonly precision?: Precision;
67
+ /** Apple only: the key in the `accuracy-purposes` build setting of the reason to show. */
68
+ readonly purpose?: string;
69
+ }
70
+
71
+ /**
72
+ * Apple only: the live update configuration, by Apple's name, which tunes when updates pause and
73
+ * how coordinates follow roads. `"maritime"` needs iOS 27 or macOS 27.
74
+ */
75
+ export type Activity =
76
+ | "default"
77
+ | "automotiveNavigation"
78
+ | "otherNavigation"
79
+ | "fitness"
80
+ | "airborne"
81
+ | "maritime";
82
+
83
+ /**
84
+ * `startUpdates` options, for continuous tracking such as a run or navigation, which can show the
85
+ * system's indicator: the blue pill on iOS with in-use permission, and the notification of
86
+ * Android's `foregroundService`. Each option is platform-specific: Apple's relaunching updates
87
+ * take only an `activity`.
88
+ */
89
+ export interface UpdateOptions {
90
+ /** Apple only: `"default"` by default. */
91
+ readonly activity?: Activity;
92
+ /** Android only: the request's quality, precision traded against battery use; `"high"` by default. */
93
+ readonly accuracy?: "high" | "balanced" | "low";
94
+ /** Android only: metres the device moves before the next update; 0 by default. */
95
+ readonly distance?: number;
96
+ /** Android only: milliseconds between updates; 10 seconds by default. */
97
+ readonly interval?: number;
98
+ /**
99
+ * Android only: runs updates in a foreground service showing this notification, so they arrive at
100
+ * the requested rate off screen. Needs the `background-updates` build setting.
101
+ */
102
+ readonly foregroundService?: ServiceNotification;
103
+ }
104
+
105
+ /** Android only: the foreground service's notification, set from JavaScript so apps can translate it. */
106
+ export interface ServiceNotification {
107
+ readonly title: string;
108
+ readonly body: string;
109
+ /** The name of the notification channel, which Android's settings show. */
110
+ readonly channel: string;
111
+ }
112
+
113
+ /** Positions from continuous updates or significant changes. */
114
+ export interface LocationUpdate {
115
+ /** Oldest first: those that arrived while the Worker handled the previous update come together. */
116
+ readonly positions: readonly Position[];
117
+ /**
118
+ * Apple only, from iOS 18 and macOS 15: true on the last update before updates pause while the
119
+ * device is still. `positions` is empty when the update has no position.
120
+ */
121
+ readonly stationary?: boolean;
122
+ }
123
+
124
+ /** A circular region to monitor, with the same fields as the notifications plugin's `Region`. */
125
+ export interface Region {
126
+ readonly id: string;
127
+ readonly latitude: number;
128
+ readonly longitude: number;
129
+ /** In metres. */
130
+ readonly radius: number;
131
+ /** Whether entering the region reaches `onRegionTransition`; true by default. */
132
+ readonly onEntry?: boolean;
133
+ /** Whether leaving it does; true by default. */
134
+ readonly onExit?: boolean;
135
+ }
136
+
137
+ /** A monitored region. */
138
+ export interface MonitoredRegion extends Required<Region> {
139
+ /** Apple only: whether the device is inside the region, as the system last determined. */
140
+ readonly state?: "inside" | "outside" | "unknown";
141
+ }
142
+
143
+ /** An entry into or exit from a monitored region. */
144
+ export interface RegionTransition {
145
+ readonly id: string;
146
+ readonly transition: "enter" | "exit";
147
+ /** Milliseconds since the epoch. */
148
+ readonly timestamp: number;
149
+ }
150
+
151
+ /** Apple only: an arrival at or departure from a place where the user stays. */
152
+ export interface Visit {
153
+ readonly latitude: number;
154
+ readonly longitude: number;
155
+ /** In metres. */
156
+ readonly accuracy: number;
157
+ /** Milliseconds since the epoch, or null when unknown. */
158
+ readonly arrival: number | null;
159
+ /** Milliseconds since the epoch, or null before the user leaves. */
160
+ readonly departure: number | null;
161
+ }
162
+
163
+ /** A failure of a background location service. */
164
+ export interface LocationErrorEvent {
165
+ /** A `DOMException` name, such as `"NotAllowedError"` or `"NotReadableError"`. */
166
+ readonly name: string;
167
+ readonly message: string;
168
+ /** The region's id, when the failure concerns one. */
169
+ readonly id?: string;
170
+ }
171
+
172
+ export type Listener<T> = (event: T) => void;
173
+ export type ErrorListener = (error: DOMException) => void;
@@ -1,21 +1,67 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "id": "location",
4
+ "settings": {
5
+ "background": {
6
+ "kind": "boolean",
7
+ "default": false,
8
+ "description": "Declares background location permission: Apple's Always, Android's ACCESS_BACKGROUND_LOCATION"
9
+ },
10
+ "background-updates": {
11
+ "kind": "boolean",
12
+ "default": false,
13
+ "description": "Declares continuous updates off screen: iOS's location background mode, and Android's location foreground service"
14
+ },
15
+ "precision": {
16
+ "kind": "enum",
17
+ "values": ["precise", "approximate"],
18
+ "default": "precise",
19
+ "description": "The precision the app declares; approximate leaves out precise location"
20
+ },
21
+ "when-in-use-description": {
22
+ "kind": "string",
23
+ "description": "Apple only: the reason the in-use prompt shows"
24
+ },
25
+ "always-description": {
26
+ "kind": "string",
27
+ "description": "iOS only: the reason the Always prompt shows"
28
+ },
29
+ "accuracy-purposes": {
30
+ "kind": "map",
31
+ "of": "string",
32
+ "description": "Apple only: the reasons for temporary precise location, by purpose key"
33
+ }
34
+ },
35
+ "build": "build/index.mjs",
4
36
  "platforms": {
5
37
  "macos": {
6
38
  "class": "TokamakLocationPlugin",
7
- "sources": ["apple/LocationPlugin.swift"],
8
- "plist": "apple/macos/Info.plist"
39
+ "sources": [
40
+ "apple/LocationEvents.swift",
41
+ "apple/LocationPlugin.swift",
42
+ "apple/LocationRegions.swift",
43
+ "apple/LocationUpdates.swift"
44
+ ]
9
45
  },
10
46
  "ios": {
11
47
  "class": "TokamakLocationPlugin",
12
- "sources": ["apple/LocationPlugin.swift"],
13
- "plist": "apple/ios/Info.plist"
48
+ "sources": [
49
+ "apple/LocationEvents.swift",
50
+ "apple/LocationPlugin.swift",
51
+ "apple/LocationRegions.swift",
52
+ "apple/LocationUpdates.swift"
53
+ ]
14
54
  },
15
55
  "android": {
16
56
  "class": "com.tokamak.plugins.location.TokamakLocationPlugin",
17
- "sources": ["android/LocationPlugin.kt"],
18
- "manifest": "android/AndroidManifest.xml"
57
+ "sources": [
58
+ "android/LocationEvents.kt",
59
+ "android/LocationPlugin.kt",
60
+ "android/LocationReceiver.kt",
61
+ "android/LocationRegions.kt",
62
+ "android/LocationService.kt",
63
+ "android/LocationUpdates.kt"
64
+ ]
19
65
  }
20
66
  }
21
67
  }
package/web/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import type {
2
+ LocationPermission,
2
3
  Position,
3
4
  PositionCallback,
4
5
  PositionErrorCallback,
5
6
  PositionOptions,
6
- } from "../src/index.js";
7
+ } from "../src/types.js";
7
8
 
8
9
  export function getCurrentPosition(options: PositionOptions): Promise<Position> {
9
10
  return new Promise((resolve, reject) => {
@@ -38,6 +39,19 @@ export function watchPosition(
38
39
  };
39
40
  }
40
41
 
42
+ /** The page's location permission, which the web has only for pages in use. */
43
+ export async function permission(): Promise<LocationPermission> {
44
+ const permissions = (globalThis as { navigator?: { permissions?: Permissions } }).navigator?.permissions;
45
+ if (!permissions) throw new DOMException("The Permissions API is unavailable", "NotSupportedError");
46
+ const { state } = await permissions.query({ name: "geolocation" });
47
+ return { inUse: state, background: "denied", precision: "precise" };
48
+ }
49
+
50
+ /** Rejects as the web does for what only native apps have. */
51
+ export function unsupported(): Promise<never> {
52
+ return Promise.reject(new DOMException("Native location is unavailable", "NotSupportedError"));
53
+ }
54
+
41
55
  function browserLocationApi(): Geolocation {
42
56
  const root: { navigator?: { geolocation?: Geolocation } } = globalThis;
43
57
  const browserLocation = root.navigator?.geolocation;
@@ -1,4 +0,0 @@
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>
@@ -1,8 +0,0 @@
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>
@@ -1,8 +0,0 @@
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>