@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/README.md +217 -18
- package/android/LocationEvents.kt +82 -0
- package/android/LocationPlugin.kt +264 -36
- package/android/LocationReceiver.kt +27 -0
- package/android/LocationRegions.kt +73 -0
- package/android/LocationService.kt +75 -0
- package/android/LocationUpdates.kt +120 -0
- package/apple/LocationEvents.swift +149 -0
- package/apple/LocationPlugin.swift +350 -56
- package/apple/LocationRegions.swift +175 -0
- package/apple/LocationUpdates.swift +137 -0
- package/build/declarations.d.mts +19 -0
- package/build/declarations.mjs +105 -0
- package/build/index.mjs +17 -0
- package/events/index.ts +18 -0
- package/package.json +6 -3
- package/src/checks.ts +65 -0
- package/src/index.ts +124 -25
- package/src/options.ts +54 -0
- package/src/types.ts +173 -0
- package/tokamak-plugin.json +52 -6
- package/web/index.ts +15 -1
- package/android/AndroidManifest.xml +0 -4
- package/apple/ios/Info.plist +0 -8
- package/apple/macos/Info.plist +0 -8
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
#if os(iOS)
|
|
5
|
+
import UIKit
|
|
6
|
+
#endif
|
|
7
|
+
|
|
8
|
+
/// The JSON of each monitored region, as `addRegion` received it, by id.
|
|
9
|
+
private let regionsKey = "tokamak.location.regions"
|
|
10
|
+
/// The ids of the regions whose last transition was an entry.
|
|
11
|
+
private let insideKey = "tokamak.location.inside"
|
|
12
|
+
/// `CLMonitor` monitors at most 20 conditions for an app, and reports no
|
|
13
|
+
/// error beyond them.
|
|
14
|
+
private let regionLimit = 20
|
|
15
|
+
/// The monitor's name, which must be alphanumeric.
|
|
16
|
+
private let monitorName = "TokamakLocation"
|
|
17
|
+
|
|
18
|
+
/// Circular regions in one `CLMonitor`, which the plugin creates at launch
|
|
19
|
+
/// and reads events from for the life of the process. The system launches the
|
|
20
|
+
/// app for their events on iOS. Use it on the main thread.
|
|
21
|
+
final class TokamakLocationRegions {
|
|
22
|
+
private let events: TokamakLocationEvents
|
|
23
|
+
private let defaults = UserDefaults.standard
|
|
24
|
+
private let monitor: Task<CLMonitor, Never>
|
|
25
|
+
|
|
26
|
+
init(events: TokamakLocationEvents) {
|
|
27
|
+
self.events = events
|
|
28
|
+
let monitor = Task { @MainActor in
|
|
29
|
+
await Self.firstUnlock()
|
|
30
|
+
return await CLMonitor(monitorName)
|
|
31
|
+
}
|
|
32
|
+
self.monitor = monitor
|
|
33
|
+
Task { @MainActor [weak self] in
|
|
34
|
+
do {
|
|
35
|
+
for try await event in await monitor.value.events {
|
|
36
|
+
self?.receive(event)
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
self?.events.fail(
|
|
40
|
+
.locationUnavailable(error.localizedDescription))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// The regions, by id.
|
|
46
|
+
private var regions: [String: [String: Any]] {
|
|
47
|
+
get {
|
|
48
|
+
defaults.data(forKey: regionsKey)
|
|
49
|
+
.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: [String: Any]] } ?? [:]
|
|
50
|
+
}
|
|
51
|
+
set {
|
|
52
|
+
defaults.set(try? JSONSerialization.data(withJSONObject: newValue), forKey: regionsKey)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private var inside: Set<String> {
|
|
57
|
+
get { Set(defaults.stringArray(forKey: insideKey) ?? []) }
|
|
58
|
+
set { defaults.set(Array(newValue), forKey: insideKey) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Monitors the region `region` describes, replacing one with the same id.
|
|
62
|
+
/// It starts outside, so a device already inside reports an entry, as on
|
|
63
|
+
/// Android.
|
|
64
|
+
func add(_ region: [String: Any]) async throws(TokamakPluginError) {
|
|
65
|
+
guard let id = region["id"] as? String, let radius = region["radius"] as? Double,
|
|
66
|
+
let latitude = region["latitude"] as? Double, let longitude = region["longitude"] as? Double
|
|
67
|
+
else {
|
|
68
|
+
throw TokamakPluginError(
|
|
69
|
+
name: "TypeError", message: "region must have an id, latitude, longitude and radius")
|
|
70
|
+
}
|
|
71
|
+
let monitor = await monitor.value
|
|
72
|
+
let monitored = await monitor.identifiers
|
|
73
|
+
guard monitored.count < regionLimit || monitored.contains(id) else {
|
|
74
|
+
throw TokamakPluginError(
|
|
75
|
+
name: "QuotaExceededError",
|
|
76
|
+
message: "The app monitors \(regionLimit) regions, the most the system allows")
|
|
77
|
+
}
|
|
78
|
+
let center = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
|
79
|
+
let condition = CLMonitor.CircularGeographicCondition(center: center, radius: radius)
|
|
80
|
+
regions[id] = region
|
|
81
|
+
inside.remove(id)
|
|
82
|
+
await monitor.add(condition, identifier: id, assuming: .unsatisfied)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func remove(_ id: String) async {
|
|
86
|
+
regions[id] = nil
|
|
87
|
+
inside.remove(id)
|
|
88
|
+
events.report([], region: id)
|
|
89
|
+
await monitor.value.remove(id)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// The monitored regions, each with whether the device is inside it.
|
|
93
|
+
func all() async -> [[String: Any]] {
|
|
94
|
+
let monitor = await monitor.value
|
|
95
|
+
let regions = regions
|
|
96
|
+
var all: [[String: Any]] = []
|
|
97
|
+
for id in await monitor.identifiers {
|
|
98
|
+
guard var region = regions[id] else { continue }
|
|
99
|
+
region["state"] =
|
|
100
|
+
switch await monitor.record(for: id)?.lastEvent.state {
|
|
101
|
+
case .satisfied: "inside"
|
|
102
|
+
case .unsatisfied: "outside"
|
|
103
|
+
default: "unknown"
|
|
104
|
+
}
|
|
105
|
+
all.append(region)
|
|
106
|
+
}
|
|
107
|
+
return all
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Reports the event's problems, and its transition when it changes whether the device is
|
|
111
|
+
/// inside the region, as events can repeat the state.
|
|
112
|
+
private func receive(_ event: CLMonitor.Event) {
|
|
113
|
+
let id = event.identifier
|
|
114
|
+
if #available(iOS 18, macOS 15, *) {
|
|
115
|
+
events.report(Self.problems(event), region: id)
|
|
116
|
+
}
|
|
117
|
+
guard let region = regions[id], event.state == .satisfied || event.state == .unsatisfied
|
|
118
|
+
else { return }
|
|
119
|
+
let entered = event.state == .satisfied
|
|
120
|
+
guard entered != inside.contains(id) else { return }
|
|
121
|
+
inside = entered ? inside.union([id]) : inside.subtracting([id])
|
|
122
|
+
if region[entered ? "onEntry" : "onExit"] as? Bool != false {
|
|
123
|
+
events.transition(id, entered: entered, at: event.date)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// The problems that stop monitoring the region, which `event` names.
|
|
128
|
+
@available(iOS 18, macOS 15, *)
|
|
129
|
+
private static func problems(_ event: CLMonitor.Event) -> [TokamakPluginError] {
|
|
130
|
+
let flags = [
|
|
131
|
+
(event.authorizationDenied, "NotAllowedError", "Location permission was denied"),
|
|
132
|
+
(event.authorizationDeniedGlobally, "NotAllowedError", "Location services are off"),
|
|
133
|
+
(event.authorizationRestricted, "NotAllowedError", "Location is restricted on this device"),
|
|
134
|
+
(
|
|
135
|
+
event.insufficientlyInUse, "NotAllowedError",
|
|
136
|
+
"Regions need the app in use, or Always permission"
|
|
137
|
+
),
|
|
138
|
+
(event.accuracyLimited, "NotAllowedError", "Regions need precise location"),
|
|
139
|
+
(event.conditionUnsupported, "NotSupportedError", "The device cannot monitor the region"),
|
|
140
|
+
(
|
|
141
|
+
event.conditionLimitExceeded, "QuotaExceededError",
|
|
142
|
+
"The app monitors the most regions the system allows"
|
|
143
|
+
),
|
|
144
|
+
(
|
|
145
|
+
event.persistenceUnavailable, "NotReadableError",
|
|
146
|
+
"The system cannot store the app's regions"
|
|
147
|
+
),
|
|
148
|
+
]
|
|
149
|
+
return flags.filter(\.0).map { TokamakPluginError(name: $0.1, message: $0.2) }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Returns once the device has been unlocked since it booted, from when `CLMonitor` can read the
|
|
153
|
+
/// file that keeps its conditions, even while the device is locked.
|
|
154
|
+
@MainActor
|
|
155
|
+
private static func firstUnlock() async {
|
|
156
|
+
#if os(iOS)
|
|
157
|
+
let unlocked = NotificationCenter.default.notifications(
|
|
158
|
+
named: UIApplication.protectedDataDidBecomeAvailableNotification)
|
|
159
|
+
guard !unlockedSinceBoot() else { return }
|
|
160
|
+
for await _ in unlocked {
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
#endif
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#if os(iOS)
|
|
167
|
+
/// Whether the device has been unlocked since it booted, as only then can a file protected
|
|
168
|
+
/// until the first unlock be written.
|
|
169
|
+
private static func unlockedSinceBoot() -> Bool {
|
|
170
|
+
let probe = FileManager.default.temporaryDirectory.appending(path: "TokamakLocation.unlocked")
|
|
171
|
+
let protection = Data.WritingOptions.completeFileProtectionUntilFirstUserAuthentication
|
|
172
|
+
return (try? Data().write(to: probe, options: protection)) != nil
|
|
173
|
+
}
|
|
174
|
+
#endif
|
|
175
|
+
}
|
|
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
105
|
+
}
|
package/build/index.mjs
ADDED
|
@@ -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 }));
|
package/events/index.ts
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.1.0-beta.104",
|
|
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.
|
|
15
|
+
"@tokamakdev/plugin": "0.1.0-beta.104"
|
|
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
|
+
}
|