craft-native 0.0.150 → 0.0.152

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.
@@ -825,6 +825,118 @@ export declare const music: {
825
825
  */
826
826
  onChange(callback: (nowPlaying: NowPlaying) => void): () => void;
827
827
  };
828
+ export type HeartRateAuthorization = 'authorized' | 'denied' | 'restricted' | 'notDetermined' | 'unavailable';
829
+ export type BluetoothState = 'poweredOn' | 'poweredOff' | 'unauthorized' | 'unsupported' | 'resetting' | 'unknown';
830
+ export type HeartRateConnection = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
831
+ export interface HeartRateDevice {
832
+ id: string;
833
+ name: string;
834
+ /** Signal strength, dBm; 0 for a strap the phone is already connected to. */
835
+ rssi: number | null;
836
+ }
837
+ export interface HeartRateRecordingState {
838
+ id: string | null;
839
+ active: boolean;
840
+ paused: boolean;
841
+ /** ms since the epoch. */
842
+ startedAt: number | null;
843
+ sampleCount: number | null;
844
+ }
845
+ export interface HeartRateState {
846
+ /** False off a phone, in a build without `enableHeartRate`, or on a phone without Bluetooth. */
847
+ available: boolean;
848
+ bluetooth: BluetoothState;
849
+ authorization: HeartRateAuthorization;
850
+ scanning: boolean;
851
+ connection: HeartRateConnection;
852
+ /** The strap connected, or being connected to. */
853
+ device: HeartRateDevice | null;
854
+ /** The strap `reconnect()` asks for. */
855
+ lastDevice: HeartRateDevice | null;
856
+ /** The latest reading while it is fresh (ten seconds), else null. */
857
+ bpm: number | null;
858
+ lastReadingAt: number | null;
859
+ recording: HeartRateRecordingState;
860
+ }
861
+ export interface HeartRateReading {
862
+ bpm: number;
863
+ /** Milliseconds between beats, oldest first; empty when the strap sends none. */
864
+ rrIntervals: number[];
865
+ /** Whether the strap touches skin; null when it cannot tell. */
866
+ contact: boolean | null;
867
+ energyExpended: number | null;
868
+ /** ms since the epoch. */
869
+ timestamp: number;
870
+ deviceId: string | null;
871
+ }
872
+ /** A reading as a recording keeps it. */
873
+ export interface HeartRateSample {
874
+ /** ms since the epoch. */
875
+ timestamp: number;
876
+ bpm: number;
877
+ /** Milliseconds between beats, when the strap sent them. */
878
+ rr?: number[];
879
+ }
880
+ export type HeartRateMethod = 'getState' | 'requestAuthorization' | 'scan' | 'stopScan' | 'getDevices' | 'connect' | 'reconnect' | 'disconnect' | 'startRecording' | 'pauseRecording' | 'resumeRecording' | 'stopRecording' | 'getRecordingState' | 'readRecording';
881
+ export declare function normalizeHeartRateDevice(value: unknown): HeartRateDevice | null;
882
+ /** A state from native, made safe to render whatever an older or newer build sent. */
883
+ export declare function normalizeHeartRateState(value: unknown): HeartRateState;
884
+ /** A reading event's detail, or null for one that is not a usable reading. */
885
+ export declare function normalizeHeartRateReading(value: unknown): HeartRateReading | null;
886
+ /** A recording's samples, oldest first, one per timestamp, implausible ones left out. */
887
+ export declare function normalizeHeartRateSamples(value: unknown): HeartRateSample[];
888
+ export declare const heartRate: {
889
+ /** Whether this app can read a heart-rate strap. */
890
+ isAvailable(): boolean;
891
+ /** Whether this app build answers `method`; an older build may lack one. */
892
+ supports(method: HeartRateMethod): boolean;
893
+ /** Bluetooth, the connection, the strap and its latest reading, without asking anything. */
894
+ getState: () => Promise<HeartRateState>;
895
+ /**
896
+ * Ask for Bluetooth: iOS's prompt the first time, its remembered answer
897
+ * after that. Scanning and connecting ask too; call this from the person's
898
+ * tap to ask first.
899
+ */
900
+ requestAuthorization: () => Promise<HeartRateAuthorization>;
901
+ /** Look for straps for `timeout` seconds (15 by default); `onDevice` hears each one. */
902
+ scan: (options?: {
903
+ timeout?: number;
904
+ }) => Promise<HeartRateState>;
905
+ stopScan: () => Promise<HeartRateState>;
906
+ /** What the last scan found, strongest signal first. */
907
+ getDevices: () => Promise<HeartRateDevice[]>;
908
+ /**
909
+ * Connect to a strap from `scan` and subscribe to its readings; it is
910
+ * remembered for `reconnect()`. Rejects with code TIMEOUT when it does not
911
+ * answer within 15 s, while still asking for it: a strap wakes up when worn.
912
+ */
913
+ connect: (id: string) => Promise<HeartRateState>;
914
+ /** Ask for the remembered strap again, without waiting for it. */
915
+ reconnect: () => Promise<HeartRateState>;
916
+ /** Stop asking for the strap; `forget` also drops it from memory. */
917
+ disconnect: (options?: {
918
+ forget?: boolean;
919
+ }) => Promise<HeartRateState>;
920
+ /** Keep every reading natively from now on: screen locked, page suspended or app closed. */
921
+ startRecording: () => Promise<HeartRateRecordingState>;
922
+ pauseRecording: () => Promise<HeartRateRecordingState>;
923
+ resumeRecording: () => Promise<HeartRateRecordingState>;
924
+ /** Ends the recording and hands back every reading it kept. */
925
+ stopRecording: () => Promise<HeartRateRecordingState & {
926
+ samples: HeartRateSample[];
927
+ }>;
928
+ getRecordingState: () => Promise<HeartRateRecordingState>;
929
+ /** The recording so far; only what came after `since` (ms) when given. */
930
+ readRecording: (options?: {
931
+ since?: number;
932
+ }) => Promise<HeartRateSample[]>;
933
+ /** Every reading from the connected strap, about once a second. */
934
+ onReading: (callback: (reading: HeartRateReading) => void) => (() => void);
935
+ /** Each strap a scan finds. */
936
+ onDevice: (callback: (device: HeartRateDevice) => void) => (() => void);
937
+ /** Bluetooth, scanning or the connection changed. */
938
+ onState: (callback: (state: HeartRateState) => void) => (() => void);
939
+ };
828
940
  export declare const deepLinks: {
829
941
  getInitialURL(): Promise<string | null>;
830
942
  /**
@@ -1308,6 +1420,7 @@ declare const mobile: {
1308
1420
  brightness: typeof brightness;
1309
1421
  speech: typeof speech;
1310
1422
  music: typeof music;
1423
+ heartRate: typeof heartRate;
1311
1424
  deepLinks: typeof deepLinks;
1312
1425
  network: typeof network;
1313
1426
  appReview: typeof appReview;
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.150";
3306
+ var version = "0.0.152";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.150";
313
+ var version = "0.0.152";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
@@ -5325,6 +5325,7 @@ var notifications = {
5325
5325
  };
5326
5326
  var speakingOnTheWeb = new Set;
5327
5327
  var musicWatch = { listeners: new Set, stop: null };
5328
+ var heartRateWatch = { readings: new Set, devices: new Set, states: new Set, stop: null };
5328
5329
  function onCraftEvent(eventName, callback) {
5329
5330
  if (typeof globalThis === "undefined")
5330
5331
  return () => {};
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.150";
61
+ var version = "0.0.152";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -5085,6 +5085,7 @@ var notifications = {
5085
5085
  };
5086
5086
  var speakingOnTheWeb = new Set;
5087
5087
  var musicWatch = { listeners: new Set, stop: null };
5088
+ var heartRateWatch = { readings: new Set, devices: new Set, states: new Set, stop: null };
5088
5089
  function onCraftEvent(eventName, callback) {
5089
5090
  if (typeof globalThis === "undefined")
5090
5091
  return () => {};
@@ -68,6 +68,15 @@ export interface CraftConfig {
68
68
  * `craft.music.requestAuthorization()`, never at launch.
69
69
  */
70
70
  enableAppleMusic?: boolean;
71
+ /**
72
+ * Read a Bluetooth heart-rate strap, or a watch broadcasting its heart rate
73
+ * (the standard Heart Rate Service), through `craft.heartRate`: scan,
74
+ * connect, live readings with RR intervals, and a recording kept natively.
75
+ * Adds the `bluetooth-central` background mode, so readings keep coming
76
+ * during a workout with the screen locked. iOS asks for Bluetooth the first
77
+ * time the page scans or connects.
78
+ */
79
+ enableHeartRate?: boolean;
71
80
  /**
72
81
  * Purpose strings in the app's own words, by Info.plist key
73
82
  * (`{ NSAppleMusicUsageDescription: 'Shows the song playing during a workout.' }`).
@@ -170,6 +170,7 @@ var DEFAULT_CONFIG = {
170
170
  enableAR: false,
171
171
  enableMLKit: false,
172
172
  enableAppleMusic: false,
173
+ enableHeartRate: false,
173
174
  iosVersion: "16.0",
174
175
  watchosVersion: "9.0",
175
176
  teamId: "",
@@ -226,7 +227,7 @@ function renderUsageDescriptions(config) {
226
227
  [config.enableBackgroundLocation, "NSLocationAlwaysAndWhenInUseUsageDescription", `${config.appName} continues recording your route when the screen is locked or the app is in the background.`],
227
228
  [config.enableContacts, "NSContactsUsageDescription", `${config.appName} accesses contacts only when you choose a contact feature.`],
228
229
  [config.enableCalendar, "NSCalendarsUsageDescription", `${config.appName} accesses your calendar only when you choose a calendar feature.`],
229
- [config.enableBluetooth, "NSBluetoothAlwaysUsageDescription", `${config.appName} uses Bluetooth to connect to nearby devices.`],
230
+ [config.enableBluetooth || config.enableHeartRate, "NSBluetoothAlwaysUsageDescription", config.enableHeartRate ? `${config.appName} connects to your heart-rate monitor over Bluetooth to show and record your heart rate during a workout.` : `${config.appName} uses Bluetooth to connect to nearby devices.`],
230
231
  [config.enableMotionSensors, "NSMotionUsageDescription", `${config.appName} uses motion data for activity features.`],
231
232
  [config.enableNFC, "NFCReaderUsageDescription", `${config.appName} reads NFC tags when you start a scan.`],
232
233
  [config.enableLocalNetwork, "NSLocalNetworkUsageDescription", `${config.appName} connects to your own devices on this network.`],
@@ -309,6 +310,8 @@ function renderBackgroundModes(config) {
309
310
  modes.add("processing");
310
311
  if (config.enablePushNotifications)
311
312
  modes.add("remote-notification");
313
+ if (config.enableHeartRate)
314
+ modes.add("bluetooth-central");
312
315
  if (!modes.size)
313
316
  return "";
314
317
  return ` <key>UIBackgroundModes</key>
@@ -787,9 +790,11 @@ async function init(options) {
787
790
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
788
791
  cpSync(join(TEMPLATES_DIR, "CraftHealth.swift"), join(output, "Sources", "CraftHealth.swift"));
789
792
  cpSync(join(TEMPLATES_DIR, "CraftMusic.swift"), join(output, "Sources", "CraftMusic.swift"));
793
+ cpSync(join(TEMPLATES_DIR, "CraftHeartRate.swift"), join(output, "Sources", "CraftHeartRate.swift"));
790
794
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
791
795
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
792
796
  cpSync(join(TEMPLATES_DIR, "CraftNativeServices.swift"), join(output, "Sources", "CraftNativeServices.swift"));
797
+ cpSync(join(TEMPLATES_DIR, "CraftWebAudio.swift"), join(output, "Sources", "CraftWebAudio.swift"));
793
798
  cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
794
799
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
795
800
  const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{BACKGROUND_TASK_IDENTIFIERS\}\}/g, renderBackgroundTaskIdentifiers(config)).replace(/\{\{LAUNCH_SCREEN\}\}/g, renderLaunchScreen(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
@@ -1119,6 +1119,9 @@ struct CraftConfig: Codable {
1119
1119
  var enableMLKit: Bool = false
1120
1120
  /// The Music app's song and transport through `craft.music` (CraftMusic.swift).
1121
1121
  var enableAppleMusic: Bool = false
1122
+ /// A Bluetooth heart-rate strap (or a watch broadcasting its heart rate)
1123
+ /// through `craft.heartRate` (CraftHeartRate.swift).
1124
+ var enableHeartRate: Bool = false
1122
1125
  var devServerURL: String? = nil
1123
1126
  var trustedOrigins: [String] = []
1124
1127
  /// The longest the launch splash stays up when the page neither says it is
@@ -1641,6 +1644,16 @@ struct CraftWebView: UIViewRepresentable {
1641
1644
  if config.enableMotionSensors {
1642
1645
  motionManager = CMMotionManager()
1643
1646
  }
1647
+ if config.enableHeartRate {
1648
+ // The strap's readings and connection changes reach the page,
1649
+ // and through sendToWeb every native screen too. False once
1650
+ // this coordinator is gone: native screens still hear them.
1651
+ CraftHeartRate.shared.pageSink = { [weak self] detail in
1652
+ guard let self else { return false }
1653
+ self.sendToWeb("craftHeartRate", data: detail)
1654
+ return true
1655
+ }
1656
+ }
1644
1657
  if config.enableWatchApp {
1645
1658
  setupWatchConnectivity()
1646
1659
  }
@@ -2213,6 +2226,22 @@ struct CraftWebView: UIViewRepresentable {
2213
2226
  } else {
2214
2227
  rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
2215
2228
  }
2229
+ // MARK: - Heart rate (CraftHeartRate.swift)
2230
+ // Gated on enableHeartRate. One action for every method, as a
2231
+ // native screen's HeartRate module calls them; asking for
2232
+ // Bluetooth has its own, which waits on the person.
2233
+ case "heartRate", "heartRateRequestAuthorization":
2234
+ if !config.enableHeartRate {
2235
+ rejectCallback(callbackId, error: "Heart rate is disabled", code: "CAPABILITY_DISABLED")
2236
+ } else {
2237
+ let method = action == "heartRate" ? body["method"] as? String ?? "" : "requestAuthorization"
2238
+ CraftHeartRate.shared.perform(method: method, args: [body["options"] as? [String: Any] ?? [:]]) { [weak self] result in
2239
+ switch result {
2240
+ case .success(let value): self?.resolveCallback(callbackId, result: value)
2241
+ case .failure(let failure): self?.rejectCallback(callbackId, error: failure.message, code: failure.code)
2242
+ }
2243
+ }
2244
+ }
2216
2245
  // MARK: - Orientation Lock
2217
2246
  case "lockOrientation":
2218
2247
  if config.enableOrientationLock {
@@ -3198,6 +3227,7 @@ struct CraftWebView: UIViewRepresentable {
3198
3227
  flashlight: true,
3199
3228
  brightness: true,
3200
3229
  music: \(config.enableAppleMusic),
3230
+ heartRate: \(config.enableHeartRate),
3201
3231
  speech: true,
3202
3232
  network: true,
3203
3233
  deviceInfo: true,
@@ -4737,6 +4767,7 @@ struct CraftWebView: UIViewRepresentable {
4737
4767
  var personFacing = {
4738
4768
  requestHealthAuthorization: true,
4739
4769
  musicRequestAuthorization: true,
4770
+ heartRateRequestAuthorization: true,
4740
4771
  requestPermission: true,
4741
4772
  signInWithApple: true,
4742
4773
  dialogAlert: true,
@@ -4951,6 +4982,30 @@ struct CraftWebView: UIViewRepresentable {
4951
4982
  },
4952
4983
  observe: function(enabled) { return craft._invoke('musicObserve', {enabled: enabled !== false}); }
4953
4984
  };
4985
+ // A Bluetooth heart-rate strap, or a watch broadcasting its
4986
+ // heart rate. Readings, devices found by scan() and connection
4987
+ // changes arrive as craftHeartRate events ({type: 'reading' |
4988
+ // 'device' | 'state'}); startRecording() keeps every reading
4989
+ // natively, screen locked or app closed, until stopRecording().
4990
+ (function() {
4991
+ function heartRate(method, options) { return craft._invoke('heartRate', {method: method, options: options || {}}); }
4992
+ craft.heartRate = {
4993
+ getState: function() { return heartRate('getState'); },
4994
+ requestAuthorization: function() { return craft._invoke('heartRateRequestAuthorization'); },
4995
+ scan: function(options) { return heartRate('scan', {timeout: options && typeof options.timeout === 'number' ? options.timeout : null}); },
4996
+ stopScan: function() { return heartRate('stopScan'); },
4997
+ getDevices: function() { return heartRate('getDevices'); },
4998
+ connect: function(id) { return heartRate('connect', {id: typeof id === 'string' ? id : id && id.id}); },
4999
+ reconnect: function() { return heartRate('reconnect'); },
5000
+ disconnect: function(options) { return heartRate('disconnect', {forget: !!(options && options.forget)}); },
5001
+ startRecording: function() { return heartRate('startRecording'); },
5002
+ pauseRecording: function() { return heartRate('pauseRecording'); },
5003
+ resumeRecording: function() { return heartRate('resumeRecording'); },
5004
+ stopRecording: function() { return heartRate('stopRecording'); },
5005
+ getRecordingState: function() { return heartRate('getRecordingState'); },
5006
+ readRecording: function(options) { return heartRate('readRecording', {since: options && typeof options.since === 'number' ? options.since : null}); }
5007
+ };
5008
+ })();
4954
5009
  craft.liveActivity = {
4955
5010
  start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
4956
5011
  update: function(idOrState, state) {
@@ -8450,7 +8505,7 @@ final class CraftZigRuntime: NSObject {
8450
8505
  enum CraftWebServices {
8451
8506
  private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
8452
8507
  /// Coordinator events a native screen hears too.
8453
- static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftMusicChange"]
8508
+ static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftMusicChange", "craftHeartRate"]
8454
8509
  static let event = Notification.Name("craftWebServicesEvent")
8455
8510
 
8456
8511
  static var isAvailable: Bool { CraftSwiftShim.coordinator != nil }