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