@craft-native/ios 0.0.247 → 0.0.248

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.248",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1574,6 +1574,8 @@ struct CraftConfig: Codable {
1574
1574
  var enableBluetooth: Bool = false
1575
1575
  var enableNFC: Bool = false
1576
1576
  var enableHealthKit: Bool = false
1577
+ /// Apple Wallet pass presentation and lookup for native screens.
1578
+ var enableWallet: Bool = false
1577
1579
  var enableLiveActivities: Bool = false
1578
1580
  var enableWatchApp: Bool = false
1579
1581
  var enableBackgroundLocation: Bool = false
@@ -2168,6 +2170,11 @@ struct CraftWebView: UIViewRepresentable {
2168
2170
  self.sendToWeb("craftHealthHeartRate", data: detail)
2169
2171
  return true
2170
2172
  }
2173
+ CraftHealth.shared.workoutSink = { [weak self] detail in
2174
+ guard let self else { return false }
2175
+ self.sendToWeb("craftHealthWorkout", data: detail)
2176
+ return true
2177
+ }
2171
2178
  }
2172
2179
  if config.enableHeartRate {
2173
2180
  // The strap's readings and connection changes reach the page,
@@ -3061,6 +3068,30 @@ struct CraftWebView: UIViewRepresentable {
3061
3068
  } else {
3062
3069
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3063
3070
  }
3071
+ case "startHealthWorkout":
3072
+ if config.enableHealthKit {
3073
+ CraftHealth.shared.startWorkout(body: body) { [weak self] in self?.answer(callbackId, $0) }
3074
+ } else {
3075
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3076
+ }
3077
+ case "pauseHealthWorkout":
3078
+ if config.enableHealthKit {
3079
+ CraftHealth.shared.pauseWorkout { [weak self] in self?.answer(callbackId, $0) }
3080
+ } else {
3081
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3082
+ }
3083
+ case "resumeHealthWorkout":
3084
+ if config.enableHealthKit {
3085
+ CraftHealth.shared.resumeWorkout { [weak self] in self?.answer(callbackId, $0) }
3086
+ } else {
3087
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3088
+ }
3089
+ case "endHealthWorkout":
3090
+ if config.enableHealthKit {
3091
+ CraftHealth.shared.endWorkout { [weak self] in self?.answer(callbackId, $0) }
3092
+ } else {
3093
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3094
+ }
3064
3095
 
3065
3096
  // MARK: - Live Activities
3066
3097
  case "isLiveActivityAvailable":
@@ -5648,7 +5679,11 @@ struct CraftWebView: UIViewRepresentable {
5648
5679
  var since = options && typeof options.since === 'number' ? options.since : null;
5649
5680
  return craft._invoke('startHealthHeartRateUpdates', {since: since});
5650
5681
  },
5651
- stopHeartRateUpdates: function() { return craft._invoke('stopHealthHeartRateUpdates'); }
5682
+ stopHeartRateUpdates: function() { return craft._invoke('stopHealthHeartRateUpdates'); },
5683
+ startWorkout: function(options) { return craft._invoke('startHealthWorkout', options || {}); },
5684
+ pauseWorkout: function() { return craft._invoke('pauseHealthWorkout'); },
5685
+ resumeWorkout: function() { return craft._invoke('resumeHealthWorkout'); },
5686
+ endWorkout: function() { return craft._invoke('endHealthWorkout'); }
5652
5687
  };
5653
5688
  // The Music app's song and transport. Nothing is read until
5654
5689
  // requestAuthorization() has been allowed; observe(true) makes
@@ -9340,7 +9375,7 @@ final class CraftZigRuntime: NSObject {
9340
9375
  enum CraftWebServices {
9341
9376
  private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
9342
9377
  /// Coordinator events a native screen hears too.
9343
- static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftMusicChange", "craftHeartRate"]
9378
+ static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftHealthWorkout", "craftMusicChange", "craftHeartRate"]
9344
9379
  static let event = Notification.Name("craftWebServicesEvent")
9345
9380
 
9346
9381
  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
  }
@@ -264,8 +264,8 @@ protocol CraftHybridScreenEvents: AnyObject {
264
264
  func nativeScreenDidRender(_ screen: CraftNativeScreenController)
265
265
  /// The screen could not draw its first frame: no bundle, or its script threw.
266
266
  func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String)
267
- /// `craft.navigation.open(path)`.
268
- func nativeScreen(_ screen: CraftNativeScreenController, open path: String)
267
+ /// `craft.navigation.open(path, props)`.
268
+ func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any])
269
269
  /// `craft.navigation.replace(path)`: finish the current flow atomically.
270
270
  func nativeScreen(_ screen: CraftNativeScreenController, replace path: String)
271
271
  }
@@ -446,11 +446,11 @@ final class CraftHybrid: NSObject {
446
446
  /// A path or a web address opened by the app script or a single-stack
447
447
  /// screen: the shell opens it (holding it until the shell exists); an app
448
448
  /// with no shell opens a web address in the in-app browser.
449
- func open(_ path: String) {
449
+ func open(_ path: String, props: [String: Any] = [:]) {
450
450
  if let controller {
451
- controller.open(path)
451
+ controller.open(path, props: props)
452
452
  } else if isEnabled {
453
- untilShell.append { $0.open(path) }
453
+ untilShell.append { $0.open(path, props: props) }
454
454
  } else {
455
455
  openOutside(path)
456
456
  }
@@ -976,8 +976,8 @@ extension CraftHybrid: CraftHybridScreenEvents {
976
976
  DispatchQueue.main.async { self.startAppScript() }
977
977
  }
978
978
 
979
- func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
980
- open(path)
979
+ func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any]) {
980
+ open(path, props: props)
981
981
  }
982
982
 
983
983
  func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
@@ -1585,11 +1585,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1585
1585
  }
1586
1586
 
1587
1587
  /// The entry for a path: its native screen, or a web entry.
1588
- private func entry(for path: String, isTabRoot: Bool, depth: Int?, params extra: [String: Any] = [:]) -> UIViewController {
1588
+ private func entry(for path: String, isTabRoot: Bool, depth: Int?, props: [String: Any] = [:]) -> UIViewController {
1589
1589
  if hybrid.isEnabled, let match = hybrid.routes.match(path) {
1590
- var params = self.params(for: path, match)
1591
- extra.forEach { params[$0.key] = $0.value }
1592
- let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params)
1590
+ let params = self.params(for: path, match)
1591
+ let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params, routeProps: props)
1593
1592
  screen.hybridEvents = self
1594
1593
  nativePaths[ObjectIdentifier(screen)] = match.path
1595
1594
  return screen
@@ -1734,7 +1733,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1734
1733
  /// an app with no web view, a path no screen draws opens in the in-app
1735
1734
  /// browser on the app's site. A web address on the app's site is its
1736
1735
  /// path; one anywhere else opens in the in-app browser.
1737
- func open(_ raw: String) {
1736
+ func open(_ raw: String, props: [String: Any] = [:]) {
1738
1737
  if let url = URL(string: raw), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
1739
1738
  guard CraftHybridRoutes.isSite(url, site: hybrid.siteURL) else {
1740
1739
  hybrid.openOutside(raw)
@@ -1749,7 +1748,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1749
1748
  return
1750
1749
  }
1751
1750
  if hybrid.routes.match(raw) != nil {
1752
- showNative(path: raw, replace: false)
1751
+ showNative(path: raw, replace: false, props: props)
1753
1752
  return
1754
1753
  }
1755
1754
  if hybrid.hasWeb {
@@ -1769,7 +1768,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1769
1768
 
1770
1769
  /// The native screen for `raw`: the one already in the stack (popping to
1771
1770
  /// it), a tab's root, or a new one pushed.
1772
- func showNative(path raw: String, replace: Bool, fromLink: Bool = false) {
1771
+ func showNative(path raw: String, replace: Bool, fromLink: Bool = false, props: [String: Any] = [:]) {
1773
1772
  guard let match = hybrid.routes.match(raw) else { return }
1774
1773
  interruptTabSwipe(restoringSelection: true)
1775
1774
  let tabs = hybrid.tabIds
@@ -1785,7 +1784,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1785
1784
  nav.popToRootViewController(animated: !fromLink)
1786
1785
  }
1787
1786
  // `/m/store?coach=5`: the tab's own screen, with the query.
1788
- (stacks[tab]?.viewControllers.first as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
1787
+ (stacks[tab]?.viewControllers.first as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match), props: props)
1789
1788
  return
1790
1789
  }
1791
1790
  if fromLink, let owner = CraftHybridRoutes.tab(owning: match.path, among: tabs), owner != currentTab {
@@ -1796,10 +1795,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1796
1795
  if existing !== nav.topViewController { nav.popToViewController(existing, animated: true) }
1797
1796
  // Opened again, perhaps with another query (`?person=8`): the
1798
1797
  // screen hears the params it was opened with this time.
1799
- (existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
1798
+ (existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match), props: props)
1800
1799
  return
1801
1800
  }
1802
- let screen = entry(for: raw, isTabRoot: false, depth: nil)
1801
+ let screen = entry(for: raw, isTabRoot: false, depth: nil, props: props)
1803
1802
  let show = { [weak nav] in
1804
1803
  guard let nav else { return }
1805
1804
  if replace, !nav.viewControllers.isEmpty {
@@ -2175,8 +2174,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2175
2174
  }
2176
2175
  }
2177
2176
 
2178
- func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
2179
- open(path)
2177
+ func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any]) {
2178
+ open(path, props: props)
2180
2179
  }
2181
2180
 
2182
2181
  func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
@@ -70,7 +70,7 @@ enum CraftLiveActivities {
70
70
 
71
71
  private static func finite(_ body: [String: Any], _ key: String, _ fallback: Double? = nil) -> Double? {
72
72
  guard body.keys.contains(key) else { return fallback }
73
- guard let number = body[key] as? NSNumber, number.doubleValue.isFinite else { return nil }
73
+ guard let number = craftNativeNumber(body[key]), number.doubleValue.isFinite else { return nil }
74
74
  return max(0, number.doubleValue)
75
75
  }
76
76