@craft-native/ios 0.0.436 → 0.0.443

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/dist/cli.js CHANGED
@@ -24,7 +24,7 @@ function iosPermissions(config) {
24
24
  contacts: config.enableContacts,
25
25
  calendar: config.enableCalendar,
26
26
  bluetooth: config.enableBluetooth || config.enableHeartRate,
27
- motion: config.enableMotionSensors || config.enableShakeGesture,
27
+ motion: config.enableMotionSensors || config.enableShakeGesture || config.enableBarometer || config.enablePedometer,
28
28
  biometric: config.enableBiometric,
29
29
  music: config.enableAppleMusic,
30
30
  health: config.enableHealthKit,
@@ -504,11 +504,16 @@ var DEFAULT_CONFIG = {
504
504
  enableAudioRecording: false,
505
505
  enableVideoRecording: false,
506
506
  enableMotionSensors: false,
507
+ enableBarometer: false,
508
+ enablePedometer: false,
507
509
  enableShakeGesture: false,
508
510
  enableLocalDatabase: false,
509
511
  enableBluetooth: false,
510
512
  enableNFC: false,
511
513
  enableHealthKit: false,
514
+ enableHealthKitBackground: false,
515
+ healthKitBackgroundTypes: [],
516
+ healthKitBackgroundFrequency: "hourly",
512
517
  enableWallet: false,
513
518
  enableLiveActivities: false,
514
519
  enableWatchApp: false,
@@ -565,7 +570,7 @@ function renderUsageDescriptions(config) {
565
570
  [config.enableContacts, "NSContactsUsageDescription", `${config.appName} accesses contacts only when you choose a contact feature.`],
566
571
  [config.enableCalendar, "NSCalendarsUsageDescription", `${config.appName} accesses your calendar only when you choose a calendar feature.`],
567
572
  [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.`],
568
- [config.enableMotionSensors, "NSMotionUsageDescription", `${config.appName} uses motion data for activity features.`],
573
+ [config.enableMotionSensors || config.enableBarometer || config.enablePedometer, "NSMotionUsageDescription", `${config.appName} uses motion data for activity features.`],
569
574
  [config.enableNFC, "NFCReaderUsageDescription", `${config.appName} reads NFC tags when you start a scan.`],
570
575
  [config.enableLocalNetwork, "NSLocalNetworkUsageDescription", `${config.appName} connects to your own devices on this network.`],
571
576
  [config.enableHealthKit, "NSHealthShareUsageDescription", `${config.appName} reads health data you choose to share.`],
@@ -674,8 +679,24 @@ ${plistArray([identifier])}
674
679
  function associatedDomainEntries(domains) {
675
680
  return [...new Set((domains ?? []).map((value) => value.trim()).filter(Boolean).map((value) => /^[a-z]+:/i.test(value) ? value : `applinks:${value}`))];
676
681
  }
682
+ function validateHealthKitBackground(config) {
683
+ if (!config.enableHealthKitBackground)
684
+ return;
685
+ if (!config.enableHealthKit)
686
+ throw new Error("enableHealthKitBackground requires enableHealthKit");
687
+ const types = config.healthKitBackgroundTypes;
688
+ const supported = ["steps", "heartRate", "restingHeartRate", "heartRateVariability", "activeEnergy", "distance", "bodyMass", "sleep", "workouts"];
689
+ if (!Array.isArray(types) || !types.length || types.some((type) => !supported.includes(type)))
690
+ throw new Error("healthKitBackgroundTypes must declare supported HealthKit sample types");
691
+ if (!["immediate", "hourly", "daily", "weekly"].includes(config.healthKitBackgroundFrequency ?? "hourly"))
692
+ throw new Error("healthKitBackgroundFrequency must be immediate, hourly, daily or weekly");
693
+ }
677
694
  function renderEntitlements(config) {
695
+ validateHealthKitBackground(config);
678
696
  const entries = [];
697
+ if (config.enableHealthKitBackground)
698
+ entries.push(` <key>com.apple.developer.healthkit.background-delivery</key>
699
+ <true/>`);
679
700
  const associatedDomains = associatedDomainEntries(config.associatedDomains);
680
701
  if (associatedDomains.length) {
681
702
  entries.push(` <key>com.apple.developer.associated-domains</key>
@@ -888,6 +909,14 @@ function renderAssetCatalog(output, config) {
888
909
  renderLaunchLogo(catalog, config);
889
910
  }
890
911
  var LAUNCH_LOGO = "LaunchLogo";
912
+ function splashScales(source) {
913
+ const extension = extname(source);
914
+ if (!/^\.(?:png|jpe?g)$/i.test(extension))
915
+ return [{ scale: 1, path: source }];
916
+ const stem = source.slice(0, -extension.length);
917
+ const scales = [2, 3].map((scale) => ({ scale, path: `${stem}@${scale}x${extension}` })).filter((item) => existsSync2(item.path));
918
+ return scales.length ? [{ scale: 1, path: source }, ...scales] : [{ scale: 1, path: source }];
919
+ }
891
920
  function renderLaunchLogo(catalog, config) {
892
921
  const imageset = join4(catalog, `${LAUNCH_LOGO}.imageset`);
893
922
  rmSync2(imageset, { recursive: true, force: true });
@@ -904,13 +933,18 @@ function renderLaunchLogo(catalog, config) {
904
933
  const extension = source.split(".").pop().toLowerCase();
905
934
  if (extension === "svg" || extension === "pdf")
906
935
  vector = true;
907
- const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}.${extension}`;
908
- cpSync(source, join4(imageset, filename));
909
- images.push({
910
- ...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
911
- filename,
912
- idiom: "universal"
913
- });
936
+ const files = splashScales(source);
937
+ const scaled = files.length > 1;
938
+ for (const { scale, path } of files) {
939
+ const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}${scale === 1 ? "" : `@${scale}x`}.${extension}`;
940
+ cpSync(path, join4(imageset, filename));
941
+ images.push({
942
+ ...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
943
+ filename,
944
+ idiom: "universal",
945
+ ...scaled ? { scale: `${scale}x` } : {}
946
+ });
947
+ }
914
948
  }
915
949
  writeFileSync2(join4(imageset, "Contents.json"), `${JSON.stringify({
916
950
  images,
@@ -1145,6 +1179,8 @@ function writeShell(output, config) {
1145
1179
  writeSource(join4(output, "Sources", "CraftNativeScreen.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeScreen.swift"), "utf8"));
1146
1180
  writeSource(join4(output, "Sources", "CraftNativeFlatList.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeFlatList.swift"), "utf8"));
1147
1181
  writeSource(join4(output, "Sources", "CraftNativeActions.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeActions.swift"), "utf8"));
1182
+ writeSource(join4(output, "Sources", "CraftNativePedometer.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativePedometer.swift"), "utf8"));
1183
+ writeSource(join4(output, "Sources", "CraftHealthBackground.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftHealthBackground.swift"), "utf8"));
1148
1184
  writeSource(join4(output, "Sources", "CraftHealth.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftHealth.swift"), "utf8"));
1149
1185
  writeSource(join4(output, "Sources", "CraftMusic.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftMusic.swift"), "utf8"));
1150
1186
  writeSource(join4(output, "Sources", "CraftBluetooth.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftBluetooth.swift"), "utf8"));
@@ -1211,6 +1247,7 @@ async function init(options) {
1211
1247
  teamId: teamId || "",
1212
1248
  ...given
1213
1249
  };
1250
+ validateHealthKitBackground(config);
1214
1251
  if (config.renderer !== "web" && config.renderer !== "native") {
1215
1252
  throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
1216
1253
  }
package/dist/index.d.ts CHANGED
@@ -84,12 +84,20 @@ export interface CraftConfig {
84
84
  enableAudioRecording?: boolean;
85
85
  enableVideoRecording?: boolean;
86
86
  enableMotionSensors?: boolean;
87
+ /** Core Motion relative altitude and pressure; includes the motion usage description. */
88
+ enableBarometer?: boolean;
89
+ /** Core Motion interval steps, cadence and seven-day device history. */
90
+ enablePedometer?: boolean;
87
91
  /** Shaking the phone raises a `shake` event. Arms the accelerometer while the app is in front. */
88
92
  enableShakeGesture?: boolean;
89
93
  enableLocalDatabase?: boolean;
90
94
  enableBluetooth?: boolean;
91
95
  enableNFC?: boolean;
92
96
  enableHealthKit?: boolean;
97
+ /** Observe declared HealthKit types on background wakes; requires enableHealthKit. */
98
+ enableHealthKitBackground?: boolean;
99
+ healthKitBackgroundTypes?: Array<'steps' | 'heartRate' | 'restingHeartRate' | 'heartRateVariability' | 'activeEnergy' | 'distance' | 'bodyMass' | 'sleep' | 'workouts'>;
100
+ healthKitBackgroundFrequency?: 'immediate' | 'hourly' | 'daily' | 'weekly';
93
101
  /** Native Apple Wallet pass presentation and lookup for native screens. */
94
102
  enableWallet?: boolean;
95
103
  enableLiveActivities?: boolean;
@@ -200,7 +208,9 @@ export interface CraftConfig {
200
208
  * screen until the page says it is ready (`splash.hide()`), so the app goes
201
209
  * from launch to content without a blank web view in between. PNG, PDF or
202
210
  * SVG, drawn at its own size in points: give an SVG the width and height it
203
- * should have on screen.
211
+ * should have on screen. A PNG with `@2x` and `@3x` files beside it
212
+ * (`LaunchLogo.png`, `LaunchLogo@2x.png`, `LaunchLogo@3x.png`, as ts-images'
213
+ * `generateSplashScreens` writes them) is sharp on every screen.
204
214
  */
205
215
  splashImagePath?: string;
206
216
  /** The splash logo in Dark Mode, on `backgroundColorDark`. The light one if unset. */
@@ -361,6 +371,8 @@ export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer
361
371
  * well as the one-file bundle, and the app runs each screen from its own
362
372
  * route's file. Stacks asks for route bundles only when this is true.
363
373
  */
374
+ /** Build-time proof for Stacks capability mappings, including unreleased source builds. */
375
+ export declare const nativeSensorCapabilities: readonly ['barometer', 'pedometer', 'healthKitBackground'];
364
376
  export declare const nativeRouteBundles = true;
365
377
  /**
366
378
  * The app evaluates stx's shared runtime (`__runtime.js`, stx's
@@ -522,6 +534,16 @@ export declare function renderPrivacyManifest(config: CraftConfig): string;
522
534
  export declare function pngHasAlpha(bytes: Uint8Array): boolean;
523
535
  /** The asset the launch screen and the splash both draw, so one hands over to the other unseen. */
524
536
  export declare const LAUNCH_LOGO = "LaunchLogo";
537
+ /**
538
+ * A bitmap splash's `@2x` and `@3x` files beside it (`LaunchLogo.png`,
539
+ * `LaunchLogo@2x.png`, `LaunchLogo@3x.png`, as ts-images'
540
+ * `generateSplashScreens` writes them), by scale. A bitmap without them is
541
+ * one image, drawn at its pixel size in points.
542
+ */
543
+ export declare function splashScales(source: string): Array<{
544
+ scale: 1 | 2 | 3;
545
+ path: string;
546
+ }>;
525
547
  /** The launch screen: the background colour, and the splash logo on it when there is one. */
526
548
  export declare function renderLaunchScreen(config: Pick<CraftConfig, 'splashImagePath'>): string;
527
549
  /** Near-black or near-white, whichever reads on `background` (WCAG relative luminance). */
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ function iosPermissions(config) {
18
18
  contacts: config.enableContacts,
19
19
  calendar: config.enableCalendar,
20
20
  bluetooth: config.enableBluetooth || config.enableHeartRate,
21
- motion: config.enableMotionSensors || config.enableShakeGesture,
21
+ motion: config.enableMotionSensors || config.enableShakeGesture || config.enableBarometer || config.enablePedometer,
22
22
  biometric: config.enableBiometric,
23
23
  music: config.enableAppleMusic,
24
24
  health: config.enableHealthKit,
@@ -618,6 +618,7 @@ function hybridConfigProblems(config) {
618
618
  problems.push(`nativeScriptThread "${String(config.nativeScriptThread)}" must be "main" or "background".`);
619
619
  return problems;
620
620
  }
621
+ var nativeSensorCapabilities = ["barometer", "pedometer", "healthKitBackground"];
621
622
  var nativeRouteBundles = true;
622
623
  var nativeRouteRuntime = true;
623
624
  var nativeMessages = true;
@@ -701,11 +702,16 @@ var DEFAULT_CONFIG = {
701
702
  enableAudioRecording: false,
702
703
  enableVideoRecording: false,
703
704
  enableMotionSensors: false,
705
+ enableBarometer: false,
706
+ enablePedometer: false,
704
707
  enableShakeGesture: false,
705
708
  enableLocalDatabase: false,
706
709
  enableBluetooth: false,
707
710
  enableNFC: false,
708
711
  enableHealthKit: false,
712
+ enableHealthKitBackground: false,
713
+ healthKitBackgroundTypes: [],
714
+ healthKitBackgroundFrequency: "hourly",
709
715
  enableWallet: false,
710
716
  enableLiveActivities: false,
711
717
  enableWatchApp: false,
@@ -762,7 +768,7 @@ function renderUsageDescriptions(config) {
762
768
  [config.enableContacts, "NSContactsUsageDescription", `${config.appName} accesses contacts only when you choose a contact feature.`],
763
769
  [config.enableCalendar, "NSCalendarsUsageDescription", `${config.appName} accesses your calendar only when you choose a calendar feature.`],
764
770
  [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.`],
765
- [config.enableMotionSensors, "NSMotionUsageDescription", `${config.appName} uses motion data for activity features.`],
771
+ [config.enableMotionSensors || config.enableBarometer || config.enablePedometer, "NSMotionUsageDescription", `${config.appName} uses motion data for activity features.`],
766
772
  [config.enableNFC, "NFCReaderUsageDescription", `${config.appName} reads NFC tags when you start a scan.`],
767
773
  [config.enableLocalNetwork, "NSLocalNetworkUsageDescription", `${config.appName} connects to your own devices on this network.`],
768
774
  [config.enableHealthKit, "NSHealthShareUsageDescription", `${config.appName} reads health data you choose to share.`],
@@ -871,8 +877,24 @@ ${plistArray([identifier])}
871
877
  function associatedDomainEntries(domains) {
872
878
  return [...new Set((domains ?? []).map((value) => value.trim()).filter(Boolean).map((value) => /^[a-z]+:/i.test(value) ? value : `applinks:${value}`))];
873
879
  }
880
+ function validateHealthKitBackground(config) {
881
+ if (!config.enableHealthKitBackground)
882
+ return;
883
+ if (!config.enableHealthKit)
884
+ throw new Error("enableHealthKitBackground requires enableHealthKit");
885
+ const types = config.healthKitBackgroundTypes;
886
+ const supported = ["steps", "heartRate", "restingHeartRate", "heartRateVariability", "activeEnergy", "distance", "bodyMass", "sleep", "workouts"];
887
+ if (!Array.isArray(types) || !types.length || types.some((type) => !supported.includes(type)))
888
+ throw new Error("healthKitBackgroundTypes must declare supported HealthKit sample types");
889
+ if (!["immediate", "hourly", "daily", "weekly"].includes(config.healthKitBackgroundFrequency ?? "hourly"))
890
+ throw new Error("healthKitBackgroundFrequency must be immediate, hourly, daily or weekly");
891
+ }
874
892
  function renderEntitlements(config) {
893
+ validateHealthKitBackground(config);
875
894
  const entries = [];
895
+ if (config.enableHealthKitBackground)
896
+ entries.push(` <key>com.apple.developer.healthkit.background-delivery</key>
897
+ <true/>`);
876
898
  const associatedDomains = associatedDomainEntries(config.associatedDomains);
877
899
  if (associatedDomains.length) {
878
900
  entries.push(` <key>com.apple.developer.associated-domains</key>
@@ -1085,6 +1107,14 @@ function renderAssetCatalog(output, config) {
1085
1107
  renderLaunchLogo(catalog, config);
1086
1108
  }
1087
1109
  var LAUNCH_LOGO = "LaunchLogo";
1110
+ function splashScales(source) {
1111
+ const extension = extname(source);
1112
+ if (!/^\.(?:png|jpe?g)$/i.test(extension))
1113
+ return [{ scale: 1, path: source }];
1114
+ const stem = source.slice(0, -extension.length);
1115
+ const scales = [2, 3].map((scale) => ({ scale, path: `${stem}@${scale}x${extension}` })).filter((item) => existsSync2(item.path));
1116
+ return scales.length ? [{ scale: 1, path: source }, ...scales] : [{ scale: 1, path: source }];
1117
+ }
1088
1118
  function renderLaunchLogo(catalog, config) {
1089
1119
  const imageset = join4(catalog, `${LAUNCH_LOGO}.imageset`);
1090
1120
  rmSync3(imageset, { recursive: true, force: true });
@@ -1101,13 +1131,18 @@ function renderLaunchLogo(catalog, config) {
1101
1131
  const extension = source.split(".").pop().toLowerCase();
1102
1132
  if (extension === "svg" || extension === "pdf")
1103
1133
  vector = true;
1104
- const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}.${extension}`;
1105
- cpSync(source, join4(imageset, filename));
1106
- images.push({
1107
- ...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
1108
- filename,
1109
- idiom: "universal"
1110
- });
1134
+ const files = splashScales(source);
1135
+ const scaled = files.length > 1;
1136
+ for (const { scale, path } of files) {
1137
+ const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}${scale === 1 ? "" : `@${scale}x`}.${extension}`;
1138
+ cpSync(path, join4(imageset, filename));
1139
+ images.push({
1140
+ ...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
1141
+ filename,
1142
+ idiom: "universal",
1143
+ ...scaled ? { scale: `${scale}x` } : {}
1144
+ });
1145
+ }
1111
1146
  }
1112
1147
  writeFileSync3(join4(imageset, "Contents.json"), `${JSON.stringify({
1113
1148
  images,
@@ -1358,6 +1393,8 @@ function writeShell(output, config) {
1358
1393
  writeSource(join4(output, "Sources", "CraftNativeScreen.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeScreen.swift"), "utf8"));
1359
1394
  writeSource(join4(output, "Sources", "CraftNativeFlatList.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeFlatList.swift"), "utf8"));
1360
1395
  writeSource(join4(output, "Sources", "CraftNativeActions.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeActions.swift"), "utf8"));
1396
+ writeSource(join4(output, "Sources", "CraftNativePedometer.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativePedometer.swift"), "utf8"));
1397
+ writeSource(join4(output, "Sources", "CraftHealthBackground.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHealthBackground.swift"), "utf8"));
1361
1398
  writeSource(join4(output, "Sources", "CraftHealth.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHealth.swift"), "utf8"));
1362
1399
  writeSource(join4(output, "Sources", "CraftMusic.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftMusic.swift"), "utf8"));
1363
1400
  writeSource(join4(output, "Sources", "CraftBluetooth.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftBluetooth.swift"), "utf8"));
@@ -1424,6 +1461,7 @@ async function init(options) {
1424
1461
  teamId: teamId || "",
1425
1462
  ...given
1426
1463
  };
1464
+ validateHealthKitBackground(config);
1427
1465
  if (config.renderer !== "web" && config.renderer !== "native") {
1428
1466
  throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
1429
1467
  }
@@ -1784,6 +1822,7 @@ export {
1784
1822
  nativeMessages,
1785
1823
  nativeRouteBundles,
1786
1824
  nativeRouteRuntime,
1825
+ nativeSensorCapabilities,
1787
1826
  nativeStartPath,
1788
1827
  normalizeNativePath,
1789
1828
  open,
@@ -1815,6 +1854,7 @@ export {
1815
1854
  runPostGenerateHook,
1816
1855
  serializeSimulatorSteps,
1817
1856
  showSimulator,
1857
+ splashScales,
1818
1858
  syncNativeFonts,
1819
1859
  syncWebAssets,
1820
1860
  usesNativeBundle
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.436",
3
+ "version": "0.0.443",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -299,6 +299,15 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
299
299
  // push, a refresh, a route recording's location) draws nothing, and
300
300
  // its events still need someone to hear them.
301
301
  CraftHybrid.shared.configure(config)
302
+ CraftHealthBackground.shared.emit = { type in
303
+ let detail: [String: Any] = ["type": type]
304
+ CraftEventManager.shared.sendToWeb("craftHealthBackground", data: detail)
305
+ NotificationCenter.default.post(name: CraftWebServices.event, object: nil,
306
+ userInfo: ["event": "craftHealthBackground", "detail": detail])
307
+ }
308
+ CraftHealthBackground.shared.configure(types: config.enableHealthKit && config.enableHealthKitBackground ? config.healthKitBackgroundTypes : [],
309
+ frequency: config.healthKitBackgroundFrequency)
310
+
302
311
  if let shortcuts = config.appShortcuts {
303
312
  application.shortcutItems = shortcuts.prefix(4).map { shortcut in
304
313
  UIApplicationShortcutItem(type: shortcut.id, localizedTitle: shortcut.title, localizedSubtitle: shortcut.subtitle,
@@ -1830,6 +1839,8 @@ struct CraftConfig: Codable {
1830
1839
  var enableAudioRecording: Bool = false
1831
1840
  var enableVideoRecording: Bool = false
1832
1841
  var enableMotionSensors: Bool = false
1842
+ var enableBarometer: Bool = false
1843
+ var enablePedometer: Bool = false
1833
1844
  /// Shaking the phone raises a `shake` event, for a report-a-problem
1834
1845
  /// gesture. Its own flag rather than a corner of `enableMotionSensors`,
1835
1846
  /// because it arms the accelerometer for as long as the app is in front:
@@ -1839,6 +1850,9 @@ struct CraftConfig: Codable {
1839
1850
  var enableBluetooth: Bool = false
1840
1851
  var enableNFC: Bool = false
1841
1852
  var enableHealthKit: Bool = false
1853
+ var enableHealthKitBackground: Bool = false
1854
+ var healthKitBackgroundTypes: [String] = []
1855
+ var healthKitBackgroundFrequency: String = "hourly"
1842
1856
  /// Apple Wallet pass presentation and lookup for native screens.
1843
1857
  var enableWallet: Bool = false
1844
1858
  var enableLiveActivities: Bool = false
@@ -227,6 +227,7 @@ final class CraftHealth {
227
227
  if readOnly { shareTypes.removeAll() }
228
228
  healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] success, error in
229
229
  if success {
230
+ DispatchQueue.main.async { CraftHealthBackground.shared.refresh() }
230
231
  completion(.success(true))
231
232
  } else {
232
233
  completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Authorization failed")))
@@ -401,7 +402,7 @@ final class CraftHealth {
401
402
 
402
403
  /// The quantity behind each health data type, its unit, and the one
403
404
  /// statistic that type supports.
404
- private static func healthQuantity(_ type: String) -> (type: HKQuantityType?, unit: HKUnit, options: HKStatisticsOptions)? {
405
+ static func healthQuantity(_ type: String) -> (type: HKQuantityType?, unit: HKUnit, options: HKStatisticsOptions)? {
405
406
  let bpm = HKUnit.count().unitDivided(by: .minute())
406
407
  switch type {
407
408
  case "steps": return (HKQuantityType.quantityType(forIdentifier: .stepCount), .count(), .cumulativeSum)
@@ -0,0 +1,308 @@
1
+ import Foundation
2
+ #if CRAFT_PERMISSION_HEALTH
3
+ import HealthKit
4
+ #endif
5
+
6
+ struct CraftHealthBackgroundSample: Codable {
7
+ let id: String
8
+ let startDate: Double
9
+ let endDate: Double
10
+ var value: Double?
11
+ var unit: String?
12
+ var category: Int?
13
+ var workoutActivity: UInt?
14
+ var durationSeconds: Double?
15
+ }
16
+
17
+ struct CraftHealthBackgroundPage {
18
+ let anchor: Data
19
+ let samples: [CraftHealthBackgroundSample]
20
+ let deleted: [String]
21
+ let more: Bool
22
+ }
23
+
24
+ struct CraftHealthBackgroundBatch: Codable {
25
+ let id: String
26
+ let type: String
27
+ let samples: [CraftHealthBackgroundSample]
28
+ let deleted: [String]
29
+ }
30
+
31
+ struct CraftHealthBackgroundState: Codable {
32
+ var anchors: [String: Data] = [:]
33
+ var observedTypes: [String]? = nil
34
+ var batches: [CraftHealthBackgroundBatch] = []
35
+ }
36
+
37
+ /// An atomic snapshot keeps a new anchor inseparable from its unconsumed delta.
38
+ protocol CraftHealthBackgroundPersistence {
39
+ func load() throws -> CraftHealthBackgroundState
40
+ func save(_ state: CraftHealthBackgroundState) throws
41
+ }
42
+
43
+ final class CraftHealthBackgroundFile: CraftHealthBackgroundPersistence {
44
+ private let url: URL
45
+ init(url: URL) { self.url = url }
46
+ func load() throws -> CraftHealthBackgroundState {
47
+ guard FileManager.default.fileExists(atPath: url.path) else { return CraftHealthBackgroundState() }
48
+ return try JSONDecoder().decode(CraftHealthBackgroundState.self, from: Data(contentsOf: url))
49
+ }
50
+ func save(_ state: CraftHealthBackgroundState) throws {
51
+ let data = try JSONEncoder().encode(state)
52
+ guard data.count <= 8 * 1024 * 1024 else { throw CraftHealthBackgroundError.outboxFull }
53
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true,
54
+ attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
55
+ try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
56
+ var excluded = url
57
+ var values = URLResourceValues()
58
+ values.isExcludedFromBackup = true
59
+ try excluded.setResourceValues(values)
60
+ }
61
+ }
62
+
63
+ enum CraftHealthBackgroundError: Error { case unavailable, outboxFull, invalidAnchor, missingAnchor }
64
+
65
+ /// All callbacks and ownership transitions are serialized on the main queue.
66
+ protocol CraftHealthBackgroundSource {
67
+ func observe(type: String, update: @escaping (Error?, @escaping () -> Void) -> Void) -> () -> Void
68
+ func enable(type: String, frequency: String, completion: @escaping (Error?) -> Void)
69
+ func disable(type: String)
70
+ func read(type: String, anchor: Data?, completion: @escaping (Result<CraftHealthBackgroundPage, Error>) -> Void) -> () -> Void
71
+ }
72
+
73
+ final class CraftHealthBackground {
74
+ static let shared: CraftHealthBackground = {
75
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
76
+ #if CRAFT_PERMISSION_HEALTH
77
+ let source: CraftHealthBackgroundSource = CraftHealthKitBackgroundSource()
78
+ #else
79
+ let source: CraftHealthBackgroundSource = CraftUnavailableHealthBackgroundSource()
80
+ #endif
81
+ return CraftHealthBackground(source: source, persistence: CraftHealthBackgroundFile(url: base.appendingPathComponent("craft-health/delivery.json")))
82
+ }()
83
+
84
+ private final class Read {
85
+ var completions: [() -> Void] = []
86
+ var cancel: (() -> Void)?
87
+ var deadline: DispatchWorkItem?
88
+ var dirty = false
89
+ }
90
+ private let readTimeout: TimeInterval
91
+ private let source: CraftHealthBackgroundSource
92
+ private let persistence: CraftHealthBackgroundPersistence
93
+ private var state = CraftHealthBackgroundState()
94
+ private var loaded = false
95
+ private var observers: [String: () -> Void] = [:]
96
+ private var reads: [String: Read] = [:]
97
+ private var types: [String] = []
98
+ private var frequency = "hourly"
99
+ private var generation = UUID()
100
+ private(set) var errors: [String: String] = [:]
101
+ var emit: (String) -> Void = { _ in }
102
+
103
+ init(source: CraftHealthBackgroundSource, persistence: CraftHealthBackgroundPersistence, readTimeout: TimeInterval = 20) {
104
+ self.source = source
105
+ self.persistence = persistence
106
+ self.readTimeout = readTimeout
107
+ }
108
+
109
+ func configure(types: [String], frequency: String) {
110
+ stop()
111
+ self.types = Array(Set(types)).sorted()
112
+ self.frequency = frequency
113
+ refresh()
114
+ }
115
+
116
+ func refresh() {
117
+ if !loaded {
118
+ do { state = try persistence.load(); loaded = true }
119
+ catch { errors["storage"] = "Health delivery state could not be read"; return }
120
+ }
121
+ errors.removeValue(forKey: "storage")
122
+ let current = generation
123
+ let observed = Set(state.observedTypes ?? []).union(state.anchors.keys).union(types)
124
+ if Set(state.observedTypes ?? []) != observed {
125
+ do {
126
+ var next = state
127
+ next.observedTypes = observed.sorted()
128
+ try persistence.save(next)
129
+ state = next
130
+ } catch { errors["storage"] = "Health registration intent could not be persisted"; return }
131
+ }
132
+ // Keep retired declarations so a temporarily unavailable store can
133
+ // retry disabling them on a later launch, even before its first delta.
134
+ for old in observed where !types.contains(old) { source.disable(type: old) }
135
+ for type in types {
136
+ if observers[type] == nil {
137
+ observers[type] = source.observe(type: type) { [weak self] error, acknowledge in
138
+ guard let self, self.generation == current else { acknowledge(); return }
139
+ if error != nil { self.errors[type] = "HealthKit observer failed"; acknowledge(); return }
140
+ self.changed(type, acknowledge: acknowledge)
141
+ }
142
+ }
143
+ source.enable(type: type, frequency: frequency) { [weak self] error in
144
+ guard let self, self.generation == current else { return }
145
+ if error != nil { self.errors["\(type).registration"] = "Background delivery registration failed" }
146
+ else { self.errors.removeValue(forKey: "\(type).registration"); self.changed(type, acknowledge: {}) }
147
+ }
148
+ }
149
+ }
150
+
151
+ private func changed(_ type: String, acknowledge: @escaping () -> Void) {
152
+ if let read = reads[type] { read.completions.append(acknowledge); read.dirty = true; return }
153
+ let read = Read()
154
+ read.completions.append(acknowledge)
155
+ reads[type] = read
156
+ // Never strand HealthKit's completion waiting for an unavailable store,
157
+ // a locked device, an unresponsive query or JavaScript consumption.
158
+ let deadline = DispatchWorkItem { [weak self, weak read] in
159
+ guard let self, let read, self.reads[type] === read else { return }
160
+ self.errors[type] = "HealthKit read timed out; anchor retained for retry"
161
+ self.finish(type, read)
162
+ }
163
+ read.deadline = deadline
164
+ DispatchQueue.main.asyncAfter(deadline: .now() + readTimeout, execute: deadline)
165
+ next(type, read)
166
+ }
167
+
168
+ private func next(_ type: String, _ read: Read) {
169
+ read.dirty = false
170
+ read.cancel = source.read(type: type, anchor: state.anchors[type]) { [weak self, weak read] answer in
171
+ guard let self, let read, self.reads[type] === read else { return }
172
+ read.cancel = nil
173
+ switch answer {
174
+ case .failure:
175
+ self.errors[type] = "HealthKit read failed; anchor retained for retry"
176
+ self.finish(type, read)
177
+ case .success(let page):
178
+ do {
179
+ var next = self.state
180
+ if !page.samples.isEmpty || !page.deleted.isEmpty {
181
+ next.batches.append(CraftHealthBackgroundBatch(id: UUID().uuidString, type: type, samples: page.samples, deleted: page.deleted))
182
+ }
183
+ next.anchors[type] = page.anchor
184
+ try self.persistence.save(next)
185
+ self.state = next
186
+ self.errors.removeValue(forKey: type)
187
+ if !page.samples.isEmpty || !page.deleted.isEmpty { self.emit(type) }
188
+ if page.more || read.dirty { self.next(type, read) }
189
+ else { self.finish(type, read) }
190
+ } catch {
191
+ self.errors[type] = "Health delivery could not be persisted; anchor retained for retry"
192
+ self.finish(type, read)
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ private func finish(_ type: String, _ read: Read) {
199
+ guard reads[type] === read else { return }
200
+ reads.removeValue(forKey: type)
201
+ read.deadline?.cancel()
202
+ read.cancel?()
203
+ let completions = read.completions
204
+ read.completions.removeAll()
205
+ completions.forEach { $0() }
206
+ }
207
+
208
+ func stop() {
209
+ generation = UUID()
210
+ observers.values.forEach { $0() }
211
+ observers.removeAll()
212
+ for (type, read) in reads { finish(type, read) }
213
+ }
214
+
215
+ func perform(method: String, args: [Any], completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
216
+ if !loaded { refresh() }
217
+ guard loaded else { completion(.failure(CraftNativeActionError(code: "STORAGE_ERROR", message: "Health delivery state is unavailable"))); return }
218
+ do {
219
+ switch method {
220
+ case "status": completion(.success(["types": types, "frequency": frequency, "pendingBatches": state.batches.count, "errors": errors]))
221
+ case "refresh": refresh(); completion(.success(true))
222
+ case "pending":
223
+ let data = try JSONEncoder().encode(Array(state.batches.prefix(20)))
224
+ completion(.success(try JSONSerialization.jsonObject(with: data)))
225
+ case "acknowledge":
226
+ guard let ids = args.first as? [String], ids.count <= 20 else {
227
+ completion(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "acknowledge needs up to 20 batch ids"))); return
228
+ }
229
+ var next = state
230
+ let acknowledged = Set(ids)
231
+ next.batches.removeAll { acknowledged.contains($0.id) }
232
+ try persistence.save(next)
233
+ state = next
234
+ refresh()
235
+ completion(.success(true))
236
+ default: completion(.failure(CraftNativeActionError(code: "UNKNOWN_ACTION", message: "Unknown HealthBackground action")))
237
+ }
238
+ } catch { completion(.failure(CraftNativeActionError(code: "STORAGE_ERROR", message: "Health delivery state could not be saved"))) }
239
+ }
240
+ }
241
+
242
+ private final class CraftUnavailableHealthBackgroundSource: CraftHealthBackgroundSource {
243
+ func disable(type: String) {}
244
+ func observe(type: String, update: @escaping (Error?, @escaping () -> Void) -> Void) -> () -> Void { {} }
245
+ func enable(type: String, frequency: String, completion: @escaping (Error?) -> Void) { completion(CraftHealthBackgroundError.unavailable) }
246
+ func read(type: String, anchor: Data?, completion: @escaping (Result<CraftHealthBackgroundPage, Error>) -> Void) -> () -> Void { completion(.failure(CraftHealthBackgroundError.unavailable)); return {} }
247
+ }
248
+
249
+ #if CRAFT_PERMISSION_HEALTH
250
+ private final class CraftHealthKitBackgroundSource: CraftHealthBackgroundSource {
251
+ private var store: HKHealthStore? { CraftHealth.shared.healthStore }
252
+ private func sampleType(_ name: String) -> HKSampleType? {
253
+ if name == "sleep" { return HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) }
254
+ if name == "workouts" { return HKObjectType.workoutType() }
255
+ return CraftHealth.healthQuantity(name)?.type
256
+ }
257
+ func disable(type: String) {
258
+ guard let store, let sample = sampleType(type) else { return }
259
+ store.disableBackgroundDelivery(for: sample) { _, _ in }
260
+ }
261
+ func observe(type: String, update: @escaping (Error?, @escaping () -> Void) -> Void) -> () -> Void {
262
+ guard let store, let sample = sampleType(type) else { return {} }
263
+ let query = HKObserverQuery(sampleType: sample, predicate: nil) { _, completion, error in
264
+ DispatchQueue.main.async { update(error, completion) }
265
+ }
266
+ store.execute(query)
267
+ return { store.stop(query) }
268
+ }
269
+ func enable(type: String, frequency: String, completion: @escaping (Error?) -> Void) {
270
+ guard let store, let sample = sampleType(type) else { completion(CraftHealthBackgroundError.unavailable); return }
271
+ let value: HKUpdateFrequency = ["immediate": .immediate, "hourly": .hourly, "daily": .daily, "weekly": .weekly][frequency] ?? .hourly
272
+ store.enableBackgroundDelivery(for: sample, frequency: value) { success, error in
273
+ DispatchQueue.main.async { completion(success ? nil : error ?? CraftHealthBackgroundError.unavailable) }
274
+ }
275
+ }
276
+ func read(type: String, anchor: Data?, completion: @escaping (Result<CraftHealthBackgroundPage, Error>) -> Void) -> () -> Void {
277
+ guard let store, let sample = sampleType(type) else { completion(.failure(CraftHealthBackgroundError.unavailable)); return {} }
278
+ let saved: HKQueryAnchor?
279
+ do {
280
+ saved = try anchor.map { data in
281
+ guard let value = try NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data) else { throw CraftHealthBackgroundError.invalidAnchor }
282
+ return value
283
+ }
284
+ } catch { completion(.failure(error)); return {} }
285
+ let query = HKAnchoredObjectQuery(type: sample, predicate: nil, anchor: saved, limit: 250) { _, samples, deleted, anchor, error in
286
+ let answer: Result<CraftHealthBackgroundPage, Error>
287
+ do {
288
+ if let error { throw error }
289
+ guard let anchor else { throw CraftHealthBackgroundError.missingAnchor }
290
+ let data = try NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
291
+ let records = (samples ?? []).map { sample -> CraftHealthBackgroundSample in
292
+ var record = CraftHealthBackgroundSample(id: sample.uuid.uuidString, startDate: sample.startDate.timeIntervalSince1970 * 1000, endDate: sample.endDate.timeIntervalSince1970 * 1000)
293
+ if let quantity = sample as? HKQuantitySample, let spec = CraftHealth.healthQuantity(type) {
294
+ record.value = quantity.quantity.doubleValue(for: spec.unit)
295
+ record.unit = spec.unit.unitString
296
+ } else if let category = sample as? HKCategorySample { record.category = category.value }
297
+ else if let workout = sample as? HKWorkout { record.workoutActivity = workout.workoutActivityType.rawValue; record.durationSeconds = workout.duration }
298
+ return record
299
+ }
300
+ answer = .success(CraftHealthBackgroundPage(anchor: data, samples: records, deleted: (deleted ?? []).map { $0.uuid.uuidString }, more: records.count + (deleted?.count ?? 0) >= 250))
301
+ } catch { answer = .failure(error) }
302
+ DispatchQueue.main.async { completion(answer) }
303
+ }
304
+ store.execute(query)
305
+ return { store.stop(query) }
306
+ }
307
+ }
308
+ #endif
@@ -1,4 +1,7 @@
1
1
  import Foundation
2
+ #if CRAFT_PERMISSION_MOTION
3
+ import CoreMotion
4
+ #endif
2
5
  import AuthenticationServices
3
6
  #if CRAFT_PERMISSION_BIOMETRIC
4
7
  import LocalAuthentication
@@ -60,6 +63,9 @@ enum CraftNativeActions {
60
63
  if config.enableDeepLinks { values.append("deepLinks") }
61
64
  if config.enableLocalNotifications { values.append("notifications") }
62
65
  if config.enableHealthKit { values.append("health") }
66
+ if config.enableHealthKit && config.enableHealthKitBackground { values.append("healthBackground") }
67
+ if config.enableBarometer { values.append("barometer") }
68
+ if config.enablePedometer { values.append("pedometer") }
63
69
  if config.enableWallet { values.append("wallet") }
64
70
  if config.enableLiveActivities { values.append("liveActivities") }
65
71
  // The in-app browser and the system's web sign-in sheet (CraftBrowser).
@@ -405,6 +411,17 @@ enum CraftNativeActions {
405
411
  complete(requestToken, result, completion)
406
412
  }
407
413
  return
414
+ case ("HealthBackground", _):
415
+ guard config.enableHealthKit && config.enableHealthKitBackground else {
416
+ complete(requestToken, failure("CAPABILITY_DISABLED", "HealthKit background delivery is disabled"), completion)
417
+ return
418
+ }
419
+ DispatchQueue.main.async {
420
+ CraftHealthBackground.shared.perform(method: method, args: args) { result in
421
+ complete(requestToken, result, completion)
422
+ }
423
+ }
424
+ return
408
425
  case ("Health", _):
409
426
  // Apple Health, the same as a page's `craft.health` (CraftHealth.swift).
410
427
  guard config.enableHealthKit else {
@@ -1999,3 +2016,139 @@ enum CraftHaptics {
1999
2016
  return generator
2000
2017
  }
2001
2018
  }
2019
+
2020
+ // MARK: - Route-owned barometer
2021
+
2022
+ struct CraftBarometerSample {
2023
+ let pressureHpa: Double
2024
+ let relativeAltitudeMeters: Double
2025
+ let timestampMs: Double
2026
+ }
2027
+
2028
+ protocol CraftBarometerSource: AnyObject {
2029
+ var available: Bool { get }
2030
+ var denied: Bool { get }
2031
+ func start(_ receive: @escaping (Result<CraftBarometerSample, Error>) -> Void) throws
2032
+ func stop()
2033
+ }
2034
+
2035
+ #if CRAFT_PERMISSION_MOTION
2036
+ private final class CraftCoreMotionBarometer: CraftBarometerSource {
2037
+ private let altimeter = CMAltimeter()
2038
+ var available: Bool { CMAltimeter.isRelativeAltitudeAvailable() }
2039
+ var denied: Bool { [.denied, .restricted].contains(CMAltimeter.authorizationStatus()) }
2040
+ func start(_ receive: @escaping (Result<CraftBarometerSample, Error>) -> Void) throws {
2041
+ altimeter.startRelativeAltitudeUpdates(to: .main) { data, error in
2042
+ if let error { receive(.failure(error)); return }
2043
+ guard let data else { return }
2044
+ receive(.success(CraftBarometerSample(pressureHpa: data.pressure.doubleValue * 10,
2045
+ relativeAltitudeMeters: data.relativeAltitude.doubleValue, timestampMs: data.timestamp * 1000)))
2046
+ }
2047
+ }
2048
+ func stop() { altimeter.stopRelativeAltitudeUpdates() }
2049
+ }
2050
+ #endif
2051
+
2052
+ private final class CraftUnavailableBarometer: CraftBarometerSource {
2053
+ var available: Bool { false }
2054
+ var denied: Bool { false }
2055
+ func start(_ receive: @escaping (Result<CraftBarometerSample, Error>) -> Void) throws {}
2056
+ func stop() {}
2057
+ }
2058
+
2059
+ final class CraftNativeBarometer {
2060
+ private final class Stream {
2061
+ let source: CraftBarometerSource
2062
+ var lastTimestamp: Double?
2063
+ init(_ source: CraftBarometerSource) { self.source = source }
2064
+ }
2065
+ private let makeSource: () -> CraftBarometerSource
2066
+ private var stream: Stream?
2067
+ private var closed = false
2068
+ var emit: ([String: Any]) -> Void = { _ in }
2069
+
2070
+ init(makeSource: (() -> CraftBarometerSource)? = nil) {
2071
+ self.makeSource = makeSource ?? {
2072
+ #if CRAFT_PERMISSION_MOTION
2073
+ return CraftCoreMotionBarometer()
2074
+ #else
2075
+ return CraftUnavailableBarometer()
2076
+ #endif
2077
+ }
2078
+ }
2079
+
2080
+ func perform(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
2081
+ guard !closed else { return failure("HOST_CLOSED", "Barometer host is closed") }
2082
+ switch method {
2083
+ case "getAvailability":
2084
+ let source = makeSource()
2085
+ return .success(["relativeAltitude": source.available, "absoluteAltitude": false,
2086
+ "background": false, "permissionDenied": source.denied])
2087
+ case "start": return start(args)
2088
+ case "stop": return .success(stop(reason: "requested"))
2089
+ default: return failure("UNKNOWN_ACTION", "Unsupported Barometer method")
2090
+ }
2091
+ }
2092
+
2093
+ private func start(_ args: [Any]) -> Result<Any, CraftNativeActionError> {
2094
+ guard args.isEmpty || args.first is [String: Any] else { return failure("INVALID_ARGUMENT", "Barometer.start needs an options object") }
2095
+ let options = args.first as? [String: Any] ?? [:]
2096
+ if let background = options["background"] {
2097
+ guard let flag = background as? NSNumber, CFGetTypeID(flag) == CFBooleanGetTypeID() else {
2098
+ return failure("INVALID_ARGUMENT", "background must be boolean")
2099
+ }
2100
+ if flag.boolValue { return failure("UNSUPPORTED", "Background barometer recording is not supported") }
2101
+ }
2102
+ let value = options["intervalMs"] ?? 100
2103
+ guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(),
2104
+ number.doubleValue.isFinite, number.doubleValue >= 5, number.doubleValue <= 60000,
2105
+ number.doubleValue.rounded() == number.doubleValue else {
2106
+ return failure("INVALID_ARGUMENT", "intervalMs must be an integer from 5 through 60000")
2107
+ }
2108
+ let source = makeSource()
2109
+ guard source.available else { return failure("UNAVAILABLE", "Barometer is not present") }
2110
+ guard !source.denied else { return failure("PERMISSION_DENIED", "Motion access was denied or restricted") }
2111
+ let next = Stream(source)
2112
+ do {
2113
+ try source.start { [weak self, weak next] result in
2114
+ guard let self, let next, !self.closed, self.stream === next else { return }
2115
+ switch result {
2116
+ case .failure:
2117
+ self.stop(reason: next.source.denied ? "permissionDenied" : "sensorError")
2118
+ case .success(let sample):
2119
+ guard sample.pressureHpa.isFinite, sample.pressureHpa > 0,
2120
+ sample.relativeAltitudeMeters.isFinite, sample.timestampMs.isFinite else {
2121
+ self.stop(reason: "sensorError")
2122
+ return
2123
+ }
2124
+ // Core Motion chooses its own cadence. A requested slower
2125
+ // output rate never invents or interpolates sensor readings.
2126
+ if let last = next.lastTimestamp, sample.timestampMs - last < number.doubleValue { return }
2127
+ next.lastTimestamp = sample.timestampMs
2128
+ self.emit(["state": "sample", "pressureHpa": sample.pressureHpa,
2129
+ "relativeAltitudeMeters": sample.relativeAltitudeMeters, "timestampMs": sample.timestampMs])
2130
+ }
2131
+ }
2132
+ } catch {
2133
+ source.stop()
2134
+ return failure("SENSOR_ERROR", error.localizedDescription)
2135
+ }
2136
+ let previous = stream
2137
+ stream = next
2138
+ previous?.source.stop()
2139
+ return .success(true)
2140
+ }
2141
+
2142
+ @discardableResult func stop(reason: String) -> Bool {
2143
+ guard let previous = stream else { return false }
2144
+ stream = nil
2145
+ previous.source.stop()
2146
+ emit(["state": "stopped", "reason": reason])
2147
+ return true
2148
+ }
2149
+ func close() { closed = true; stop(reason: "disposed"); emit = { _ in } }
2150
+ deinit { stream?.source.stop() }
2151
+ private func failure(_ code: String, _ message: String) -> Result<Any, CraftNativeActionError> {
2152
+ .failure(CraftNativeActionError(code: code, message: message))
2153
+ }
2154
+ }
@@ -0,0 +1,181 @@
1
+ import Foundation
2
+ #if CRAFT_PERMISSION_MOTION
3
+ import CoreMotion
4
+ #endif
5
+
6
+ struct CraftPedometerSample {
7
+ let startDate: Double
8
+ let endDate: Double
9
+ let steps: Int
10
+ var distanceMeters: Double?
11
+ var cadenceStepsPerSecond: Double?
12
+ var paceSecondsPerMeter: Double?
13
+ var averageActivePaceSecondsPerMeter: Double?
14
+ var floorsAscended: Int?
15
+ var floorsDescended: Int?
16
+
17
+ var payload: [String: Any] {
18
+ var value: [String: Any] = ["state": "sample", "kind": "interval", "startDate": startDate, "endDate": endDate, "steps": steps]
19
+ if let distanceMeters { value["distanceMeters"] = distanceMeters }
20
+ if let cadenceStepsPerSecond { value["cadenceStepsPerSecond"] = cadenceStepsPerSecond }
21
+ if let paceSecondsPerMeter { value["paceSecondsPerMeter"] = paceSecondsPerMeter }
22
+ if let averageActivePaceSecondsPerMeter { value["averageActivePaceSecondsPerMeter"] = averageActivePaceSecondsPerMeter }
23
+ if let floorsAscended { value["floorsAscended"] = floorsAscended }
24
+ if let floorsDescended { value["floorsDescended"] = floorsDescended }
25
+ return value
26
+ }
27
+ }
28
+
29
+ protocol CraftPedometerSource: AnyObject {
30
+ var availability: [String: Bool] { get }
31
+ var permission: String { get }
32
+ func start(from: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void)
33
+ func query(from: Date, to: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void)
34
+ func stop()
35
+ }
36
+
37
+ #if CRAFT_PERMISSION_MOTION
38
+ private final class CraftCoreMotionPedometer: CraftPedometerSource {
39
+ private let pedometer = CMPedometer()
40
+ var availability: [String: Bool] {
41
+ ["steps": CMPedometer.isStepCountingAvailable(), "distance": CMPedometer.isDistanceAvailable(),
42
+ "floors": CMPedometer.isFloorCountingAvailable(), "cadence": CMPedometer.isCadenceAvailable(),
43
+ "pace": CMPedometer.isPaceAvailable(), "history": CMPedometer.isStepCountingAvailable(), "background": false]
44
+ }
45
+ var permission: String {
46
+ switch CMPedometer.authorizationStatus() {
47
+ case .authorized: return "granted"
48
+ case .denied: return "denied"
49
+ case .restricted: return "restricted"
50
+ case .notDetermined: return "notDetermined"
51
+ @unknown default: return "unavailable"
52
+ }
53
+ }
54
+ private func handler(_ receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) -> CMPedometerHandler {
55
+ { data, error in
56
+ let result: Result<CraftPedometerSample, Error>
57
+ if let error { result = .failure(error) }
58
+ else if let data {
59
+ result = .success(CraftPedometerSample(startDate: data.startDate.timeIntervalSince1970 * 1000,
60
+ endDate: data.endDate.timeIntervalSince1970 * 1000, steps: data.numberOfSteps.intValue,
61
+ distanceMeters: data.distance?.doubleValue, cadenceStepsPerSecond: data.currentCadence?.doubleValue,
62
+ paceSecondsPerMeter: data.currentPace?.doubleValue, averageActivePaceSecondsPerMeter: data.averageActivePace?.doubleValue,
63
+ floorsAscended: data.floorsAscended?.intValue, floorsDescended: data.floorsDescended?.intValue))
64
+ } else { result = .failure(NSError(domain: "CraftPedometer", code: 1, userInfo: [NSLocalizedDescriptionKey: "Pedometer returned no data"])) }
65
+ DispatchQueue.main.async { receive(result) }
66
+ }
67
+ }
68
+ func start(from: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {
69
+ pedometer.startUpdates(from: from, withHandler: handler(receive))
70
+ }
71
+ func query(from: Date, to: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {
72
+ pedometer.queryPedometerData(from: from, to: to, withHandler: handler(receive))
73
+ }
74
+ func stop() { pedometer.stopUpdates() }
75
+ }
76
+ #endif
77
+
78
+ private final class CraftUnavailablePedometer: CraftPedometerSource {
79
+ var availability: [String: Bool] { ["steps": false, "distance": false, "floors": false, "cadence": false, "pace": false, "history": false, "background": false] }
80
+ var permission: String { "unavailable" }
81
+ func start(from: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {}
82
+ func query(from: Date, to: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {}
83
+ func stop() {}
84
+ }
85
+
86
+ /// Interval counts are never relabelled as Android's counter since boot.
87
+ final class CraftNativePedometer {
88
+ typealias Completion = (Result<Any, CraftNativeActionError>) -> Void
89
+ private let makeSource: () -> CraftPedometerSource
90
+ private let now: () -> Date
91
+ private var live: CraftPedometerSource?
92
+ private var pending = [String: CraftPedometerSource]()
93
+ private var closed = false
94
+ var emit: ([String: Any]) -> Void = { _ in }
95
+
96
+ init(makeSource: (() -> CraftPedometerSource)? = nil, now: @escaping () -> Date = Date.init) {
97
+ self.now = now
98
+ self.makeSource = makeSource ?? {
99
+ #if CRAFT_PERMISSION_MOTION
100
+ return CraftCoreMotionPedometer()
101
+ #else
102
+ return CraftUnavailablePedometer()
103
+ #endif
104
+ }
105
+ }
106
+
107
+ func perform(method: String, args: [Any], token: String, completion: @escaping Completion) {
108
+ guard !closed else { return completion(failure("HOST_CLOSED", "Pedometer host is closed")) }
109
+ if method == "stop" { completion(.success(stop(reason: "requested"))); return }
110
+ let source = makeSource()
111
+ if method == "getAvailability" { completion(.success(source.availability)); return }
112
+ if method == "getPermissionStatus" { completion(.success(source.permission)); return }
113
+ guard ["start", "query", "requestPermission"].contains(method) else { return completion(failure("UNKNOWN_ACTION", "Unsupported Pedometer method")) }
114
+ guard source.availability["steps"] == true else { return completion(failure("UNAVAILABLE", "Step counting is not present")) }
115
+ if method == "requestPermission", ["granted", "denied", "restricted"].contains(source.permission) {
116
+ completion(.success(source.permission)); return
117
+ }
118
+ guard !["denied", "restricted"].contains(source.permission) else { return completion(failure("PERMISSION_DENIED", "Motion access was denied or restricted")) }
119
+ guard args.isEmpty || args.first is [String: Any] else { return completion(failure("INVALID_ARGUMENT", "Pedometer options must be an object")) }
120
+ let options = args.first as? [String: Any] ?? [:]
121
+ if let background = options["background"] {
122
+ guard let flag = background as? NSNumber, CFGetTypeID(flag) == CFBooleanGetTypeID() else { return completion(failure("INVALID_ARGUMENT", "background must be boolean")) }
123
+ if flag.boolValue { return completion(failure("UNSUPPORTED", "Background pedometer streaming is not supported")) }
124
+ }
125
+ let current = now().timeIntervalSince1970 * 1000
126
+ let first: Double, last: Double
127
+ if method == "requestPermission" { first = current - 1000; last = current }
128
+ else {
129
+ guard let start = date(options["startDate"] ?? (method == "start" ? current : NSNull())),
130
+ let end = date(options["endDate"] ?? current), start >= current - 7 * 86400 * 1000,
131
+ start <= current, end <= current, (method == "start" ? start <= end : start < end) else {
132
+ return completion(failure("INVALID_ARGUMENT", "Dates must describe an interval within the last seven days"))
133
+ }
134
+ first = start; last = end
135
+ }
136
+ if method == "start" {
137
+ let previous = live
138
+ live = source
139
+ source.start(from: Date(timeIntervalSince1970: first / 1000)) { [weak self, weak source] result in
140
+ guard let self, let source, !self.closed, self.live === source else { return }
141
+ switch result {
142
+ case .success(let sample): self.emit(sample.payload)
143
+ case .failure: self.stop(reason: ["denied", "restricted"].contains(source.permission) ? "permissionDenied" : "sensorError")
144
+ }
145
+ }
146
+ previous?.stop()
147
+ completion(.success(true))
148
+ } else {
149
+ pending[token] = source
150
+ source.query(from: Date(timeIntervalSince1970: first / 1000), to: Date(timeIntervalSince1970: last / 1000)) { [weak self, weak source] result in
151
+ guard let self, let source, !self.closed, self.pending[token] === source else { return }
152
+ self.pending.removeValue(forKey: token)
153
+ if method == "requestPermission", ["granted", "denied", "restricted"].contains(source.permission) {
154
+ completion(.success(source.permission)); return
155
+ }
156
+ switch result {
157
+ case .success(let sample): completion(.success(method == "requestPermission" ? source.permission as Any : sample.payload))
158
+ case .failure(let error): completion(self.failure(["denied", "restricted"].contains(source.permission) ? "PERMISSION_DENIED" : "SENSOR_ERROR", error.localizedDescription))
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ private func date(_ value: Any) -> Double? {
165
+ guard let value = value as? NSNumber, CFGetTypeID(value) != CFBooleanGetTypeID(), value.doubleValue.isFinite else { return nil }
166
+ return value.doubleValue
167
+ }
168
+ func cancel(_ token: String) { pending.removeValue(forKey: token) }
169
+ @discardableResult func stop(reason: String) -> Bool {
170
+ guard let previous = live else { return false }
171
+ live = nil
172
+ previous.stop()
173
+ emit(["state": "stopped", "reason": reason])
174
+ return true
175
+ }
176
+ func close() { closed = true; pending.removeAll(); stop(reason: "disposed"); emit = { _ in } }
177
+ deinit { live?.stop() }
178
+ private func failure(_ code: String, _ message: String) -> Result<Any, CraftNativeActionError> {
179
+ .failure(CraftNativeActionError(code: code, message: message))
180
+ }
181
+ }
@@ -6191,6 +6191,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6191
6191
  private var inputIdentities: [ObjectIdentifier: String] = [:]
6192
6192
  private var inputDrafts: [String: String] = [:]
6193
6193
  private var longPressHandlers: [ObjectIdentifier: String] = [:]
6194
+ private var accessibilityActionHandlers: [ObjectIdentifier: String] = [:]
6194
6195
  private var textMaxLengths: [ObjectIdentifier: Int] = [:]
6195
6196
  private var scrollHandlers: [ObjectIdentifier: String] = [:]
6196
6197
  private var scrollBeginHandlers: [ObjectIdentifier: String] = [:]
@@ -6259,6 +6260,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6259
6260
  private let audio = CraftWebAudio()
6260
6261
  /// `craft.audio`'s sounds and the streaming responses this screen reads.
6261
6262
  private let sounds = CraftNativeSounds()
6263
+ private let barometer = CraftNativeBarometer()
6264
+ private let pedometer = CraftNativePedometer()
6262
6265
  private let streams = CraftNativeEventStreams()
6263
6266
  /// A request a screen-owned module answers, by request token, and how to
6264
6267
  /// call it off when it times out or is cancelled.
@@ -6595,6 +6598,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6595
6598
  }
6596
6599
  audio.close()
6597
6600
  sounds.close()
6601
+ barometer.close()
6602
+ pedometer.close()
6598
6603
  streams.close()
6599
6604
  screenRequestCancellers.values.forEach { $0() }
6600
6605
  screenRequestCancellers.removeAll()
@@ -8037,6 +8042,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8037
8042
  guard let name = notification.userInfo?["event"] as? String else { return }
8038
8043
  self?.send(type: "CRAFT_EVENT", payload: ["event": name, "detail": notification.userInfo?["detail"] ?? [:]])
8039
8044
  })
8045
+ pedometer.emit = { [weak self] detail in self?.send(type: "CRAFT_EVENT", payload: ["event": "craftPedometerUpdate", "detail": detail]) }
8046
+ barometer.emit = { [weak self] detail in self?.send(type: "CRAFT_EVENT", payload: ["event": "craftBarometerUpdate", "detail": detail]) }
8040
8047
  sounds.emit = { [weak self] payload in self?.send(type: "SOUND_EVENT", payload: payload) }
8041
8048
  streams.emit = { [weak self] payload in self?.send(type: "NETWORK_STREAM", payload: payload) }
8042
8049
  sendAppState(CraftNativeActions.currentAppState())
@@ -8201,6 +8208,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8201
8208
  }
8202
8209
 
8203
8210
  private func sendAppState(_ state: String) {
8211
+ if state != "active" { barometer.stop(reason: "background"); pedometer.stop(reason: "background") }
8204
8212
  // A screen hears it while it is on top of its stack; the app script,
8205
8213
  // which is on no stack, always.
8206
8214
  guard isAppScript || navigationController?.topViewController === self else { return }
@@ -8412,6 +8420,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8412
8420
  }
8413
8421
  let id = args.first as? String ?? ""
8414
8422
  switch (module, method) {
8423
+ case ("Pedometer", _):
8424
+ if !config.enablePedometer {
8425
+ reply(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Pedometer is disabled")))
8426
+ } else if isAppScript {
8427
+ reply(.failure(CraftNativeActionError(code: "NEEDS_ACTIVITY", message: "Pedometer needs a visible app")))
8428
+ } else if ["start", "requestPermission"].contains(method) && UIApplication.shared.applicationState != .active {
8429
+ reply(.failure(CraftNativeActionError(code: "APP_INACTIVE", message: "Pedometer needs a foreground app")))
8430
+ } else {
8431
+ screenRequestCancellers[requestToken] = { [weak self] in self?.pedometer.cancel(requestToken) }
8432
+ pedometer.perform(method: method, args: args, token: requestToken, completion: reply)
8433
+ }
8434
+ return true
8435
+ case ("Barometer", _):
8436
+ if !config.enableBarometer {
8437
+ reply(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Barometer is disabled")))
8438
+ } else if isAppScript {
8439
+ reply(.failure(CraftNativeActionError(code: "NEEDS_ACTIVITY", message: "Barometer needs a visible app")))
8440
+ } else if method == "start" && UIApplication.shared.applicationState != .active {
8441
+ reply(.failure(CraftNativeActionError(code: "APP_INACTIVE", message: "Barometer needs a foreground app")))
8442
+ } else {
8443
+ reply(barometer.perform(method: method, args: args))
8444
+ }
8445
+ return true
8415
8446
  case ("AudioRecording", _):
8416
8447
  guard config.enableAudioRecording else {
8417
8448
  reply(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Audio recording is disabled")))
@@ -9400,6 +9431,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9400
9431
  }
9401
9432
  result.craftPointerEvents = CraftNativePointerEvents.resolve(props: props, style: style)
9402
9433
  applyAccessibility(props, type: type, to: result)
9434
+ // After applyAccessibility: it settles whether the view is an element
9435
+ // at all, and an action on a non-element is unreachable.
9436
+ updateAccessibilityActions(props["accessibilityActions"], handler: props["disabled"] as? Bool == true ? nil : events["onAccessibilityAction"], for: result)
9403
9437
  (result as? CraftNativeFlowView)?.configureCelebration(props["celebration"])
9404
9438
  return current
9405
9439
  }
@@ -9882,6 +9916,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9882
9916
  inputEventCounts.removeValue(forKey: id)
9883
9917
  if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
9884
9918
  longPressHandlers.removeValue(forKey: id)
9919
+ accessibilityActionHandlers.removeValue(forKey: id)
9885
9920
  textMaxLengths.removeValue(forKey: id)
9886
9921
  scrollHandlers.removeValue(forKey: id)
9887
9922
  scrollBeginHandlers.removeValue(forKey: id)
@@ -10099,6 +10134,47 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10099
10134
  }
10100
10135
  }
10101
10136
 
10137
+ /// What a hold offers, published where the rotor can reach it.
10138
+ ///
10139
+ /// A long press is invisible to VoiceOver: nothing announces that it is
10140
+ /// there and nothing can perform it, so an action living only behind a
10141
+ /// hold does not exist for anyone driving the screen by assistive
10142
+ /// technology. A thread that holds a message for Copy and Report is the
10143
+ /// case this was written for; without this the honest mitigation is a
10144
+ /// permanently visible button under every message.
10145
+ ///
10146
+ /// Shape follows the rest of the accessibility props: `name` identifies
10147
+ /// the action to the handler, `label` is what a person hears, and the
10148
+ /// event says which one ran so one handler can serve them all.
10149
+ private func updateAccessibilityActions(_ raw: Any?, handler: String?, for view: UIView) {
10150
+ let id = ObjectIdentifier(view)
10151
+ let entries = (raw as? [[String: Any]] ?? []).compactMap { entry -> (name: String, label: String)? in
10152
+ guard let name = entry["name"] as? String, !name.isEmpty else { return nil }
10153
+ return (name, (entry["label"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? name)
10154
+ }
10155
+ guard let handler, !handler.isEmpty, !entries.isEmpty else {
10156
+ accessibilityActionHandlers.removeValue(forKey: id)
10157
+ view.accessibilityCustomActions = nil
10158
+ return
10159
+ }
10160
+ accessibilityActionHandlers[id] = handler
10161
+ view.accessibilityCustomActions = entries.map { entry in
10162
+ UIAccessibilityCustomAction(name: entry.label) { [weak self, weak view] _ in
10163
+ // Read the handler at firing time: a reconcile can rename it
10164
+ // while the rotor sits on the element.
10165
+ guard let self, let view, let current = self.accessibilityActionHandlers[ObjectIdentifier(view)] else { return false }
10166
+ self.send(type: "EVENT", payload: ["handlerName": current, "nativeEvent": ["actionName": entry.name]])
10167
+ return true
10168
+ }
10169
+ }
10170
+ // The rotor only lands on an element, and actions on a view it walks
10171
+ // straight through are actions nobody can get to. A bubble whose own
10172
+ // label is its text is exactly that case.
10173
+ if !view.isAccessibilityElement && view.accessibilityElements == nil && view.accessibilityLabel != nil {
10174
+ view.isAccessibilityElement = true
10175
+ }
10176
+ }
10177
+
10102
10178
  /// Legacy runtimes echo values without a revision. Keep their bounded
10103
10179
  /// history on either script thread; bridge delivery may still be delayed.
10104
10180
  private var unechoedText: [ObjectIdentifier: [String]] = [:]