@craft-native/ios 0.0.151 → 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.
package/README.md CHANGED
@@ -179,6 +179,7 @@ Edit `craft.config.json` in your iOS project:
179
179
  "enableNFC": true,
180
180
  "enableHealthKit": false,
181
181
  "enableAppleMusic": false,
182
+ "enableHeartRate": false,
182
183
  "enableScreenCapture": true,
183
184
  "iosVersion": "15.0",
184
185
  "teamId": ""
@@ -558,6 +559,35 @@ window.addEventListener('craftMusicChange', (event) => render(event.detail));
558
559
  // CraftFakeMusicPlayer -bool YES`) for three made-up songs that play (counting as other audio) and skip.
559
560
  // Simulator builds only; a device always talks to the Music app.
560
561
 
562
+ // Heart rate (enableHeartRate): a Bluetooth strap, or a watch broadcasting
563
+ // its heart rate, through the standard Heart Rate Service (0x180D) and its
564
+ // Heart Rate Measurement (0x2A37): bpm, sensor contact, energy and RR
565
+ // intervals. The app gets the `bluetooth-central` background mode, so
566
+ // readings keep coming during a workout with the screen locked. iOS asks
567
+ // for Bluetooth on the first scan or connect (or requestAuthorization()); word
568
+ // it with usageDescriptions.NSBluetoothAlwaysUsageDescription.
569
+ window.addEventListener('craftHeartRate', (event) => {
570
+ // { type: 'device', device: { id, name, rssi } } a scan found a strap
571
+ // { type: 'reading', bpm, rrIntervals, contact, energyExpended, timestamp, deviceId }
572
+ // { type: 'state', connection, bluetooth, device, lastDevice, bpm, ... }
573
+ });
574
+ await window.craft.heartRate.scan({ timeout: 15 }); // straps nearby, and one already connected
575
+ const strap = (await window.craft.heartRate.getDevices())[0]; // strongest signal first
576
+ await window.craft.heartRate.connect(strap.id); // remembered; rejects TIMEOUT after 15 s while still asking
577
+ await window.craft.heartRate.reconnect(); // next time: the remembered strap, no scan
578
+ // A strap that drops out is asked for again (connection 'reconnecting')
579
+ // until disconnect({ forget }) lets it go.
580
+ await window.craft.heartRate.startRecording(); // every reading kept natively, app closed or not
581
+ await window.craft.heartRate.pauseRecording(); // resumeRecording() / getRecordingState()
582
+ const { samples } = await window.craft.heartRate.stopRecording(); // [{ timestamp, bpm, rr? }]
583
+ // Native screens call the same service as the HeartRate module (capability
584
+ // `heartRate`), and hear the same craftHeartRate events. Or use `heartRate`
585
+ // from 'craft-native/mobile' (onReading, onDevice, onState); off a phone it
586
+ // answers { available: false }.
587
+ // The simulator has no Bluetooth: `simctl spawn <device> defaults write
588
+ // <bundle id> CraftFakeHeartRateMonitor -bool YES` stands in a strap ("Simulated
589
+ // HRM") whose readings are encoded and decoded as 0x2A37 payloads. Simulator builds only.
590
+
561
591
  // Speech: short spoken cues that duck the person's music instead of stopping
562
592
  // it, and play with the silent switch on. Always available (no config flag).
563
593
  // Resolves when the utterance ends: true if spoken, false if stopped or
package/dist/cli.js CHANGED
@@ -115,6 +115,7 @@ var DEFAULT_CONFIG = {
115
115
  enableAR: false,
116
116
  enableMLKit: false,
117
117
  enableAppleMusic: false,
118
+ enableHeartRate: false,
118
119
  iosVersion: "16.0",
119
120
  watchosVersion: "9.0",
120
121
  teamId: "",
@@ -171,7 +172,7 @@ function renderUsageDescriptions(config) {
171
172
  [config.enableBackgroundLocation, "NSLocationAlwaysAndWhenInUseUsageDescription", `${config.appName} continues recording your route when the screen is locked or the app is in the background.`],
172
173
  [config.enableContacts, "NSContactsUsageDescription", `${config.appName} accesses contacts only when you choose a contact feature.`],
173
174
  [config.enableCalendar, "NSCalendarsUsageDescription", `${config.appName} accesses your calendar only when you choose a calendar feature.`],
174
- [config.enableBluetooth, "NSBluetoothAlwaysUsageDescription", `${config.appName} uses Bluetooth to connect to nearby devices.`],
175
+ [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.`],
175
176
  [config.enableMotionSensors, "NSMotionUsageDescription", `${config.appName} uses motion data for activity features.`],
176
177
  [config.enableNFC, "NFCReaderUsageDescription", `${config.appName} reads NFC tags when you start a scan.`],
177
178
  [config.enableLocalNetwork, "NSLocalNetworkUsageDescription", `${config.appName} connects to your own devices on this network.`],
@@ -254,6 +255,8 @@ function renderBackgroundModes(config) {
254
255
  modes.add("processing");
255
256
  if (config.enablePushNotifications)
256
257
  modes.add("remote-notification");
258
+ if (config.enableHeartRate)
259
+ modes.add("bluetooth-central");
257
260
  if (!modes.size)
258
261
  return "";
259
262
  return ` <key>UIBackgroundModes</key>
@@ -732,6 +735,7 @@ async function init(options) {
732
735
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
733
736
  cpSync(join(TEMPLATES_DIR, "CraftHealth.swift"), join(output, "Sources", "CraftHealth.swift"));
734
737
  cpSync(join(TEMPLATES_DIR, "CraftMusic.swift"), join(output, "Sources", "CraftMusic.swift"));
738
+ cpSync(join(TEMPLATES_DIR, "CraftHeartRate.swift"), join(output, "Sources", "CraftHeartRate.swift"));
735
739
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
736
740
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
737
741
  cpSync(join(TEMPLATES_DIR, "CraftNativeServices.swift"), join(output, "Sources", "CraftNativeServices.swift"));
package/dist/index.d.ts CHANGED
@@ -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.' }`).
package/dist/index.js CHANGED
@@ -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,6 +790,7 @@ 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"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.151",
3
+ "version": "0.0.152",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 }
@@ -0,0 +1,699 @@
1
+ import CoreBluetooth
2
+ import Foundation
3
+ import UIKit
4
+
5
+ /// A heart-rate call that did not happen: the message a page sees, and the
6
+ /// bridge's error code.
7
+ struct CraftHeartRateError: Error {
8
+ let message: String
9
+ var code: String = "CRAFT_ERROR"
10
+ }
11
+
12
+ /// One Heart Rate Measurement (characteristic 0x2A37 of the Bluetooth SIG's
13
+ /// Heart Rate Service, 0x180D), decoded.
14
+ ///
15
+ /// The payload is a flags byte and then the fields the flags announce, all
16
+ /// little-endian:
17
+ ///
18
+ /// bit 0 the heart rate is a uint16 (else a uint8)
19
+ /// bits 1-2 sensor contact: bit 2 says the strap reports it at all,
20
+ /// bit 1 that the skin is touching (a strap worn loose reads 0)
21
+ /// bit 3 a uint16 of energy expended, kilojoules, follows the rate
22
+ /// bit 4 uint16 RR intervals follow, as many as fit, in 1/1024 s
23
+ ///
24
+ /// Every strap and every watch that broadcasts its heart rate (Polar, Garmin,
25
+ /// Wahoo, an Apple Watch with a broadcasting app) sends this one shape.
26
+ struct CraftHeartRateMeasurement: Equatable {
27
+ /// Beats per minute.
28
+ let bpm: Int
29
+ /// Whether the strap is touching skin; nil when it cannot tell.
30
+ let sensorContact: Bool?
31
+ /// Kilojoules since the strap was last reset; nil when it does not count them.
32
+ let energyExpended: Int?
33
+ /// The time between beats, in milliseconds, oldest first.
34
+ let rrIntervals: [Double]
35
+
36
+ /// Nil for a payload too short for what its flags announce.
37
+ static func parse(_ bytes: [UInt8]) -> CraftHeartRateMeasurement? {
38
+ guard let flags = bytes.first else { return nil }
39
+ var offset = 1
40
+ func uint8() -> Int? {
41
+ guard offset < bytes.count else { return nil }
42
+ defer { offset += 1 }
43
+ return Int(bytes[offset])
44
+ }
45
+ func uint16() -> Int? {
46
+ guard offset + 1 < bytes.count else { return nil }
47
+ defer { offset += 2 }
48
+ return Int(bytes[offset]) | Int(bytes[offset + 1]) << 8
49
+ }
50
+ guard let bpm = flags & 0x01 != 0 ? uint16() : uint8() else { return nil }
51
+ let contact: Bool? = flags & 0x04 != 0 ? flags & 0x02 != 0 : nil
52
+ var energy: Int?
53
+ if flags & 0x08 != 0 {
54
+ guard let value = uint16() else { return nil }
55
+ energy = value
56
+ }
57
+ var intervals: [Double] = []
58
+ if flags & 0x10 != 0 {
59
+ while let value = uint16() { intervals.append(Double(value) * 1000 / 1024) }
60
+ }
61
+ return CraftHeartRateMeasurement(bpm: bpm, sensorContact: contact, energyExpended: energy, rrIntervals: intervals)
62
+ }
63
+
64
+ static func parse(_ data: Data) -> CraftHeartRateMeasurement? { parse([UInt8](data)) }
65
+
66
+ /// The payload a strap would send for these values: the simulator's
67
+ /// stand-in speaks it, so its readings take the same path as a strap's.
68
+ static func encode(bpm: Int, sensorContact: Bool? = true, energyExpended: Int? = nil, rrIntervals: [Double] = []) -> [UInt8] {
69
+ var flags: UInt8 = 0
70
+ var body: [UInt8] = []
71
+ func append16(_ value: Int) { body.append(UInt8(value & 0xFF)); body.append(UInt8((value >> 8) & 0xFF)) }
72
+ if bpm > 255 {
73
+ flags |= 0x01
74
+ append16(bpm)
75
+ } else {
76
+ body.append(UInt8(max(0, bpm)))
77
+ }
78
+ if let contact = sensorContact { flags |= 0x04 | (contact ? 0x02 : 0) }
79
+ if let energy = energyExpended {
80
+ flags |= 0x08
81
+ append16(energy)
82
+ }
83
+ if !rrIntervals.isEmpty {
84
+ flags |= 0x10
85
+ for interval in rrIntervals { append16(Int((interval * 1024 / 1000).rounded())) }
86
+ }
87
+ return [flags] + body
88
+ }
89
+ }
90
+
91
+ /// A heart-rate strap, or a watch broadcasting its heart rate, read during a
92
+ /// workout: the page's `craft.heartRate` (CraftApp.swift) and a native
93
+ /// screen's `HeartRate` module (CraftNativeActions.swift), one service for
94
+ /// both.
95
+ ///
96
+ /// CoreBluetooth central for the standard Heart Rate Service (0x180D):
97
+ /// - `scan` finds what advertises the service nearby (and a strap the phone
98
+ /// is already connected to, which does not advertise), each one a
99
+ /// `{type: 'device'}` event with its name and signal strength.
100
+ /// - `connect` subscribes to Heart Rate Measurement (0x2A37); every reading
101
+ /// is a `{type: 'reading'}` event with bpm and, when the strap sends them,
102
+ /// RR intervals. The device is remembered, and `reconnect` finds it again
103
+ /// next time without a scan.
104
+ /// - A strap that drops out (out of range, the battery, a sweaty contact) is
105
+ /// asked for again until `disconnect`: CoreBluetooth's pending connect
106
+ /// never times out, so it comes back by itself when the strap does.
107
+ /// - `startRecording` keeps every reading on disk until `stopRecording`, as
108
+ /// the location recording keeps fixes: the readings survive the web view
109
+ /// being suspended with the screen locked (the `bluetooth-central`
110
+ /// background mode keeps them coming) and the app being closed.
111
+ ///
112
+ /// Connection changes are `{type: 'state'}` events. All of them reach the
113
+ /// page as `craftHeartRate` and every native screen the same way.
114
+ ///
115
+ /// Simulator builds have no Bluetooth; with the `CraftFakeHeartRateMonitor`
116
+ /// default set (`xcrun simctl spawn booted defaults write <bundle id>
117
+ /// CraftFakeHeartRateMonitor -bool YES`) a made-up strap stands in, whose
118
+ /// readings are encoded as 0x2A37 payloads and decoded like a real one's.
119
+ ///
120
+ /// Everything runs on main: the central manager delivers there, and every
121
+ /// caller hops there first.
122
+ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
123
+ typealias Completion = (Result<Any, CraftHeartRateError>) -> Void
124
+
125
+ static let shared = CraftHeartRate()
126
+
127
+ static let serviceUUID = CBUUID(string: "180D")
128
+ static let measurementUUID = CBUUID(string: "2A37")
129
+ /// How long `connect` waits before answering TIMEOUT. The connection is
130
+ /// still asked for after that: a strap only wakes when it is worn.
131
+ static let connectTimeout: TimeInterval = 15
132
+ /// A reading older than this is no longer "now".
133
+ static let staleAfter: TimeInterval = 10
134
+ /// Fourteen hours of one reading a second: more is not one workout.
135
+ static let maxRecordedSamples = 50_000
136
+
137
+ /// The web view's coordinator, which sends an event to the page and every
138
+ /// native screen. False (or unset, in an app without a web view) and the
139
+ /// event goes to the native screens alone.
140
+ var pageSink: (([String: Any]) -> Bool)?
141
+
142
+ private var central: CBCentralManager?
143
+ private var stateWaiters: [() -> Void] = []
144
+ private var discovered: [UUID: (peripheral: CBPeripheral?, name: String, rssi: Int)] = [:]
145
+ private var scanning = false
146
+ private var scanStop: DispatchWorkItem?
147
+ private var peripheral: CBPeripheral?
148
+ private var deviceId: UUID?
149
+ private var deviceName: String?
150
+ private var connection = "disconnected"
151
+ /// Between connect and disconnect: a lost strap is asked for again.
152
+ private var wantsConnection = false
153
+ private var connectWaiters: [Completion] = []
154
+ private var connectDeadline: DispatchWorkItem?
155
+ private var latest: CraftHeartRateMeasurement?
156
+ private var latestAt: Date?
157
+
158
+ private var recordingId: String?
159
+ private var recordingStartedAt: Double?
160
+ private var recordingPaused = false
161
+ private var recordedCount = 0
162
+ private var restored = false
163
+
164
+ private static let rememberedKey = "CraftHeartRate.lastDevice"
165
+
166
+ // MARK: The simulator's stand-in
167
+
168
+ /// A strap that is not there: one device, readings once a second.
169
+ private var simulated: Bool = {
170
+ #if targetEnvironment(simulator)
171
+ return UserDefaults.standard.bool(forKey: "CraftFakeHeartRateMonitor")
172
+ #else
173
+ return false
174
+ #endif
175
+ }()
176
+ private static let simulatedId = UUID(uuidString: "00000000-0000-4000-8000-00000000180D")!
177
+ private var simulatedTimer: Timer?
178
+ private var simulatedBeat = 0
179
+
180
+ // MARK: Entry
181
+
182
+ /// One call from a page or a native screen, by method name.
183
+ func perform(method: String, args: [Any], completion: @escaping Completion) {
184
+ let options = args.first as? [String: Any] ?? [:]
185
+ DispatchQueue.main.async {
186
+ self.restoreRecording()
187
+ switch method {
188
+ case "getState": completion(.success(self.state()))
189
+ case "requestAuthorization": self.requestAuthorization(completion: completion)
190
+ case "scan": self.scan(seconds: (options["timeout"] as? NSNumber)?.doubleValue, completion: completion)
191
+ case "stopScan":
192
+ self.stopScan()
193
+ completion(.success(self.state()))
194
+ case "getDevices": completion(.success(self.devices()))
195
+ case "connect": self.connect(id: options["id"] as? String, wait: true, completion: completion)
196
+ case "reconnect": self.connect(id: nil, wait: false, completion: completion)
197
+ case "disconnect": self.disconnect(forget: options["forget"] as? Bool ?? false, completion: completion)
198
+ case "startRecording": self.startRecording(completion: completion)
199
+ case "pauseRecording": self.setRecordingPaused(true, completion: completion)
200
+ case "resumeRecording": self.setRecordingPaused(false, completion: completion)
201
+ case "stopRecording": self.stopRecording(completion: completion)
202
+ case "getRecordingState": completion(.success(self.recordingSummary(count: true)))
203
+ case "readRecording": completion(.success(self.loadSamples(since: (options["since"] as? NSNumber)?.doubleValue)))
204
+ default: completion(.failure(CraftHeartRateError(message: "Unsupported HeartRate method", code: "UNKNOWN_ACTION")))
205
+ }
206
+ }
207
+ }
208
+
209
+ // MARK: State
210
+
211
+ static func authorizationName(_ value: CBManagerAuthorization) -> String {
212
+ switch value {
213
+ case .allowedAlways: return "authorized"
214
+ case .denied: return "denied"
215
+ case .restricted: return "restricted"
216
+ case .notDetermined: return "notDetermined"
217
+ @unknown default: return "denied"
218
+ }
219
+ }
220
+
221
+ static func bluetoothName(_ value: CBManagerState) -> String {
222
+ switch value {
223
+ case .poweredOn: return "poweredOn"
224
+ case .poweredOff: return "poweredOff"
225
+ case .unauthorized: return "unauthorized"
226
+ case .unsupported: return "unsupported"
227
+ case .resetting: return "resetting"
228
+ case .unknown: return "unknown"
229
+ @unknown default: return "unknown"
230
+ }
231
+ }
232
+
233
+ private var authorization: String { simulated ? "authorized" : Self.authorizationName(CBManager.authorization) }
234
+
235
+ /// "unknown" until something has asked for Bluetooth: reading it would
236
+ /// make the central manager, and that is what shows iOS's prompt.
237
+ private var bluetooth: String {
238
+ if simulated { return "poweredOn" }
239
+ guard let central else { return "unknown" }
240
+ return Self.bluetoothName(central.state)
241
+ }
242
+
243
+ private func remembered() -> [String: Any]? {
244
+ guard let device = UserDefaults.standard.dictionary(forKey: Self.rememberedKey),
245
+ device["id"] is String else { return nil }
246
+ return device
247
+ }
248
+
249
+ private func remember(id: UUID, name: String) {
250
+ UserDefaults.standard.set(["id": id.uuidString, "name": name], forKey: Self.rememberedKey)
251
+ }
252
+
253
+ func state() -> [String: Any] {
254
+ let fresh = latestAt.map { Date().timeIntervalSince($0) <= Self.staleAfter } ?? false
255
+ let device: Any = deviceId.map { ["id": $0.uuidString, "name": deviceName ?? "Heart rate monitor"] as [String: Any] } ?? NSNull()
256
+ return [
257
+ "available": bluetooth != "unsupported",
258
+ "bluetooth": bluetooth,
259
+ "authorization": authorization,
260
+ "scanning": scanning,
261
+ "connection": connection,
262
+ "device": device,
263
+ "lastDevice": remembered() ?? NSNull(),
264
+ "bpm": (fresh ? latest?.bpm : nil) ?? NSNull(),
265
+ "lastReadingAt": latestAt.map { $0.timeIntervalSince1970 * 1000 } ?? NSNull(),
266
+ "recording": recordingSummary(count: false),
267
+ ]
268
+ }
269
+
270
+ private func emit(_ detail: [String: Any]) {
271
+ if pageSink?(detail) != true { CraftWebServices.forward("craftHeartRate", data: detail) }
272
+ }
273
+
274
+ private func emitState() {
275
+ var detail = state()
276
+ detail["type"] = "state"
277
+ emit(detail)
278
+ }
279
+
280
+ // MARK: Bluetooth
281
+
282
+ /// The central manager, made on first use: that is when iOS asks.
283
+ private func whenReady(_ run: @escaping () -> Void) {
284
+ if simulated { return run() }
285
+ if central == nil {
286
+ central = CBCentralManager(delegate: self, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: false])
287
+ }
288
+ if central!.state == .unknown || central!.state == .resetting {
289
+ stateWaiters.append(run)
290
+ } else {
291
+ run()
292
+ }
293
+ }
294
+
295
+ /// A reason the radio cannot be used now, or nil when it can.
296
+ private func unusable() -> CraftHeartRateError? {
297
+ switch bluetooth {
298
+ case "poweredOn": return nil
299
+ case "poweredOff": return CraftHeartRateError(message: "Bluetooth is off", code: "BLUETOOTH_OFF")
300
+ case "unauthorized": return CraftHeartRateError(message: "Bluetooth access was not allowed", code: "NOT_AUTHORIZED")
301
+ case "unsupported": return CraftHeartRateError(message: "This device has no Bluetooth", code: "UNSUPPORTED")
302
+ default: return CraftHeartRateError(message: "Bluetooth is not ready", code: "BLUETOOTH_UNAVAILABLE")
303
+ }
304
+ }
305
+
306
+ private func requestAuthorization(completion: @escaping Completion) {
307
+ whenReady { completion(.success(self.authorization)) }
308
+ }
309
+
310
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
311
+ let waiting = stateWaiters
312
+ if central.state != .unknown && central.state != .resetting {
313
+ stateWaiters.removeAll()
314
+ waiting.forEach { $0() }
315
+ }
316
+ if central.state == .poweredOn {
317
+ // Bluetooth back on (or on at last): ask for the strap again.
318
+ if wantsConnection, let peripheral, peripheral.state == .disconnected {
319
+ connection = "reconnecting"
320
+ central.connect(peripheral)
321
+ }
322
+ } else if central.state != .unknown {
323
+ scanning = false
324
+ if wantsConnection && connection != "disconnected" { connection = "reconnecting" }
325
+ }
326
+ emitState()
327
+ }
328
+
329
+ // MARK: Scanning
330
+
331
+ private func devices() -> [[String: Any]] {
332
+ discovered
333
+ .map { id, device in ["id": id.uuidString, "name": device.name, "rssi": device.rssi] as [String: Any] }
334
+ .sorted { ($0["rssi"] as? Int ?? -200) > ($1["rssi"] as? Int ?? -200) }
335
+ }
336
+
337
+ private func found(id: UUID, peripheral: CBPeripheral?, name: String, rssi: Int) {
338
+ let known = discovered[id] != nil
339
+ discovered[id] = (peripheral, name, rssi)
340
+ if !known { emit(["type": "device", "device": ["id": id.uuidString, "name": name, "rssi": rssi]]) }
341
+ }
342
+
343
+ private func scan(seconds: Double?, completion: @escaping Completion) {
344
+ whenReady {
345
+ if let reason = self.unusable() { return completion(.failure(reason)) }
346
+ self.discovered.removeAll()
347
+ self.scanning = true
348
+ if self.simulated {
349
+ self.found(id: Self.simulatedId, peripheral: nil, name: "Simulated HRM", rssi: -48)
350
+ } else if let central = self.central {
351
+ // A strap already connected to this phone (by Settings or
352
+ // another app) no longer advertises; it is still a choice.
353
+ for peripheral in central.retrieveConnectedPeripherals(withServices: [Self.serviceUUID]) {
354
+ self.found(id: peripheral.identifier, peripheral: peripheral, name: peripheral.name ?? "Heart rate monitor", rssi: 0)
355
+ }
356
+ central.scanForPeripherals(withServices: [Self.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
357
+ }
358
+ self.scanStop?.cancel()
359
+ let stop = DispatchWorkItem { [weak self] in self?.stopScan() }
360
+ self.scanStop = stop
361
+ DispatchQueue.main.asyncAfter(deadline: .now() + min(max(seconds ?? 15, 1), 60), execute: stop)
362
+ self.emitState()
363
+ completion(.success(self.state()))
364
+ }
365
+ }
366
+
367
+ private func stopScan() {
368
+ scanStop?.cancel()
369
+ scanStop = nil
370
+ guard scanning else { return }
371
+ scanning = false
372
+ if central?.state == .poweredOn { central?.stopScan() }
373
+ emitState()
374
+ }
375
+
376
+ func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
377
+ let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name ?? "Heart rate monitor"
378
+ // 127 is CoreBluetooth's "no reading".
379
+ found(id: peripheral.identifier, peripheral: peripheral, name: name, rssi: RSSI.intValue == 127 ? -100 : RSSI.intValue)
380
+ }
381
+
382
+ // MARK: Connecting
383
+
384
+ private func connect(id: String?, wait: Bool, completion: @escaping Completion) {
385
+ let target = id ?? remembered()?["id"] as? String
386
+ guard let target, let uuid = UUID(uuidString: target) else {
387
+ if wait { return completion(.failure(CraftHeartRateError(message: "connect needs the id of a device from scan", code: "INVALID_ARGUMENT"))) }
388
+ return completion(.success(state()))
389
+ }
390
+ whenReady {
391
+ if let reason = self.unusable() { return completion(.failure(reason)) }
392
+ // Already there, or on the way: nothing to start again.
393
+ if self.deviceId == uuid && self.wantsConnection {
394
+ if self.connection == "connected" || !wait { return completion(.success(self.state())) }
395
+ self.connectWaiters.append(completion)
396
+ self.armConnectDeadline()
397
+ return
398
+ }
399
+ self.release()
400
+ let saved = self.remembered()
401
+ let savedName = (saved?["id"] as? String) == target ? saved?["name"] as? String : nil
402
+ let name = self.discovered[uuid]?.name ?? savedName ?? "Heart rate monitor"
403
+ if self.simulated {
404
+ guard uuid == Self.simulatedId else { return completion(.failure(CraftHeartRateError(message: "No heart-rate monitor with that id is nearby", code: "NOT_FOUND"))) }
405
+ } else {
406
+ let peripheral = self.discovered[uuid]?.peripheral ?? self.central?.retrievePeripherals(withIdentifiers: [uuid]).first
407
+ guard let peripheral else { return completion(.failure(CraftHeartRateError(message: "No heart-rate monitor with that id is nearby", code: "NOT_FOUND"))) }
408
+ peripheral.delegate = self
409
+ self.peripheral = peripheral
410
+ }
411
+ self.deviceId = uuid
412
+ self.deviceName = name
413
+ self.wantsConnection = true
414
+ self.connection = "connecting"
415
+ self.remember(id: uuid, name: name)
416
+ if wait {
417
+ self.connectWaiters.append(completion)
418
+ self.armConnectDeadline()
419
+ }
420
+ if self.simulated {
421
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.simulatedConnected() }
422
+ } else if let peripheral = self.peripheral {
423
+ self.central?.connect(peripheral)
424
+ }
425
+ self.emitState()
426
+ if !wait { completion(.success(self.state())) }
427
+ }
428
+ }
429
+
430
+ /// `connect` answers TIMEOUT after a while; the strap is still asked
431
+ /// for, since it may wake up once it is worn.
432
+ private func armConnectDeadline() {
433
+ guard connectDeadline == nil else { return }
434
+ let deadline = DispatchWorkItem { [weak self] in
435
+ guard let self, self.connection != "connected" else { return }
436
+ self.settleConnect(.failure(CraftHeartRateError(message: "The heart-rate monitor did not answer. Wear it to wake it up.", code: "TIMEOUT")))
437
+ }
438
+ connectDeadline = deadline
439
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.connectTimeout, execute: deadline)
440
+ }
441
+
442
+ private func settleConnect(_ result: Result<Any, CraftHeartRateError>) {
443
+ connectDeadline?.cancel()
444
+ connectDeadline = nil
445
+ let waiting = connectWaiters
446
+ connectWaiters.removeAll()
447
+ waiting.forEach { $0(result) }
448
+ }
449
+
450
+ /// Lets go of the current strap without telling anyone.
451
+ private func release() {
452
+ wantsConnection = false
453
+ simulatedTimer?.invalidate()
454
+ simulatedTimer = nil
455
+ if let peripheral, let central, peripheral.state != .disconnected { central.cancelPeripheralConnection(peripheral) }
456
+ peripheral = nil
457
+ deviceId = nil
458
+ deviceName = nil
459
+ latest = nil
460
+ latestAt = nil
461
+ connection = "disconnected"
462
+ settleConnect(.failure(CraftHeartRateError(message: "Another heart-rate monitor was chosen", code: "CANCELLED")))
463
+ }
464
+
465
+ private func disconnect(forget: Bool, completion: @escaping Completion) {
466
+ release()
467
+ if forget { UserDefaults.standard.removeObject(forKey: Self.rememberedKey) }
468
+ emitState()
469
+ completion(.success(state()))
470
+ }
471
+
472
+ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
473
+ guard peripheral.identifier == deviceId else { return }
474
+ peripheral.delegate = self
475
+ peripheral.discoverServices([Self.serviceUUID])
476
+ }
477
+
478
+ func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
479
+ guard peripheral.identifier == deviceId, wantsConnection else { return }
480
+ // Asked for again: a failed attempt is usually a strap going to sleep.
481
+ connection = "reconnecting"
482
+ central.connect(peripheral)
483
+ emitState()
484
+ }
485
+
486
+ func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
487
+ guard peripheral.identifier == deviceId else { return }
488
+ latest = nil
489
+ latestAt = nil
490
+ if wantsConnection {
491
+ connection = "reconnecting"
492
+ central.connect(peripheral)
493
+ } else {
494
+ connection = "disconnected"
495
+ }
496
+ emitState()
497
+ }
498
+
499
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
500
+ guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
501
+ notAHeartRateMonitor(peripheral)
502
+ return
503
+ }
504
+ peripheral.discoverCharacteristics([Self.measurementUUID], for: service)
505
+ }
506
+
507
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
508
+ guard let measurement = service.characteristics?.first(where: { $0.uuid == Self.measurementUUID }) else {
509
+ notAHeartRateMonitor(peripheral)
510
+ return
511
+ }
512
+ peripheral.setNotifyValue(true, for: measurement)
513
+ }
514
+
515
+ func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
516
+ guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId else { return }
517
+ if characteristic.isNotifying {
518
+ connection = "connected"
519
+ emitState()
520
+ settleConnect(.success(state()))
521
+ } else if let error {
522
+ settleConnect(.failure(CraftHeartRateError(message: error.localizedDescription, code: "CONNECT_FAILED")))
523
+ }
524
+ }
525
+
526
+ func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
527
+ guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, let value = characteristic.value else { return }
528
+ received([UInt8](value))
529
+ }
530
+
531
+ private func notAHeartRateMonitor(_ peripheral: CBPeripheral) {
532
+ wantsConnection = false
533
+ central?.cancelPeripheralConnection(peripheral)
534
+ connection = "disconnected"
535
+ settleConnect(.failure(CraftHeartRateError(message: "That device does not send a heart rate", code: "NOT_HEART_RATE_MONITOR")))
536
+ emitState()
537
+ }
538
+
539
+ // MARK: Readings
540
+
541
+ private func received(_ bytes: [UInt8]) {
542
+ // A strap reads 0 between losing the skin and giving up; that is no
543
+ // heart rate, and neither is anything a heart could not do.
544
+ guard let reading = CraftHeartRateMeasurement.parse(bytes), reading.bpm >= 20, reading.bpm <= 250 else { return }
545
+ let now = Date()
546
+ latest = reading
547
+ latestAt = now
548
+ let timestamp = (now.timeIntervalSince1970 * 1000).rounded()
549
+ let intervals = reading.rrIntervals.map { ($0 * 10).rounded() / 10 }
550
+ var detail: [String: Any] = [
551
+ "type": "reading",
552
+ "bpm": reading.bpm,
553
+ "rrIntervals": intervals,
554
+ "contact": reading.sensorContact ?? NSNull(),
555
+ "energyExpended": reading.energyExpended ?? NSNull(),
556
+ "timestamp": timestamp,
557
+ ]
558
+ if let deviceId { detail["deviceId"] = deviceId.uuidString }
559
+ appendSample(["timestamp": timestamp, "bpm": reading.bpm, "rr": intervals])
560
+ emit(detail)
561
+ }
562
+
563
+ private func simulatedConnected() {
564
+ guard simulated, wantsConnection, deviceId == Self.simulatedId else { return }
565
+ connection = "connected"
566
+ emitState()
567
+ settleConnect(.success(state()))
568
+ simulatedTimer?.invalidate()
569
+ simulatedTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
570
+ guard let self else { return }
571
+ self.simulatedBeat += 1
572
+ // A steady run with a slow swell, the way a real one drifts.
573
+ let bpm = 142 + Int((sin(Double(self.simulatedBeat) / 20) * 12).rounded())
574
+ let interval = 60_000 / Double(bpm)
575
+ self.received(CraftHeartRateMeasurement.encode(bpm: bpm, sensorContact: true, rrIntervals: [interval]))
576
+ }
577
+ }
578
+
579
+ // MARK: Recording
580
+
581
+ private var directory: URL {
582
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
583
+ }
584
+ private var stateURL: URL { directory.appendingPathComponent("craft-heart-rate-recording-state.json") }
585
+ private var samplesURL: URL { directory.appendingPathComponent("craft-heart-rate-recording.jsonl") }
586
+
587
+ /// A recording that was running when the app last closed carries on.
588
+ private func restoreRecording() {
589
+ guard !restored else { return }
590
+ restored = true
591
+ guard let data = try? Data(contentsOf: stateURL),
592
+ let saved = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
593
+ saved["active"] as? Bool == true else { return }
594
+ recordingId = saved["id"] as? String
595
+ recordingStartedAt = saved["startedAt"] as? Double
596
+ recordingPaused = saved["paused"] as? Bool ?? false
597
+ recordedCount = loadSamples(since: nil).count
598
+ }
599
+
600
+ private func persistRecording() {
601
+ let saved: [String: Any] = [
602
+ "active": recordingId != nil,
603
+ "paused": recordingPaused,
604
+ "id": recordingId ?? NSNull(),
605
+ "startedAt": recordingStartedAt ?? NSNull(),
606
+ ]
607
+ do {
608
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
609
+ try JSONSerialization.data(withJSONObject: saved).write(to: stateURL, options: .atomic)
610
+ protect(stateURL)
611
+ } catch {
612
+ print("Unable to persist the heart-rate recording state: \(error)")
613
+ }
614
+ }
615
+
616
+ private func protect(_ url: URL) {
617
+ try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: url.path)
618
+ var values = URLResourceValues()
619
+ values.isExcludedFromBackup = true
620
+ var protectedURL = url
621
+ try? protectedURL.setResourceValues(values)
622
+ }
623
+
624
+ private func recordingSummary(count: Bool) -> [String: Any] {
625
+ var summary: [String: Any] = [
626
+ "id": recordingId ?? NSNull(),
627
+ "active": recordingId != nil,
628
+ "paused": recordingPaused,
629
+ "startedAt": recordingStartedAt ?? NSNull(),
630
+ ]
631
+ if count { summary["sampleCount"] = recordedCount }
632
+ return summary
633
+ }
634
+
635
+ private func startRecording(completion: @escaping Completion) {
636
+ do {
637
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
638
+ try Data().write(to: samplesURL, options: .atomic)
639
+ protect(samplesURL)
640
+ } catch {
641
+ return completion(.failure(CraftHeartRateError(message: "Unable to create the heart-rate recording", code: "RECORDING_STORAGE_ERROR")))
642
+ }
643
+ recordingId = UUID().uuidString
644
+ recordingStartedAt = (Date().timeIntervalSince1970 * 1000).rounded()
645
+ recordingPaused = false
646
+ recordedCount = 0
647
+ persistRecording()
648
+ completion(.success(recordingSummary(count: true)))
649
+ }
650
+
651
+ private func setRecordingPaused(_ paused: Bool, completion: @escaping Completion) {
652
+ guard recordingId != nil else {
653
+ return completion(.failure(CraftHeartRateError(message: "No active heart-rate recording", code: "NO_ACTIVE_RECORDING")))
654
+ }
655
+ recordingPaused = paused
656
+ persistRecording()
657
+ completion(.success(recordingSummary(count: true)))
658
+ }
659
+
660
+ private func stopRecording(completion: @escaping Completion) {
661
+ var summary = recordingSummary(count: true)
662
+ summary["active"] = false
663
+ summary["samples"] = loadSamples(since: nil)
664
+ recordingId = nil
665
+ recordingStartedAt = nil
666
+ recordingPaused = false
667
+ recordedCount = 0
668
+ persistRecording()
669
+ try? FileManager.default.removeItem(at: samplesURL)
670
+ completion(.success(summary))
671
+ }
672
+
673
+ private func appendSample(_ sample: [String: Any]) {
674
+ guard recordingId != nil, !recordingPaused, recordedCount < Self.maxRecordedSamples,
675
+ let json = try? JSONSerialization.data(withJSONObject: sample) else { return }
676
+ var line = json
677
+ line.append(0x0A)
678
+ do {
679
+ let handle = try FileHandle(forWritingTo: samplesURL)
680
+ try handle.seekToEnd()
681
+ try handle.write(contentsOf: line)
682
+ try handle.close()
683
+ recordedCount += 1
684
+ } catch {
685
+ print("Unable to append a heart-rate sample: \(error)")
686
+ }
687
+ }
688
+
689
+ /// The recording's samples, oldest first; only those after `since` (ms) when given.
690
+ private func loadSamples(since: Double?) -> [[String: Any]] {
691
+ guard let text = try? String(contentsOf: samplesURL, encoding: .utf8) else { return [] }
692
+ return text.split(separator: "\n").compactMap { line in
693
+ guard let data = line.data(using: .utf8),
694
+ let sample = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
695
+ if let since, ((sample["timestamp"] as? NSNumber)?.doubleValue ?? 0) <= since { return nil }
696
+ return sample
697
+ }
698
+ }
699
+ }
@@ -61,6 +61,9 @@ enum CraftNativeActions {
61
61
  if config.enableGeolocation && config.renderer != "native" { values.append("geolocation") }
62
62
  // The Music app's song and transport are the coordinator's too.
63
63
  if config.enableAppleMusic && config.renderer != "native" { values.append("music") }
64
+ // A heart-rate strap is the app's own service (CraftHeartRate.swift),
65
+ // so it needs no web view.
66
+ if config.enableHeartRate { values.append("heartRate") }
64
67
  return values
65
68
  }
66
69
 
@@ -290,6 +293,19 @@ enum CraftNativeActions {
290
293
  }
291
294
  CraftWebServices.perform(action: action, body: args.first as? [String: Any] ?? [:]) { complete(requestToken, $0, completion) }
292
295
  return
296
+ case ("HeartRate", _):
297
+ // A Bluetooth heart-rate strap, the same service as a page's
298
+ // `craft.heartRate`: one connection and one recording, whichever
299
+ // side started them. Its events reach every screen as
300
+ // `craftHeartRate`.
301
+ guard config.enableHeartRate else {
302
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Heart rate is disabled"), completion)
303
+ return
304
+ }
305
+ CraftHeartRate.shared.perform(method: method, args: args) { result in
306
+ complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
307
+ }
308
+ return
293
309
  case ("Linking", "openURL"), ("Linking", "canOpenURL"):
294
310
  // Another app or the system: `webcal:` subscriptions, `mailto:`,
295
311
  // `tel:`, the App Store, this app's page in Settings