craft-native 0.0.164 → 0.0.167

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.
@@ -614,12 +614,16 @@ final class CraftChrome: ObservableObject {
614
614
  @Published var tabBarVisible = false
615
615
  @Published var splashVisible = true
616
616
  @Published var tint: UIColor?
617
+ /// Whether a hybrid page lets the shell claim horizontal swipes for tabs.
618
+ /// Native screens use their own per-screen `tabSwipeEnabled` option.
619
+ private(set) var tabSwipeEnabled = true
617
620
 
618
621
  weak var webView: WKWebView?
619
622
  /// What the tab bar covers at the bottom of the screen, in points.
620
623
  private var occupied: CGFloat = 0
621
624
  private var pendingHide: DispatchWorkItem?
622
625
  private var splashDeadline: DispatchWorkItem?
626
+ private var tabSwipePrevious: String?
623
627
  /// The keyboard is up and covers the tab bar, so the page need not leave
624
628
  /// room for it: the web view ends at the keyboard (CraftHybrid's webHost).
625
629
  private var keyboardUp = false
@@ -645,6 +649,7 @@ final class CraftChrome: ObservableObject {
645
649
  /// false when the first screen is native and draws itself.
646
650
  func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
647
651
  splashVisible = splash
652
+ tabSwipeEnabled = true
648
653
  guard !configured.isEmpty else { return }
649
654
  tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil) }
650
655
  self.selected = selected ?? configured.first?.id
@@ -692,6 +697,7 @@ final class CraftChrome: ObservableObject {
692
697
  let dark = (body["tintDark"] as? String).flatMap { UIColor(hex: $0) }
693
698
  tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
694
699
  }
700
+ if let swipeEnabled = body["swipeEnabled"] as? Bool { tabSwipeEnabled = swipeEnabled }
695
701
  withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen }
696
702
  publishLayout()
697
703
  case "selectTab":
@@ -750,6 +756,29 @@ final class CraftChrome: ObservableObject {
750
756
  emit("craftTabSelect", ["id": id])
751
757
  }
752
758
 
759
+ /// Move the tab indicator with an in-flight native-stack swipe. The
760
+ /// controller commits or restores this preview when the finger lifts.
761
+ func beginTabSwipe(to id: String) {
762
+ guard id != selected, tabs.contains(where: { $0.id == id }) else { return }
763
+ tabSwipePrevious = selected
764
+ withAnimation(nil) { selected = id }
765
+ }
766
+
767
+ func cancelTabSwipe() {
768
+ guard let previous = tabSwipePrevious else { return }
769
+ tabSwipePrevious = nil
770
+ withAnimation(nil) { selected = previous }
771
+ }
772
+
773
+ func commitTabSwipe(to id: String) {
774
+ tabSwipePrevious = nil
775
+ guard tabs.contains(where: { $0.id == id }) else { return }
776
+ withAnimation(nil) { selected = id }
777
+ UISelectionFeedbackGenerator().selectionChanged()
778
+ guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
779
+ emit("craftTabSelect", ["id": id])
780
+ }
781
+
753
782
  /// The animation, or none with Reduce Motion on: the selection jumps to
754
783
  /// its tab rather than springing there, as UIKit's own controls do.
755
784
  static func motion(_ animation: Animation) -> Animation? {
@@ -1683,14 +1712,22 @@ struct CraftWebView: UIViewRepresentable {
1683
1712
  // Keep awake
1684
1713
  private var isKeepingAwake = false
1685
1714
 
1686
- // Speech synthesis. One synthesizer for the life of the web view, so a
1687
- // cue can interrupt the one before it; each utterance it has not
1688
- // finished yet is held beside the call waiting on it.
1715
+ // Speech synthesis. Each utterance not finished yet is held beside the
1716
+ // call waiting on it. A cue that interrupts one still sounding speaks
1717
+ // on a fresh synthesizer: AVSpeechSynthesizer can drop an utterance
1718
+ // handed to it straight after `stopSpeaking(at: .immediate)`, which
1719
+ // lost the "2" of a "3, 2, 1" countdown. The stopped one is kept in
1720
+ // `retiredSpeechSynthesizers` until it reports the cancel.
1689
1721
  private lazy var speechSynthesizer: AVSpeechSynthesizer = {
1690
1722
  let synthesizer = AVSpeechSynthesizer()
1691
1723
  synthesizer.delegate = self
1692
1724
  return synthesizer
1693
1725
  }()
1726
+ private var retiredSpeechSynthesizers: [AVSpeechSynthesizer] = []
1727
+ /// The session is handed back this long after the last word, so a
1728
+ /// countdown ducks the music once rather than between every number.
1729
+ private static let speechReleaseAfterSilence: TimeInterval = 1.5
1730
+ private var pendingSpeechRelease: DispatchWorkItem?
1694
1731
  private var pendingUtterances: [(utterance: AVSpeechUtterance, callbackId: String?)] = []
1695
1732
  /// What the session was before speech claimed it; nil while speech
1696
1733
  /// does not hold it.
@@ -1752,6 +1789,17 @@ struct CraftWebView: UIViewRepresentable {
1752
1789
  if config.enableMotionSensors {
1753
1790
  motionManager = CMMotionManager()
1754
1791
  }
1792
+ if config.enableHealthKit {
1793
+ // Apple Health's heart rate as it arrives
1794
+ // (craft.health.startHeartRateUpdates) reaches the page, and
1795
+ // through sendToWeb every native screen too. False once this
1796
+ // coordinator is gone: native screens still hear it.
1797
+ CraftHealth.shared.pageSink = { [weak self] detail in
1798
+ guard let self else { return false }
1799
+ self.sendToWeb("craftHealthHeartRate", data: detail)
1800
+ return true
1801
+ }
1802
+ }
1755
1803
  if config.enableHeartRate {
1756
1804
  // The strap's readings and connection changes reach the page,
1757
1805
  // and through sendToWeb every native screen too. False once
@@ -2610,6 +2658,20 @@ struct CraftWebView: UIViewRepresentable {
2610
2658
  } else {
2611
2659
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2612
2660
  }
2661
+ // Heart rate as Apple Health receives it: craftHealthHeartRate
2662
+ // events until stopped (CraftHealth.swift).
2663
+ case "startHealthHeartRateUpdates":
2664
+ if config.enableHealthKit {
2665
+ CraftHealth.shared.startHeartRateUpdates(since: body["since"] as? Double) { [weak self] in self?.answer(callbackId, $0) }
2666
+ } else {
2667
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2668
+ }
2669
+ case "stopHealthHeartRateUpdates":
2670
+ if config.enableHealthKit {
2671
+ CraftHealth.shared.stopHeartRateUpdates { [weak self] in self?.answer(callbackId, $0) }
2672
+ } else {
2673
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2674
+ }
2613
2675
 
2614
2676
  // MARK: - Live Activities
2615
2677
  case "startLiveActivity":
@@ -5132,7 +5194,14 @@ struct CraftWebView: UIViewRepresentable {
5132
5194
  getMindfulSessions: function(options) {
5133
5195
  options = options || {};
5134
5196
  return craft._invoke('getHealthMindfulSessions', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
5135
- }
5197
+ },
5198
+ // Heart rate as Apple Health receives it, as
5199
+ // craftHealthHeartRate events ({samples}) until stopped.
5200
+ startHeartRateUpdates: function(options) {
5201
+ var since = options && typeof options.since === 'number' ? options.since : null;
5202
+ return craft._invoke('startHealthHeartRateUpdates', {since: since});
5203
+ },
5204
+ stopHeartRateUpdates: function() { return craft._invoke('stopHealthHeartRateUpdates'); }
5136
5205
  };
5137
5206
  // The Music app's song and transport. Nothing is read until
5138
5207
  // requestAuthorization() has been allowed; observe(true) makes
@@ -6654,8 +6723,13 @@ struct CraftWebView: UIViewRepresentable {
6654
6723
 
6655
6724
  private func speak(_ text: String, body: [String: Any], callbackId: String?) {
6656
6725
  DispatchQueue.main.async {
6657
- if body["interrupt"] as? Bool ?? true {
6658
- self.cancelPendingSpeech()
6726
+ self.pendingSpeechRelease?.cancel()
6727
+ self.pendingSpeechRelease = nil
6728
+ if body["interrupt"] as? Bool ?? true, self.cancelPendingSpeech() {
6729
+ self.retiredSpeechSynthesizers.append(self.speechSynthesizer)
6730
+ let fresh = AVSpeechSynthesizer()
6731
+ fresh.delegate = self
6732
+ self.speechSynthesizer = fresh
6659
6733
  }
6660
6734
 
6661
6735
  let utterance = AVSpeechUtterance(string: text)
@@ -6757,8 +6831,20 @@ struct CraftWebView: UIViewRepresentable {
6757
6831
  /// Checked on the main thread, where the queue of utterances lives, and
6758
6832
  /// done on the speech queue after any setup already asked for, so a
6759
6833
  /// cue that arrives meanwhile reclaims the session after this.
6760
- private func releaseAudioSessionIfSilent(retry: Bool = true) {
6834
+ private func releaseAudioSessionIfSilent(retry: Bool = true, after delay: TimeInterval = Coordinator.speechReleaseAfterSilence) {
6761
6835
  guard pendingUtterances.isEmpty else { return }
6836
+ pendingSpeechRelease?.cancel()
6837
+ let work = DispatchWorkItem { [weak self] in
6838
+ guard let self else { return }
6839
+ self.pendingSpeechRelease = nil
6840
+ guard self.pendingUtterances.isEmpty, !self.speechSynthesizer.isSpeaking else { return }
6841
+ self.deactivateSpeechAudioSession(retry: retry)
6842
+ }
6843
+ pendingSpeechRelease = work
6844
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
6845
+ }
6846
+
6847
+ private func deactivateSpeechAudioSession(retry: Bool) {
6762
6848
  speechAudioQueue.async {
6763
6849
  guard let previous = self.speechAudioSessionToRestore else { return }
6764
6850
  let session = AVAudioSession.sharedInstance()
@@ -6771,8 +6857,8 @@ struct CraftWebView: UIViewRepresentable {
6771
6857
  // right, and the ducking still ends when the music app
6772
6858
  // hears nothing more.
6773
6859
  if retry {
6774
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
6775
- self.releaseAudioSessionIfSilent(retry: false)
6860
+ DispatchQueue.main.async {
6861
+ self.releaseAudioSessionIfSilent(retry: false, after: 0.5)
6776
6862
  }
6777
6863
  return
6778
6864
  }
@@ -6783,11 +6869,14 @@ struct CraftWebView: UIViewRepresentable {
6783
6869
  }
6784
6870
  }
6785
6871
 
6786
- fileprivate func speechDidEnd(_ utterance: AVSpeechUtterance, spoken: Bool) {
6872
+ fileprivate func speechDidEnd(_ synthesizer: AVSpeechSynthesizer, _ utterance: AVSpeechUtterance, spoken: Bool) {
6787
6873
  if let index = pendingUtterances.firstIndex(where: { $0.utterance === utterance }) {
6788
6874
  let entry = pendingUtterances.remove(at: index)
6789
6875
  resolveCallback(entry.callbackId, result: spoken)
6790
6876
  }
6877
+ if synthesizer !== speechSynthesizer, !synthesizer.isSpeaking {
6878
+ retiredSpeechSynthesizers.removeAll { $0 === synthesizer }
6879
+ }
6791
6880
  releaseAudioSessionIfSilent()
6792
6881
  }
6793
6882
 
@@ -8419,11 +8508,11 @@ extension CraftWebView.Coordinator: WKUIDelegate {
8419
8508
 
8420
8509
  extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
8421
8510
  func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
8422
- DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
8511
+ DispatchQueue.main.async { self.speechDidEnd(synthesizer, utterance, spoken: true) }
8423
8512
  }
8424
8513
 
8425
8514
  func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
8426
- DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: false) }
8515
+ DispatchQueue.main.async { self.speechDidEnd(synthesizer, utterance, spoken: false) }
8427
8516
  }
8428
8517
  }
8429
8518
 
@@ -8692,7 +8781,7 @@ final class CraftZigRuntime: NSObject {
8692
8781
  enum CraftWebServices {
8693
8782
  private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
8694
8783
  /// Coordinator events a native screen hears too.
8695
- static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftMusicChange", "craftHeartRate"]
8784
+ static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftMusicChange", "craftHeartRate"]
8696
8785
  static let event = Notification.Name("craftWebServicesEvent")
8697
8786
 
8698
8787
  static var isAvailable: Bool { CraftSwiftShim.coordinator != nil }
@@ -13,18 +13,29 @@ struct CraftHealthError: Error {
13
13
  /// the hybrid's web view, CraftApp.swift) and native screens alike
14
14
  /// (`Health` through CraftNativeActions), so a screen drawn natively reads
15
15
  /// and writes Apple Health exactly as a page does. One HKHealthStore for the
16
- /// process, as Apple asks. Every answer arrives through `completion`, on
17
- /// whatever queue HealthKit answered on; each caller hops to its own.
16
+ /// process, as Apple asks. Every answer is delivered on the main queue so the
17
+ /// native-screen JavaScript bridge can safely resume its pending call.
18
18
  final class CraftHealth {
19
19
  typealias Completion = (Result<Any, CraftHealthError>) -> Void
20
20
 
21
21
  static let shared = CraftHealth()
22
22
 
23
+ private func completeOnMain(_ completion: @escaping Completion) -> Completion {
24
+ { result in
25
+ if Thread.isMainThread {
26
+ completion(result)
27
+ } else {
28
+ DispatchQueue.main.async { completion(result) }
29
+ }
30
+ }
31
+ }
32
+
23
33
  /// Nil where Apple Health is not on this device (an iPad without it).
24
34
  let healthStore: HKHealthStore? = HKHealthStore.isHealthDataAvailable() ? HKHealthStore() : nil
25
35
 
26
36
  /// A native screen's call (`Health` in CraftNativeActions), by method and arguments as the bridge sends them.
27
- func perform(method: String, args: [Any], completion: @escaping Completion) {
37
+ func perform(method: String, args: [Any], completion rawCompletion: @escaping Completion) {
38
+ let completion = completeOnMain(rawCompletion)
28
39
  let options = { (index: Int) in args.count > index ? args[index] as? [String: Any] ?? [:] : [:] }
29
40
  switch method {
30
41
  case "requestAuthorization":
@@ -54,12 +65,17 @@ final class CraftHealth {
54
65
  saveMindfulSession(body: options(0), completion: completion)
55
66
  case "getMindfulSessions":
56
67
  getMindfulSessions(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, limit: options(0)["limit"] as? Int, completion: completion)
68
+ case "startHeartRateUpdates":
69
+ startHeartRateUpdates(since: options(0)["since"] as? Double, completion: completion)
70
+ case "stopHeartRateUpdates":
71
+ stopHeartRateUpdates(completion: completion)
57
72
  default:
58
73
  completion(.failure(CraftHealthError(message: "Unsupported Health method", code: "UNKNOWN_ACTION")))
59
74
  }
60
75
  }
61
76
 
62
- func requestAuthorization(types: [String], readOnly: Bool = false, completion: @escaping Completion) {
77
+ func requestAuthorization(types: [String], readOnly: Bool = false, completion rawCompletion: @escaping Completion) {
78
+ let completion = completeOnMain(rawCompletion)
63
79
  guard let healthStore = healthStore else {
64
80
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
65
81
  return
@@ -151,7 +167,8 @@ final class CraftHealth {
151
167
  }
152
168
  }
153
169
 
154
- func getData(type: String, startDate: Double?, endDate: Double?, completion: @escaping Completion) {
170
+ func getData(type: String, startDate: Double?, endDate: Double?, completion rawCompletion: @escaping Completion) {
171
+ let completion = completeOnMain(rawCompletion)
155
172
  guard let healthStore = healthStore else {
156
173
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
157
174
  return
@@ -191,7 +208,8 @@ final class CraftHealth {
191
208
 
192
209
  /// Save one quantity sample, primarily a food-log entry. Dates are epoch
193
210
  /// milliseconds; an omitted range records an instantaneous sample now.
194
- func saveData(type: String, value: Double, options: [String: Any], completion: @escaping Completion) {
211
+ func saveData(type: String, value: Double, options: [String: Any], completion rawCompletion: @escaping Completion) {
212
+ let completion = completeOnMain(rawCompletion)
195
213
  guard let healthStore = healthStore else {
196
214
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
197
215
  return
@@ -225,7 +243,8 @@ final class CraftHealth {
225
243
  }
226
244
  }
227
245
 
228
- func saveWorkout(body: [String: Any], completion: @escaping Completion) {
246
+ func saveWorkout(body: [String: Any], completion rawCompletion: @escaping Completion) {
247
+ let completion = completeOnMain(rawCompletion)
229
248
  guard let healthStore = healthStore else {
230
249
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
231
250
  return
@@ -378,7 +397,8 @@ final class CraftHealth {
378
397
 
379
398
  /// Workouts in Apple Health — the watch's, and every app's that
380
399
  /// writes there — newest first, with the numbers a training log needs.
381
- func getWorkouts(startDate: Double?, endDate: Double?, limit: Int?, completion: @escaping Completion) {
400
+ func getWorkouts(startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
401
+ let completion = completeOnMain(rawCompletion)
382
402
  guard let healthStore = healthStore else {
383
403
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
384
404
  return
@@ -436,7 +456,8 @@ final class CraftHealth {
436
456
  /// One value per local day: a sum for steps, energy and distance, an
437
457
  /// average for heart rate, resting heart rate and HRV, the latest
438
458
  /// weight, and hours asleep for sleep (counted on the day you woke).
439
- func getDailyStatistics(type: String, startDate: Double?, endDate: Double?, completion: @escaping Completion) {
459
+ func getDailyStatistics(type: String, startDate: Double?, endDate: Double?, completion rawCompletion: @escaping Completion) {
460
+ let completion = completeOnMain(rawCompletion)
440
461
  guard let healthStore = healthStore else {
441
462
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
442
463
  return
@@ -510,7 +531,8 @@ final class CraftHealth {
510
531
  /// beats per minute, when it was taken, and the app and device that
511
532
  /// wrote it. A watch's workout readings can arrive as one series
512
533
  /// sample; those also carry their count, lowest and highest.
513
- func getHeartRateSamples(startDate: Double?, endDate: Double?, limit: Int?, completion: @escaping Completion) {
534
+ func getHeartRateSamples(startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
535
+ let completion = completeOnMain(rawCompletion)
514
536
  guard let healthStore = healthStore else {
515
537
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
516
538
  return
@@ -526,39 +548,42 @@ final class CraftHealth {
526
548
  let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
527
549
  let cap = max(1, min(limit ?? 10_000, 50_000))
528
550
 
529
- let query = HKSampleQuery(sampleType: heartType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { [weak self] _, samples, error in
551
+ let query = HKSampleQuery(sampleType: heartType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { _, samples, error in
530
552
  if let error = error, (error as? HKError)?.code != .errorNoData {
531
553
  completion(.failure(CraftHealthError(message: error.localizedDescription)))
532
554
  return
533
555
  }
556
+ completion(.success((samples as? [HKQuantitySample] ?? []).map(Self.heartRateRow)))
557
+ }
558
+ healthStore.execute(query)
559
+ }
534
560
 
535
- let bpm = HKUnit.count().unitDivided(by: .minute())
536
- let rows: [[String: Any]] = (samples as? [HKQuantitySample] ?? []).map { sample in
537
- var item: [String: Any] = [
538
- "bpm": sample.quantity.doubleValue(for: bpm),
539
- "startDate": sample.startDate.timeIntervalSince1970 * 1000,
540
- "endDate": sample.endDate.timeIntervalSince1970 * 1000,
541
- "sourceName": sample.sourceRevision.source.name,
542
- ]
543
- if let device = sample.device?.name { item["deviceName"] = device }
544
- if let model = sample.device?.model { item["deviceModel"] = model }
545
- if let series = sample as? HKDiscreteQuantitySample, series.count > 1 {
546
- item["count"] = series.count
547
- item["min"] = series.minimumQuantity.doubleValue(for: bpm)
548
- item["max"] = series.maximumQuantity.doubleValue(for: bpm)
549
- }
550
- if let raw = sample.metadata?[HKMetadataKeyHeartRateMotionContext] as? NSNumber {
551
- switch HKHeartRateMotionContext(rawValue: raw.intValue) {
552
- case .sedentary: item["motionContext"] = "sedentary"
553
- case .active: item["motionContext"] = "active"
554
- default: break
555
- }
556
- }
557
- return item
561
+ /// One heart-rate sample as a page sees it, from a read or the stream
562
+ /// alike: bpm, when, the app and device that wrote it, a series' count,
563
+ /// lowest and highest, and what the wearer was doing when the watch says.
564
+ private static func heartRateRow(_ sample: HKQuantitySample) -> [String: Any] {
565
+ let bpm = HKUnit.count().unitDivided(by: .minute())
566
+ var item: [String: Any] = [
567
+ "bpm": sample.quantity.doubleValue(for: bpm),
568
+ "startDate": sample.startDate.timeIntervalSince1970 * 1000,
569
+ "endDate": sample.endDate.timeIntervalSince1970 * 1000,
570
+ "sourceName": sample.sourceRevision.source.name,
571
+ ]
572
+ if let device = sample.device?.name { item["deviceName"] = device }
573
+ if let model = sample.device?.model { item["deviceModel"] = model }
574
+ if let series = sample as? HKDiscreteQuantitySample, series.count > 1 {
575
+ item["count"] = series.count
576
+ item["min"] = series.minimumQuantity.doubleValue(for: bpm)
577
+ item["max"] = series.maximumQuantity.doubleValue(for: bpm)
578
+ }
579
+ if let raw = sample.metadata?[HKMetadataKeyHeartRateMotionContext] as? NSNumber {
580
+ switch HKHeartRateMotionContext(rawValue: raw.intValue) {
581
+ case .sedentary: item["motionContext"] = "sedentary"
582
+ case .active: item["motionContext"] = "active"
583
+ default: break
558
584
  }
559
- completion(.success(rows))
560
585
  }
561
- healthStore.execute(query)
586
+ return item
562
587
  }
563
588
 
564
589
  /// A stable name for each sleep analysis value: in bed, awake, or one
@@ -577,7 +602,8 @@ final class CraftHealth {
577
602
  /// Sleep between two instants, oldest first, as the sources wrote it:
578
603
  /// time in bed and every asleep or awake stretch, each with its stage
579
604
  /// and whether it counts as asleep, so a page can mark the night.
580
- func getSleepIntervals(startDate: Double?, endDate: Double?, completion: @escaping Completion) {
605
+ func getSleepIntervals(startDate: Double?, endDate: Double?, completion rawCompletion: @escaping Completion) {
606
+ let completion = completeOnMain(rawCompletion)
581
607
  guard let healthStore = healthStore else {
582
608
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
583
609
  return
@@ -623,7 +649,8 @@ final class CraftHealth {
623
649
  /// as the sample's external UUID, so saving the same session twice (a
624
650
  /// retry, a second device) keeps the first sample instead of adding
625
651
  /// another: the answer then says `existing: true`.
626
- func saveMindfulSession(body: [String: Any], completion: @escaping Completion) {
652
+ func saveMindfulSession(body: [String: Any], completion rawCompletion: @escaping Completion) {
653
+ let completion = completeOnMain(rawCompletion)
627
654
  guard let healthStore = healthStore else {
628
655
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
629
656
  return
@@ -675,7 +702,8 @@ final class CraftHealth {
675
702
  /// that writes them (the Mindfulness app, Calm, this one). Each says
676
703
  /// whether this app wrote it (`ownApp`) and carries the external id it
677
704
  /// was saved with, so a page can tell its own sessions from the rest.
678
- func getMindfulSessions(startDate: Double?, endDate: Double?, limit: Int?, completion: @escaping Completion) {
705
+ func getMindfulSessions(startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
706
+ let completion = completeOnMain(rawCompletion)
679
707
  guard let healthStore = healthStore else {
680
708
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
681
709
  return
@@ -717,4 +745,151 @@ final class CraftHealth {
717
745
  healthStore.execute(query)
718
746
  }
719
747
 
748
+ // MARK: Heart rate as it arrives
749
+
750
+ /// The web view's coordinator, which sends `craftHealthHeartRate` to the
751
+ /// page and every native screen. False (or unset, in an app without a web
752
+ /// view) and the event goes to the native screens alone.
753
+ var pageSink: (([String: Any]) -> Bool)?
754
+
755
+ /// The one running stream: an anchored query that hears every heart-rate
756
+ /// sample HealthKit receives after it starts.
757
+ private var heartRateQuery: HKAnchoredObjectQuery?
758
+ /// Samples already sent, so a batch HealthKit repeats is not sent twice.
759
+ private var heartRateSent: Set<UUID> = []
760
+ /// Bumped by every start and stop, so a stopped stream's late batch is dropped.
761
+ private var heartRateStream = 0
762
+ #if targetEnvironment(simulator)
763
+ private var simulatedHeartRateTimer: Timer?
764
+ #endif
765
+
766
+ private func onMain(_ work: @escaping () -> Void) {
767
+ if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
768
+ }
769
+
770
+ /// Heart rate as Apple Health receives it, for as long as it runs: every
771
+ /// sample that starts at or after `since` (epoch ms; two minutes ago by
772
+ /// default), first what is there already, then each batch as the watch
773
+ /// syncs it, as `craftHealthHeartRate` events (`{samples}`, oldest first,
774
+ /// each sample once, in getHeartRateSamples' shape). One stream at a
775
+ /// time: starting again replaces the running one. Reads under the
776
+ /// `heartRate` grant. Answers true once the samples already there are
777
+ /// sent.
778
+ ///
779
+ /// Simulator builds with the `CraftFakeHealthHeartRate` default
780
+ /// (`xcrun simctl spawn booted defaults write <bundle id>
781
+ /// CraftFakeHealthHeartRate -bool YES`) skip HealthKit and send a made-up
782
+ /// watch reading every five seconds instead.
783
+ func startHeartRateUpdates(since: Double?, completion rawCompletion: @escaping Completion) {
784
+ let completion = completeOnMain(rawCompletion)
785
+ let start = Date(timeIntervalSince1970: (since ?? Date().timeIntervalSince1970 * 1000 - 120_000) / 1000)
786
+ onMain { [self] in
787
+ stopHeartRateStream()
788
+ let stream = heartRateStream
789
+
790
+ #if targetEnvironment(simulator)
791
+ if UserDefaults.standard.bool(forKey: "CraftFakeHealthHeartRate") {
792
+ startSimulatedHeartRate(stream: stream)
793
+ completion(.success(true))
794
+ return
795
+ }
796
+ #endif
797
+
798
+ guard let healthStore = healthStore else {
799
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
800
+ return
801
+ }
802
+ guard let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
803
+ completion(.failure(CraftHealthError(message: "Heart rate is unavailable")))
804
+ return
805
+ }
806
+
807
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: nil, options: .strictStartDate)
808
+ let query = HKAnchoredObjectQuery(type: heartType, predicate: predicate, anchor: nil, limit: HKObjectQueryNoLimit) { [weak self] _, samples, _, _, error in
809
+ let rows = (samples as? [HKQuantitySample] ?? []).map { ($0.uuid, Self.heartRateRow($0)) }
810
+ DispatchQueue.main.async {
811
+ // Stopped or replaced before its first batch: it did start.
812
+ guard let self, self.heartRateStream == stream else { return completion(.success(true)) }
813
+ if let error = error, (error as? HKError)?.code != .errorNoData {
814
+ self.stopHeartRateStream()
815
+ return completion(.failure(CraftHealthError(message: error.localizedDescription)))
816
+ }
817
+ self.sendHeartRate(rows)
818
+ completion(.success(true))
819
+ }
820
+ }
821
+ query.updateHandler = { [weak self] _, samples, _, _, error in
822
+ if let error = error {
823
+ NSLog("[craft] Apple Health heart rate: %@", error.localizedDescription)
824
+ return
825
+ }
826
+ let rows = (samples as? [HKQuantitySample] ?? []).map { ($0.uuid, Self.heartRateRow($0)) }
827
+ DispatchQueue.main.async {
828
+ guard let self, self.heartRateStream == stream else { return }
829
+ self.sendHeartRate(rows)
830
+ }
831
+ }
832
+ heartRateQuery = query
833
+ healthStore.execute(query)
834
+ }
835
+ }
836
+
837
+ /// Stops the stream; true whether or not one was running.
838
+ func stopHeartRateUpdates(completion rawCompletion: @escaping Completion) {
839
+ let completion = completeOnMain(rawCompletion)
840
+ onMain { [self] in
841
+ stopHeartRateStream()
842
+ completion(.success(true))
843
+ }
844
+ }
845
+
846
+ /// Main only.
847
+ private func stopHeartRateStream() {
848
+ heartRateStream += 1
849
+ if let query = heartRateQuery { healthStore?.stop(query) }
850
+ heartRateQuery = nil
851
+ heartRateSent.removeAll()
852
+ #if targetEnvironment(simulator)
853
+ simulatedHeartRateTimer?.invalidate()
854
+ simulatedHeartRateTimer = nil
855
+ #endif
856
+ }
857
+
858
+ /// Sends the samples not sent before, oldest first; nothing when none are new. Main only.
859
+ private func sendHeartRate(_ rows: [(UUID, [String: Any])]) {
860
+ let fresh = rows
861
+ .filter { heartRateSent.insert($0.0).inserted }
862
+ .map { $0.1 }
863
+ .sorted { ($0["startDate"] as? Double ?? 0) < ($1["startDate"] as? Double ?? 0) }
864
+ guard !fresh.isEmpty else { return }
865
+ let detail: [String: Any] = ["samples": fresh]
866
+ if pageSink?(detail) != true { CraftWebServices.forward("craftHealthHeartRate", data: detail) }
867
+ }
868
+
869
+ /// The simulator's watch: someone settling into slow breathing, about 72
870
+ /// at first and easing toward 60 over a few minutes, with a small wobble.
871
+ static func simulatedHeartRate(elapsed: TimeInterval) -> Double {
872
+ (60 + 12 * exp(-elapsed / 150) + 1.5 * sin(elapsed / 9)).rounded()
873
+ }
874
+
875
+ #if targetEnvironment(simulator)
876
+ /// One made-up watch reading now and every five seconds after. Main only.
877
+ private func startSimulatedHeartRate(stream: Int) {
878
+ let began = Date()
879
+ let send: () -> Void = { [weak self] in
880
+ guard let self, self.heartRateStream == stream else { return }
881
+ let now = Date().timeIntervalSince1970 * 1000
882
+ self.sendHeartRate([(UUID(), [
883
+ "bpm": Self.simulatedHeartRate(elapsed: Date().timeIntervalSince(began)),
884
+ "startDate": now,
885
+ "endDate": now,
886
+ "sourceName": "Apple Watch (simulated)",
887
+ "deviceName": "Apple Watch",
888
+ "motionContext": "sedentary",
889
+ ])])
890
+ }
891
+ send()
892
+ simulatedHeartRateTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in send() }
893
+ }
894
+ #endif
720
895
  }
@@ -115,7 +115,8 @@ struct CraftHeartRateMeasurement: Equatable {
115
115
  /// Simulator builds have no Bluetooth; with the `CraftFakeHeartRateMonitor`
116
116
  /// default set (`xcrun simctl spawn booted defaults write <bundle id>
117
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.
118
+ /// readings are encoded as 0x2A37 payloads and decoded like a real one's. Its
119
+ /// RR intervals vary beat to beat with the breath, as a heart's do.
119
120
  ///
120
121
  /// Everything runs on main: the central manager delivers there, and every
121
122
  /// caller hops there first.
@@ -176,6 +177,9 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
176
177
  private static let simulatedId = UUID(uuidString: "00000000-0000-4000-8000-00000000180D")!
177
178
  private var simulatedTimer: Timer?
178
179
  private var simulatedBeat = 0
180
+ /// Milliseconds since the stand-in connected, and how far its beats reach.
181
+ private var simulatedElapsed: Double = 0
182
+ private var simulatedBeatsUntil: Double = 0
179
183
 
180
184
  // MARK: Entry
181
185
 
@@ -566,16 +570,42 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
566
570
  emitState()
567
571
  settleConnect(.success(state()))
568
572
  simulatedTimer?.invalidate()
573
+ simulatedElapsed = 0
574
+ simulatedBeatsUntil = 0
569
575
  simulatedTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
570
576
  guard let self else { return }
571
577
  self.simulatedBeat += 1
578
+ self.simulatedElapsed += 1000
572
579
  // 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]))
580
+ let rate = 142 + sin(Double(self.simulatedBeat) / 20) * 12
581
+ var beatsUntil = self.simulatedBeatsUntil
582
+ let intervals = Self.simulatedIntervals(rate: rate, from: &beatsUntil, until: self.simulatedElapsed)
583
+ self.simulatedBeatsUntil = beatsUntil
584
+ guard !intervals.isEmpty else { return }
585
+ // The rate a strap reports is the one its intervals give.
586
+ let bpm = Int((60_000 / (intervals.reduce(0, +) / Double(intervals.count))).rounded())
587
+ self.received(CraftHeartRateMeasurement.encode(bpm: bpm, sensorContact: true, rrIntervals: intervals))
576
588
  }
577
589
  }
578
590
 
591
+ /// The beats from `start` (ms) until `end`, each interval in ms: the
592
+ /// rate's own interval, plus respiratory sinus arrhythmia (beats come
593
+ /// closer on the in-breath and spread on the out-breath over a ten-second
594
+ /// breath, ±4% of the interval: ±40 ms at 60 bpm) and a few ms of noise,
595
+ /// so a heart-rate variability worked out from them means something.
596
+ /// `start` moves to the last beat.
597
+ static func simulatedIntervals(rate: Double, from start: inout Double, until end: Double, noise: () -> Double = { Double.random(in: -4...4) }) -> [Double] {
598
+ let base = 60_000 / rate
599
+ var intervals: [Double] = []
600
+ while start < end {
601
+ let breath = sin(2 * Double.pi * start / 10_000)
602
+ let interval = base * (1 - 0.04 * breath) + noise()
603
+ start += interval
604
+ intervals.append(interval)
605
+ }
606
+ return intervals
607
+ }
608
+
579
609
  // MARK: Recording
580
610
 
581
611
  private var directory: URL {