@craft-native/ios 0.0.435 → 0.0.442
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 +26 -2
- package/dist/index.d.ts +10 -0
- package/dist/index.js +28 -2
- package/package.json +1 -1
- package/templates/CraftApp.swift +14 -0
- package/templates/CraftHealth.swift +2 -1
- package/templates/CraftHealthBackground.swift +308 -0
- package/templates/CraftNativeActions.swift +153 -0
- package/templates/CraftNativeMutation.swift +18 -3
- package/templates/CraftNativePedometer.swift +181 -0
- package/templates/CraftNativeScreen.swift +76 -0
- package/templates/testing/DriverTests.swift.template +24 -4
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>
|
|
@@ -1145,6 +1166,8 @@ function writeShell(output, config) {
|
|
|
1145
1166
|
writeSource(join4(output, "Sources", "CraftNativeScreen.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeScreen.swift"), "utf8"));
|
|
1146
1167
|
writeSource(join4(output, "Sources", "CraftNativeFlatList.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeFlatList.swift"), "utf8"));
|
|
1147
1168
|
writeSource(join4(output, "Sources", "CraftNativeActions.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeActions.swift"), "utf8"));
|
|
1169
|
+
writeSource(join4(output, "Sources", "CraftNativePedometer.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativePedometer.swift"), "utf8"));
|
|
1170
|
+
writeSource(join4(output, "Sources", "CraftHealthBackground.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftHealthBackground.swift"), "utf8"));
|
|
1148
1171
|
writeSource(join4(output, "Sources", "CraftHealth.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftHealth.swift"), "utf8"));
|
|
1149
1172
|
writeSource(join4(output, "Sources", "CraftMusic.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftMusic.swift"), "utf8"));
|
|
1150
1173
|
writeSource(join4(output, "Sources", "CraftBluetooth.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftBluetooth.swift"), "utf8"));
|
|
@@ -1211,6 +1234,7 @@ async function init(options) {
|
|
|
1211
1234
|
teamId: teamId || "",
|
|
1212
1235
|
...given
|
|
1213
1236
|
};
|
|
1237
|
+
validateHealthKitBackground(config);
|
|
1214
1238
|
if (config.renderer !== "web" && config.renderer !== "native") {
|
|
1215
1239
|
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
1216
1240
|
}
|
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;
|
|
@@ -361,6 +369,8 @@ export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer
|
|
|
361
369
|
* well as the one-file bundle, and the app runs each screen from its own
|
|
362
370
|
* route's file. Stacks asks for route bundles only when this is true.
|
|
363
371
|
*/
|
|
372
|
+
/** Build-time proof for Stacks capability mappings, including unreleased source builds. */
|
|
373
|
+
export declare const nativeSensorCapabilities: readonly ['barometer', 'pedometer', 'healthKitBackground'];
|
|
364
374
|
export declare const nativeRouteBundles = true;
|
|
365
375
|
/**
|
|
366
376
|
* The app evaluates stx's shared runtime (`__runtime.js`, stx's
|
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>
|
|
@@ -1358,6 +1380,8 @@ function writeShell(output, config) {
|
|
|
1358
1380
|
writeSource(join4(output, "Sources", "CraftNativeScreen.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeScreen.swift"), "utf8"));
|
|
1359
1381
|
writeSource(join4(output, "Sources", "CraftNativeFlatList.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeFlatList.swift"), "utf8"));
|
|
1360
1382
|
writeSource(join4(output, "Sources", "CraftNativeActions.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeActions.swift"), "utf8"));
|
|
1383
|
+
writeSource(join4(output, "Sources", "CraftNativePedometer.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativePedometer.swift"), "utf8"));
|
|
1384
|
+
writeSource(join4(output, "Sources", "CraftHealthBackground.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHealthBackground.swift"), "utf8"));
|
|
1361
1385
|
writeSource(join4(output, "Sources", "CraftHealth.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHealth.swift"), "utf8"));
|
|
1362
1386
|
writeSource(join4(output, "Sources", "CraftMusic.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftMusic.swift"), "utf8"));
|
|
1363
1387
|
writeSource(join4(output, "Sources", "CraftBluetooth.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftBluetooth.swift"), "utf8"));
|
|
@@ -1424,6 +1448,7 @@ async function init(options) {
|
|
|
1424
1448
|
teamId: teamId || "",
|
|
1425
1449
|
...given
|
|
1426
1450
|
};
|
|
1451
|
+
validateHealthKitBackground(config);
|
|
1427
1452
|
if (config.renderer !== "web" && config.renderer !== "native") {
|
|
1428
1453
|
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
1429
1454
|
}
|
|
@@ -1784,6 +1809,7 @@ export {
|
|
|
1784
1809
|
nativeMessages,
|
|
1785
1810
|
nativeRouteBundles,
|
|
1786
1811
|
nativeRouteRuntime,
|
|
1812
|
+
nativeSensorCapabilities,
|
|
1787
1813
|
nativeStartPath,
|
|
1788
1814
|
normalizeNativePath,
|
|
1789
1815
|
open,
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -18,10 +18,22 @@ struct CraftNativeMutationFailure: Error, Equatable {
|
|
|
18
18
|
struct CraftNativeMutationResult {
|
|
19
19
|
let batchId: String
|
|
20
20
|
let revision: Int
|
|
21
|
-
let document: [String: Any]?
|
|
22
21
|
let updatedNodeIds: [String]
|
|
23
22
|
let affectedNodeIds: [String]
|
|
24
23
|
let requiresFullRender: Bool
|
|
24
|
+
private let materialize: () -> [String: Any]?
|
|
25
|
+
/// The committed tree, built when asked for: a host that redraws only the
|
|
26
|
+
/// nodes a batch touched never needs the whole tree.
|
|
27
|
+
var document: [String: Any]? { materialize() }
|
|
28
|
+
|
|
29
|
+
init(batchId: String, revision: Int, document: @escaping () -> [String: Any]?, updatedNodeIds: [String], affectedNodeIds: [String], requiresFullRender: Bool) {
|
|
30
|
+
self.batchId = batchId
|
|
31
|
+
self.revision = revision
|
|
32
|
+
self.materialize = document
|
|
33
|
+
self.updatedNodeIds = updatedNodeIds
|
|
34
|
+
self.affectedNodeIds = affectedNodeIds
|
|
35
|
+
self.requiresFullRender = requiresFullRender
|
|
36
|
+
}
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
/// Validates and applies versioned native-tree mutations without touching UIKit.
|
|
@@ -91,6 +103,8 @@ final class CraftNativeMutationDocument {
|
|
|
91
103
|
var updatedNodeIds: [String] = []
|
|
92
104
|
var affectedNodeIds: [String] = []
|
|
93
105
|
var requiresFullRender = false
|
|
106
|
+
// Only an operation that adds, moves or removes nodes can leave one unreachable.
|
|
107
|
+
var reshaped = false
|
|
94
108
|
for (index, value) in operations.enumerated() {
|
|
95
109
|
guard let operation = value as? [String: Any], let name = operation["op"] as? String else {
|
|
96
110
|
throw CraftNativeMutationFailure(
|
|
@@ -99,6 +113,7 @@ final class CraftNativeMutationDocument {
|
|
|
99
113
|
operationIndex: index
|
|
100
114
|
)
|
|
101
115
|
}
|
|
116
|
+
if name != "updateNode" { reshaped = true }
|
|
102
117
|
do {
|
|
103
118
|
switch name {
|
|
104
119
|
case "createNode":
|
|
@@ -147,7 +162,7 @@ final class CraftNativeMutationDocument {
|
|
|
147
162
|
guard let root = candidateNodes[candidateRoot], root.parent == nil else {
|
|
148
163
|
throw CraftNativeMutationFailure("INVALID_TREE", "root node must not have a parent")
|
|
149
164
|
}
|
|
150
|
-
guard descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
|
|
165
|
+
guard !reshaped || descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
|
|
151
166
|
throw CraftNativeMutationFailure("INVALID_TREE", "all nodes must be reachable from the root")
|
|
152
167
|
}
|
|
153
168
|
}
|
|
@@ -158,7 +173,7 @@ final class CraftNativeMutationDocument {
|
|
|
158
173
|
return CraftNativeMutationResult(
|
|
159
174
|
batchId: batchId,
|
|
160
175
|
revision: nextRevision,
|
|
161
|
-
document: candidateRoot.flatMap { materialize($0, nodes: candidateNodes) },
|
|
176
|
+
document: { [candidateRoot, candidateNodes, weak self] in candidateRoot.flatMap { self?.materialize($0, nodes: candidateNodes) } },
|
|
162
177
|
updatedNodeIds: updatedNodeIds,
|
|
163
178
|
affectedNodeIds: affectedNodeIds,
|
|
164
179
|
requiresFullRender: requiresFullRender
|
|
@@ -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]] = [:]
|
|
@@ -285,8 +285,15 @@ final class DriverTests: XCTestCase {
|
|
|
285
285
|
return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
/// A
|
|
289
|
-
///
|
|
288
|
+
/// A switch reports "0" or "1". Any other value is not a switch state to
|
|
289
|
+
/// wait on, so the tap stands as it would for any other control.
|
|
290
|
+
func switchFlipped(from before: String?, to after: String?) -> Bool {
|
|
291
|
+
let states: Set<String> = ["0", "1"]
|
|
292
|
+
guard let before, let after, states.contains(before), states.contains(after) else { return true }
|
|
293
|
+
return before != after
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// Deliver one gesture; the command's observation determines success.
|
|
290
297
|
func press(_ element: XCUIElement) {
|
|
291
298
|
if element.isHittable { element.tap() }
|
|
292
299
|
else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
|
|
@@ -438,8 +445,21 @@ final class DriverTests: XCTestCase {
|
|
|
438
445
|
else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
|
|
439
446
|
case "tapid":
|
|
440
447
|
let target = identified(arg)
|
|
441
|
-
if target.waitForExistence(timeout: 10) {
|
|
442
|
-
else
|
|
448
|
+
if !target.waitForExistence(timeout: 10) { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
|
|
449
|
+
else if target.elementType == .switch {
|
|
450
|
+
// Observe one physical gesture. Retapping could conceal a
|
|
451
|
+
// renderer or hit-testing failure (HQ agreement job 7570).
|
|
452
|
+
let before = target.value as? String
|
|
453
|
+
press(target)
|
|
454
|
+
let settle = Date().addingTimeInterval(1.5)
|
|
455
|
+
var flipped = false
|
|
456
|
+
repeat {
|
|
457
|
+
flipped = switchFlipped(from: before, to: target.value as? String)
|
|
458
|
+
if !flipped { Thread.sleep(forTimeInterval: 0.25) }
|
|
459
|
+
} while !flipped && Date() < settle
|
|
460
|
+
check(flipped, "tapid \(arg) flips the switch")
|
|
461
|
+
}
|
|
462
|
+
else { press(target); note("tapid \(arg): ok") }
|
|
443
463
|
case "longpressid":
|
|
444
464
|
let target = identified(arg)
|
|
445
465
|
guard target.waitForExistence(timeout: 10), target.isHittable else {
|