@craft-native/ios 0.0.247 → 0.0.249

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
@@ -330,6 +330,7 @@ Edit `craft.config.json` in your iOS project:
330
330
  "enableBluetooth": true,
331
331
  "enableNFC": true,
332
332
  "enableHealthKit": false,
333
+ "enableWallet": true,
333
334
  "enableAppleMusic": false,
334
335
  "enableHeartRate": false,
335
336
  "enableBackgroundAudio": false,
package/dist/cli.js CHANGED
@@ -360,6 +360,7 @@ var DEFAULT_CONFIG = {
360
360
  enableBluetooth: false,
361
361
  enableNFC: false,
362
362
  enableHealthKit: false,
363
+ enableWallet: false,
363
364
  enableLiveActivities: false,
364
365
  enableWatchApp: false,
365
366
  enableBackgroundLocation: false,
package/dist/index.d.ts CHANGED
@@ -65,6 +65,8 @@ export interface CraftConfig {
65
65
  enableBluetooth?: boolean;
66
66
  enableNFC?: boolean;
67
67
  enableHealthKit?: boolean;
68
+ /** Native Apple Wallet pass presentation and lookup for native screens. */
69
+ enableWallet?: boolean;
68
70
  enableLiveActivities?: boolean;
69
71
  enableWatchApp?: boolean;
70
72
  enableBackgroundLocation?: boolean;
package/dist/index.js CHANGED
@@ -134,8 +134,8 @@ function initSimulatorDriver(options) {
134
134
  if (!["http:", "https:"].includes(new URL(control).protocol))
135
135
  throw new Error("controlUrl must use HTTP or HTTPS");
136
136
  const tabIdentifiers = Object.entries(options.tabIdentifiers ?? {});
137
- for (const [name2, identifier] of tabIdentifiers) {
138
- if (!name2.trim() || !identifier.trim())
137
+ for (const [name, identifier] of tabIdentifiers) {
138
+ if (!name.trim() || !identifier.trim())
139
139
  throw new Error("Tab names and native identifiers must be nonempty");
140
140
  }
141
141
  const replacements = {
@@ -145,7 +145,7 @@ function initSimulatorDriver(options) {
145
145
  BUNDLE_ID: swiftString(bundleId),
146
146
  CONTROL_URL: swiftString(control),
147
147
  TABS: `[${(options.tabs ?? []).map(swiftString).join(", ")}]`,
148
- TAB_IDENTIFIERS: tabIdentifiers.length ? `[${tabIdentifiers.map(([name2, identifier]) => `${swiftString(name2)}: ${swiftString(identifier)}`).join(", ")}]` : "[:]"
148
+ TAB_IDENTIFIERS: tabIdentifiers.length ? `[${tabIdentifiers.map(([name, identifier]) => `${swiftString(name)}: ${swiftString(identifier)}`).join(", ")}]` : "[:]"
149
149
  };
150
150
  for (const [source, destination] of [
151
151
  ["HostApp.swift", "Host/HostApp.swift"],
@@ -547,6 +547,7 @@ var DEFAULT_CONFIG = {
547
547
  enableBluetooth: false,
548
548
  enableNFC: false,
549
549
  enableHealthKit: false,
550
+ enableWallet: false,
550
551
  enableLiveActivities: false,
551
552
  enableWatchApp: false,
552
553
  enableBackgroundLocation: false,
@@ -1539,55 +1540,55 @@ async function run(options) {
1539
1540
  }
1540
1541
  }
1541
1542
  export {
1542
- usesNativeBundle,
1543
- syncWebAssets,
1544
- syncNativeFonts,
1545
- showSimulator,
1546
- serializeSimulatorSteps,
1547
- run,
1548
- restoreShell,
1549
- resolveRuntimeDir,
1550
- resolveCraftVersion,
1551
- resolveAppearance,
1552
- renderWatchEntitlements,
1553
- renderUsageDescriptions,
1554
- renderUrlTypes,
1555
- renderRuntimeSettings,
1556
- renderPrivacyManifest,
1557
- renderOrientations,
1558
- renderOfflinePage,
1559
- renderLaunchScreen,
1560
- renderFeatureSettings,
1561
- renderEntitlements,
1562
- renderDeviceFamilies,
1563
- renderBackgroundTaskIdentifiers,
1564
- renderBackgroundModes,
1565
- renderAppearance,
1566
- renderAppBoundDomains,
1567
- readableTextOn,
1568
- productName,
1569
- pngHasAlpha,
1570
- pickSimulator,
1571
- orderSimulators,
1572
- open,
1573
- normalizeNativePath,
1574
- nativeStartPath,
1575
- nativeRouteRuntime,
1576
- nativeRouteBundles,
1577
- nativeMessages,
1578
- matchNativePath,
1579
- isHybrid,
1580
- installRuntime,
1581
- initSimulatorDriver,
1582
- init,
1583
- hybridConfigProblems,
1584
- hasNativeRoutes,
1585
- findApplicationSwiftSources,
1586
- createNativeMutationValidator,
1587
- build,
1588
- bootSimulator,
1589
- backgroundRefreshIdentifier,
1590
- associatedDomainEntries,
1543
+ LAUNCH_LOGO,
1591
1544
  assertNoApplicationSwiftSources,
1592
- LAUNCH_LOGO
1545
+ associatedDomainEntries,
1546
+ backgroundRefreshIdentifier,
1547
+ bootSimulator,
1548
+ build,
1549
+ createNativeMutationValidator,
1550
+ findApplicationSwiftSources,
1551
+ hasNativeRoutes,
1552
+ hybridConfigProblems,
1553
+ init,
1554
+ initSimulatorDriver,
1555
+ installRuntime,
1556
+ isHybrid,
1557
+ matchNativePath,
1558
+ nativeMessages,
1559
+ nativeRouteBundles,
1560
+ nativeRouteRuntime,
1561
+ nativeStartPath,
1562
+ normalizeNativePath,
1563
+ open,
1564
+ orderSimulators,
1565
+ pickSimulator,
1566
+ pngHasAlpha,
1567
+ productName,
1568
+ readableTextOn,
1569
+ renderAppBoundDomains,
1570
+ renderAppearance,
1571
+ renderBackgroundModes,
1572
+ renderBackgroundTaskIdentifiers,
1573
+ renderDeviceFamilies,
1574
+ renderEntitlements,
1575
+ renderFeatureSettings,
1576
+ renderLaunchScreen,
1577
+ renderOfflinePage,
1578
+ renderOrientations,
1579
+ renderPrivacyManifest,
1580
+ renderRuntimeSettings,
1581
+ renderUrlTypes,
1582
+ renderUsageDescriptions,
1583
+ renderWatchEntitlements,
1584
+ resolveAppearance,
1585
+ resolveCraftVersion,
1586
+ resolveRuntimeDir,
1587
+ restoreShell,
1588
+ run,
1589
+ serializeSimulatorSteps,
1590
+ showSimulator,
1591
+ syncNativeFonts,
1592
+ syncWebAssets,
1593
+ usesNativeBundle
1593
1594
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.247",
3
+ "version": "0.0.249",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -648,6 +648,9 @@ final class CraftChrome: ObservableObject {
648
648
  var titleOverride: String? = nil
649
649
  var iconTint: String? = nil
650
650
  var iconPulse = false
651
+ /// The page's own symbol for the tab while it says so (a live
652
+ /// reading's filled heart), in place of the configured one.
653
+ var iconSymbol: String? = nil
651
654
  var selectedSymbol: String? = nil
652
655
  var selectedIconTint: String? = nil
653
656
  var selectedIconPulse: Bool? = nil
@@ -658,7 +661,7 @@ final class CraftChrome: ObservableObject {
658
661
  }
659
662
 
660
663
  func iconAppearance(selected: Bool, motionAllowed: Bool) -> (symbol: String, tint: String?, pulse: Bool) {
661
- let name = selected ? selectedSymbol ?? symbol : symbol
664
+ let name = selected ? selectedSymbol ?? iconSymbol ?? symbol : iconSymbol ?? symbol
662
665
  let tint = selected ? selectedIconTint ?? iconTint : iconTint
663
666
  let pulse = selected ? selectedIconPulse ?? iconPulse : iconPulse
664
667
  return (name, tint, pulse && motionAllowed)
@@ -750,6 +753,9 @@ final class CraftChrome: ObservableObject {
750
753
  if item.keys.contains("iconPulse") {
751
754
  next.iconPulse = item["iconPulse"] as? Bool ?? false
752
755
  }
756
+ if item.keys.contains("symbol") {
757
+ next.iconSymbol = Tab.normalizedSelectedSymbol(item["symbol"] as? String)
758
+ }
753
759
  if item.keys.contains("selectedSymbol") {
754
760
  next.selectedSymbol = Tab.normalizedSelectedSymbol(item["selectedSymbol"] as? String)
755
761
  }
@@ -1574,6 +1580,8 @@ struct CraftConfig: Codable {
1574
1580
  var enableBluetooth: Bool = false
1575
1581
  var enableNFC: Bool = false
1576
1582
  var enableHealthKit: Bool = false
1583
+ /// Apple Wallet pass presentation and lookup for native screens.
1584
+ var enableWallet: Bool = false
1577
1585
  var enableLiveActivities: Bool = false
1578
1586
  var enableWatchApp: Bool = false
1579
1587
  var enableBackgroundLocation: Bool = false
@@ -2168,6 +2176,11 @@ struct CraftWebView: UIViewRepresentable {
2168
2176
  self.sendToWeb("craftHealthHeartRate", data: detail)
2169
2177
  return true
2170
2178
  }
2179
+ CraftHealth.shared.workoutSink = { [weak self] detail in
2180
+ guard let self else { return false }
2181
+ self.sendToWeb("craftHealthWorkout", data: detail)
2182
+ return true
2183
+ }
2171
2184
  }
2172
2185
  if config.enableHeartRate {
2173
2186
  // The strap's readings and connection changes reach the page,
@@ -3061,6 +3074,30 @@ struct CraftWebView: UIViewRepresentable {
3061
3074
  } else {
3062
3075
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3063
3076
  }
3077
+ case "startHealthWorkout":
3078
+ if config.enableHealthKit {
3079
+ CraftHealth.shared.startWorkout(body: body) { [weak self] in self?.answer(callbackId, $0) }
3080
+ } else {
3081
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3082
+ }
3083
+ case "pauseHealthWorkout":
3084
+ if config.enableHealthKit {
3085
+ CraftHealth.shared.pauseWorkout { [weak self] in self?.answer(callbackId, $0) }
3086
+ } else {
3087
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3088
+ }
3089
+ case "resumeHealthWorkout":
3090
+ if config.enableHealthKit {
3091
+ CraftHealth.shared.resumeWorkout { [weak self] in self?.answer(callbackId, $0) }
3092
+ } else {
3093
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3094
+ }
3095
+ case "endHealthWorkout":
3096
+ if config.enableHealthKit {
3097
+ CraftHealth.shared.endWorkout { [weak self] in self?.answer(callbackId, $0) }
3098
+ } else {
3099
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3100
+ }
3064
3101
 
3065
3102
  // MARK: - Live Activities
3066
3103
  case "isLiveActivityAvailable":
@@ -5648,7 +5685,11 @@ struct CraftWebView: UIViewRepresentable {
5648
5685
  var since = options && typeof options.since === 'number' ? options.since : null;
5649
5686
  return craft._invoke('startHealthHeartRateUpdates', {since: since});
5650
5687
  },
5651
- stopHeartRateUpdates: function() { return craft._invoke('stopHealthHeartRateUpdates'); }
5688
+ stopHeartRateUpdates: function() { return craft._invoke('stopHealthHeartRateUpdates'); },
5689
+ startWorkout: function(options) { return craft._invoke('startHealthWorkout', options || {}); },
5690
+ pauseWorkout: function() { return craft._invoke('pauseHealthWorkout'); },
5691
+ resumeWorkout: function() { return craft._invoke('resumeHealthWorkout'); },
5692
+ endWorkout: function() { return craft._invoke('endHealthWorkout'); }
5652
5693
  };
5653
5694
  // The Music app's song and transport. Nothing is read until
5654
5695
  // requestAuthorization() has been allowed; observe(true) makes
@@ -9340,7 +9381,7 @@ final class CraftZigRuntime: NSObject {
9340
9381
  enum CraftWebServices {
9341
9382
  private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
9342
9383
  /// Coordinator events a native screen hears too.
9343
- static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftMusicChange", "craftHeartRate"]
9384
+ static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftHealthWorkout", "craftMusicChange", "craftHeartRate"]
9344
9385
  static let event = Notification.Name("craftWebServicesEvent")
9345
9386
 
9346
9387
  static var isAvailable: Bool { CraftSwiftShim.coordinator != nil }
@@ -9,6 +9,40 @@ struct CraftHealthError: Error {
9
9
  var code: String = "CRAFT_ERROR"
10
10
  }
11
11
 
12
+ /// A separate delegate keeps the iOS 17+ HealthKit session types out of the
13
+ /// iOS 16-compatible CraftHealth declaration. The owning service retains it
14
+ /// for the lifetime of the session.
15
+ @available(iOS 17.0, *)
16
+ private final class CraftHealthWorkoutDelegate: NSObject, HKWorkoutSessionDelegate {
17
+ private let id: () -> String
18
+ private let emit: ([String: Any]) -> Void
19
+
20
+ init(id: @escaping () -> String, emit: @escaping ([String: Any]) -> Void) {
21
+ self.id = id
22
+ self.emit = emit
23
+ }
24
+
25
+ func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
26
+ emit(["id": id(), "event": "state", "state": Self.stateName(toState), "date": date.timeIntervalSince1970 * 1000])
27
+ }
28
+
29
+ func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
30
+ emit(["id": id(), "event": "error", "message": error.localizedDescription])
31
+ }
32
+
33
+ private static func stateName(_ state: HKWorkoutSessionState) -> String {
34
+ switch state {
35
+ case .notStarted: return "notStarted"
36
+ case .prepared: return "prepared"
37
+ case .running: return "running"
38
+ case .paused: return "paused"
39
+ case .stopped: return "stopped"
40
+ case .ended: return "ended"
41
+ @unknown default: return "unknown"
42
+ }
43
+ }
44
+ }
45
+
12
46
  /// Apple Health for the whole app: the web page's bridge (`craft.health` in
13
47
  /// the hybrid's web view, CraftApp.swift) and native screens alike
14
48
  /// (`Health` through CraftNativeActions), so a screen drawn natively reads
@@ -69,6 +103,14 @@ final class CraftHealth {
69
103
  startHeartRateUpdates(since: options(0)["since"] as? Double, completion: completion)
70
104
  case "stopHeartRateUpdates":
71
105
  stopHeartRateUpdates(completion: completion)
106
+ case "startWorkout":
107
+ startWorkout(body: options(0), completion: completion)
108
+ case "pauseWorkout":
109
+ pauseWorkout(completion: completion)
110
+ case "resumeWorkout":
111
+ resumeWorkout(completion: completion)
112
+ case "endWorkout":
113
+ endWorkout(completion: completion)
72
114
  default:
73
115
  completion(.failure(CraftHealthError(message: "Unsupported Health method", code: "UNKNOWN_ACTION")))
74
116
  }
@@ -745,6 +787,166 @@ final class CraftHealth {
745
787
  healthStore.execute(query)
746
788
  }
747
789
 
790
+ // MARK: Live workout sessions
791
+
792
+ /// Starts an OS-owned workout session. HealthKit's live builder was
793
+ /// introduced for iOS 26; older systems receive a clear unsupported
794
+ /// result instead of silently recording a post-hoc workout.
795
+ func startWorkout(body: [String: Any], completion rawCompletion: @escaping Completion) {
796
+ let completion = completeOnMain(rawCompletion)
797
+ guard let type = body["type"] as? String, !type.isEmpty else {
798
+ completion(.failure(CraftHealthError(message: "startWorkout needs a workout type", code: "INVALID_ARGUMENT")))
799
+ return
800
+ }
801
+ guard healthStore != nil else {
802
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
803
+ return
804
+ }
805
+ guard liveWorkoutID == nil else {
806
+ completion(.failure(CraftHealthError(message: "A live workout is already running", code: "WORKOUT_IN_PROGRESS")))
807
+ return
808
+ }
809
+ guard #available(iOS 26.0, *) else {
810
+ completion(.failure(CraftHealthError(message: "Live workouts require iOS 26 or later", code: "UNSUPPORTED")))
811
+ return
812
+ }
813
+ startWorkout26(type: type, location: body["location"] as? String, completion: completion)
814
+ }
815
+
816
+ @available(iOS 26.0, *)
817
+ private func startWorkout26(type: String, location: String?, completion: @escaping Completion) {
818
+ guard let healthStore else {
819
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
820
+ return
821
+ }
822
+ guard let activity = Self.workoutActivity(type) else {
823
+ completion(.failure(CraftHealthError(message: "Unsupported workout type", code: "INVALID_ARGUMENT")))
824
+ return
825
+ }
826
+ let configuration = HKWorkoutConfiguration()
827
+ configuration.activityType = activity
828
+ switch location {
829
+ case "indoor": configuration.locationType = .indoor
830
+ case "outdoor": configuration.locationType = .outdoor
831
+ case nil, "unknown": configuration.locationType = .unknown
832
+ default:
833
+ completion(.failure(CraftHealthError(message: "location must be indoor, outdoor, or unknown", code: "INVALID_ARGUMENT")))
834
+ return
835
+ }
836
+ do {
837
+ let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
838
+ let builder = session.associatedWorkoutBuilder()
839
+ let delegate = CraftHealthWorkoutDelegate(id: { [weak self] in self?.liveWorkoutID ?? "" }) { [weak self] detail in self?.sendWorkout(detail) }
840
+ session.delegate = delegate
841
+ builder.shouldCollectWorkoutEvents = true
842
+ builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
843
+ liveWorkoutSession = session
844
+ liveWorkoutBuilder = builder
845
+ liveWorkoutDelegate = delegate
846
+ liveWorkoutID = UUID().uuidString.lowercased()
847
+ liveWorkoutType = type
848
+ liveWorkoutStart = Date()
849
+ let id = liveWorkoutID!
850
+ let start = liveWorkoutStart!
851
+ session.startActivity(with: start)
852
+ builder.beginCollection(withStart: start) { [weak self] success, error in
853
+ DispatchQueue.main.async {
854
+ guard let self else { return }
855
+ guard success else {
856
+ self.clearLiveWorkout()
857
+ completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Live workout could not start")))
858
+ return
859
+ }
860
+ completion(.success(["id": id, "type": type, "startDate": start.timeIntervalSince1970 * 1000]))
861
+ }
862
+ }
863
+ } catch {
864
+ clearLiveWorkout()
865
+ completion(.failure(CraftHealthError(message: error.localizedDescription, code: "INVALID_ARGUMENT")))
866
+ }
867
+ }
868
+
869
+ func pauseWorkout(completion rawCompletion: @escaping Completion) {
870
+ let completion = completeOnMain(rawCompletion)
871
+ guard #available(iOS 26.0, *), let session = liveWorkoutSession as? HKWorkoutSession else {
872
+ completion(.failure(CraftHealthError(message: "No live workout is running", code: "NO_WORKOUT")))
873
+ return
874
+ }
875
+ session.pause()
876
+ completion(.success(true))
877
+ }
878
+
879
+ func resumeWorkout(completion rawCompletion: @escaping Completion) {
880
+ let completion = completeOnMain(rawCompletion)
881
+ guard #available(iOS 26.0, *), let session = liveWorkoutSession as? HKWorkoutSession else {
882
+ completion(.failure(CraftHealthError(message: "No live workout is running", code: "NO_WORKOUT")))
883
+ return
884
+ }
885
+ session.resume()
886
+ completion(.success(true))
887
+ }
888
+
889
+ func endWorkout(completion rawCompletion: @escaping Completion) {
890
+ let completion = completeOnMain(rawCompletion)
891
+ guard #available(iOS 26.0, *), let session = liveWorkoutSession as? HKWorkoutSession, let builder = liveWorkoutBuilder as? HKLiveWorkoutBuilder, let id = liveWorkoutID else {
892
+ completion(.failure(CraftHealthError(message: "No live workout is running", code: "NO_WORKOUT")))
893
+ return
894
+ }
895
+ let end = Date()
896
+ session.end()
897
+ builder.endCollection(withEnd: end) { [weak self] success, error in
898
+ guard let self else { return }
899
+ guard success else {
900
+ DispatchQueue.main.async { completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Live workout could not end"))) }
901
+ return
902
+ }
903
+ builder.finishWorkout { [weak self] workout, finishError in
904
+ DispatchQueue.main.async {
905
+ guard let self else { return }
906
+ defer { self.clearLiveWorkout() }
907
+ guard let workout else {
908
+ completion(.failure(CraftHealthError(message: finishError?.localizedDescription ?? "Live workout could not be saved")))
909
+ return
910
+ }
911
+ completion(.success([
912
+ "id": id,
913
+ "workoutId": workout.uuid.uuidString,
914
+ "endDate": end.timeIntervalSince1970 * 1000,
915
+ "durationSeconds": workout.duration,
916
+ ]))
917
+ }
918
+ }
919
+ }
920
+ }
921
+
922
+ private func clearLiveWorkout() {
923
+ liveWorkoutSession = nil
924
+ liveWorkoutBuilder = nil
925
+ liveWorkoutDelegate = nil
926
+ liveWorkoutID = nil
927
+ liveWorkoutType = nil
928
+ liveWorkoutStart = nil
929
+ }
930
+
931
+ private static func workoutActivity(_ type: String) -> HKWorkoutActivityType? {
932
+ switch type {
933
+ case "running": return .running
934
+ case "walking": return .walking
935
+ case "hiking": return .hiking
936
+ case "cycling": return .cycling
937
+ case "swimming": return .swimming
938
+ case "rowing": return .rowing
939
+ case "elliptical": return .elliptical
940
+ case "strength": return .traditionalStrengthTraining
941
+ case "hiit": return .highIntensityIntervalTraining
942
+ default: return nil
943
+ }
944
+ }
945
+
946
+ private func sendWorkout(_ detail: [String: Any]) {
947
+ if workoutSink?(detail) != true { CraftWebServices.forward("craftHealthWorkout", data: detail) }
948
+ }
949
+
748
950
  // MARK: Heart rate as it arrives
749
951
 
750
952
  /// The web view's coordinator, which sends `craftHealthHeartRate` to the
@@ -752,6 +954,21 @@ final class CraftHealth {
752
954
  /// view) and the event goes to the native screens alone.
753
955
  var pageSink: (([String: Any]) -> Bool)?
754
956
 
957
+ /// Live workout lifecycle events use a separate page event so existing
958
+ /// heart-rate listeners remain unchanged. When no page owns the event,
959
+ /// CraftWebServices forwards it to native screens.
960
+ var workoutSink: (([String: Any]) -> Bool)?
961
+
962
+ // Stored as object references because the generated app still supports
963
+ // iOS 16.2; concrete HealthKit types are only touched behind the iOS 26
964
+ // availability checks below.
965
+ private var liveWorkoutSession: AnyObject?
966
+ private var liveWorkoutBuilder: AnyObject?
967
+ private var liveWorkoutDelegate: AnyObject?
968
+ private var liveWorkoutID: String?
969
+ private var liveWorkoutType: String?
970
+ private var liveWorkoutStart: Date?
971
+
755
972
  /// The one running stream: an anchored query that hears every heart-rate
756
973
  /// sample HealthKit receives after it starts.
757
974
  private var heartRateQuery: HKAnchoredObjectQuery?
@@ -235,7 +235,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
235
235
  }
236
236
  if var archived = try CraftRecordingArchive.read(self.directory, "heart-rate", id) {
237
237
  if method == "readRecording" {
238
- let since = (options["since"] as? NSNumber)?.doubleValue
238
+ let since = craftNativeNumber(options["since"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil }
239
239
  let samples = archived["samples"] as? [[String: Any]] ?? []
240
240
  return completion(.success(samples.filter { since == nil || (($0["timestamp"] as? NSNumber)?.doubleValue ?? 0) > since! }))
241
241
  }
@@ -256,7 +256,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
256
256
  self.recoverQuietBroadcast()
257
257
  completion(.success(self.state()))
258
258
  case "requestAuthorization": self.requestAuthorization(completion: completion)
259
- case "scan": self.scan(seconds: (options["timeout"] as? NSNumber)?.doubleValue, completion: completion)
259
+ case "scan": self.scan(seconds: craftNativeNumber(options["timeout"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil }, completion: completion)
260
260
  case "stopScan":
261
261
  self.stopScan()
262
262
  completion(.success(self.state()))
@@ -270,7 +270,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
270
270
  case "stopRecording": self.stopRecording(completion: completion)
271
271
  case "getRecordingState": completion(.success(self.recordingSummary(count: true)))
272
272
  case "readRecording":
273
- do { completion(.success(try self.persistedSamples(since: (options["since"] as? NSNumber)?.doubleValue))) }
273
+ do {
274
+ let since = craftNativeNumber(options["since"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil }
275
+ completion(.success(try self.persistedSamples(since: since)))
276
+ }
274
277
  catch { completion(.failure(CraftHeartRateError(message: "Unable to read the recording", code: "RECORDING_STORAGE_ERROR"))) }
275
278
  default: completion(.failure(CraftHeartRateError(message: "Unsupported HeartRate method", code: "UNKNOWN_ACTION")))
276
279
  }