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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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("&", "&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.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.102"
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
+ }