@craft-native/ios 0.0.247 → 0.0.248
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +37 -2
- package/templates/CraftHealth.swift +217 -0
- package/templates/CraftHeartRate.swift +6 -3
- package/templates/CraftHybrid.swift +18 -19
- 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 +813 -65
- 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
|
@@ -1574,6 +1574,8 @@ struct CraftConfig: Codable {
|
|
|
1574
1574
|
var enableBluetooth: Bool = false
|
|
1575
1575
|
var enableNFC: Bool = false
|
|
1576
1576
|
var enableHealthKit: Bool = false
|
|
1577
|
+
/// Apple Wallet pass presentation and lookup for native screens.
|
|
1578
|
+
var enableWallet: Bool = false
|
|
1577
1579
|
var enableLiveActivities: Bool = false
|
|
1578
1580
|
var enableWatchApp: Bool = false
|
|
1579
1581
|
var enableBackgroundLocation: Bool = false
|
|
@@ -2168,6 +2170,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2168
2170
|
self.sendToWeb("craftHealthHeartRate", data: detail)
|
|
2169
2171
|
return true
|
|
2170
2172
|
}
|
|
2173
|
+
CraftHealth.shared.workoutSink = { [weak self] detail in
|
|
2174
|
+
guard let self else { return false }
|
|
2175
|
+
self.sendToWeb("craftHealthWorkout", data: detail)
|
|
2176
|
+
return true
|
|
2177
|
+
}
|
|
2171
2178
|
}
|
|
2172
2179
|
if config.enableHeartRate {
|
|
2173
2180
|
// The strap's readings and connection changes reach the page,
|
|
@@ -3061,6 +3068,30 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3061
3068
|
} else {
|
|
3062
3069
|
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
3063
3070
|
}
|
|
3071
|
+
case "startHealthWorkout":
|
|
3072
|
+
if config.enableHealthKit {
|
|
3073
|
+
CraftHealth.shared.startWorkout(body: body) { [weak self] in self?.answer(callbackId, $0) }
|
|
3074
|
+
} else {
|
|
3075
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
3076
|
+
}
|
|
3077
|
+
case "pauseHealthWorkout":
|
|
3078
|
+
if config.enableHealthKit {
|
|
3079
|
+
CraftHealth.shared.pauseWorkout { [weak self] in self?.answer(callbackId, $0) }
|
|
3080
|
+
} else {
|
|
3081
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
3082
|
+
}
|
|
3083
|
+
case "resumeHealthWorkout":
|
|
3084
|
+
if config.enableHealthKit {
|
|
3085
|
+
CraftHealth.shared.resumeWorkout { [weak self] in self?.answer(callbackId, $0) }
|
|
3086
|
+
} else {
|
|
3087
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
3088
|
+
}
|
|
3089
|
+
case "endHealthWorkout":
|
|
3090
|
+
if config.enableHealthKit {
|
|
3091
|
+
CraftHealth.shared.endWorkout { [weak self] in self?.answer(callbackId, $0) }
|
|
3092
|
+
} else {
|
|
3093
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
3094
|
+
}
|
|
3064
3095
|
|
|
3065
3096
|
// MARK: - Live Activities
|
|
3066
3097
|
case "isLiveActivityAvailable":
|
|
@@ -5648,7 +5679,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5648
5679
|
var since = options && typeof options.since === 'number' ? options.since : null;
|
|
5649
5680
|
return craft._invoke('startHealthHeartRateUpdates', {since: since});
|
|
5650
5681
|
},
|
|
5651
|
-
stopHeartRateUpdates: function() { return craft._invoke('stopHealthHeartRateUpdates'); }
|
|
5682
|
+
stopHeartRateUpdates: function() { return craft._invoke('stopHealthHeartRateUpdates'); },
|
|
5683
|
+
startWorkout: function(options) { return craft._invoke('startHealthWorkout', options || {}); },
|
|
5684
|
+
pauseWorkout: function() { return craft._invoke('pauseHealthWorkout'); },
|
|
5685
|
+
resumeWorkout: function() { return craft._invoke('resumeHealthWorkout'); },
|
|
5686
|
+
endWorkout: function() { return craft._invoke('endHealthWorkout'); }
|
|
5652
5687
|
};
|
|
5653
5688
|
// The Music app's song and transport. Nothing is read until
|
|
5654
5689
|
// requestAuthorization() has been allowed; observe(true) makes
|
|
@@ -9340,7 +9375,7 @@ final class CraftZigRuntime: NSObject {
|
|
|
9340
9375
|
enum CraftWebServices {
|
|
9341
9376
|
private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
|
|
9342
9377
|
/// Coordinator events a native screen hears too.
|
|
9343
|
-
static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftMusicChange", "craftHeartRate"]
|
|
9378
|
+
static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftHealthWorkout", "craftMusicChange", "craftHeartRate"]
|
|
9344
9379
|
static let event = Notification.Name("craftWebServicesEvent")
|
|
9345
9380
|
|
|
9346
9381
|
static var isAvailable: Bool { CraftSwiftShim.coordinator != nil }
|
|
@@ -9,6 +9,40 @@ struct CraftHealthError: Error {
|
|
|
9
9
|
var code: String = "CRAFT_ERROR"
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/// A separate delegate keeps the iOS 17+ HealthKit session types out of the
|
|
13
|
+
/// iOS 16-compatible CraftHealth declaration. The owning service retains it
|
|
14
|
+
/// for the lifetime of the session.
|
|
15
|
+
@available(iOS 17.0, *)
|
|
16
|
+
private final class CraftHealthWorkoutDelegate: NSObject, HKWorkoutSessionDelegate {
|
|
17
|
+
private let id: () -> String
|
|
18
|
+
private let emit: ([String: Any]) -> Void
|
|
19
|
+
|
|
20
|
+
init(id: @escaping () -> String, emit: @escaping ([String: Any]) -> Void) {
|
|
21
|
+
self.id = id
|
|
22
|
+
self.emit = emit
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
|
|
26
|
+
emit(["id": id(), "event": "state", "state": Self.stateName(toState), "date": date.timeIntervalSince1970 * 1000])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
|
|
30
|
+
emit(["id": id(), "event": "error", "message": error.localizedDescription])
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private static func stateName(_ state: HKWorkoutSessionState) -> String {
|
|
34
|
+
switch state {
|
|
35
|
+
case .notStarted: return "notStarted"
|
|
36
|
+
case .prepared: return "prepared"
|
|
37
|
+
case .running: return "running"
|
|
38
|
+
case .paused: return "paused"
|
|
39
|
+
case .stopped: return "stopped"
|
|
40
|
+
case .ended: return "ended"
|
|
41
|
+
@unknown default: return "unknown"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
12
46
|
/// Apple Health for the whole app: the web page's bridge (`craft.health` in
|
|
13
47
|
/// the hybrid's web view, CraftApp.swift) and native screens alike
|
|
14
48
|
/// (`Health` through CraftNativeActions), so a screen drawn natively reads
|
|
@@ -69,6 +103,14 @@ final class CraftHealth {
|
|
|
69
103
|
startHeartRateUpdates(since: options(0)["since"] as? Double, completion: completion)
|
|
70
104
|
case "stopHeartRateUpdates":
|
|
71
105
|
stopHeartRateUpdates(completion: completion)
|
|
106
|
+
case "startWorkout":
|
|
107
|
+
startWorkout(body: options(0), completion: completion)
|
|
108
|
+
case "pauseWorkout":
|
|
109
|
+
pauseWorkout(completion: completion)
|
|
110
|
+
case "resumeWorkout":
|
|
111
|
+
resumeWorkout(completion: completion)
|
|
112
|
+
case "endWorkout":
|
|
113
|
+
endWorkout(completion: completion)
|
|
72
114
|
default:
|
|
73
115
|
completion(.failure(CraftHealthError(message: "Unsupported Health method", code: "UNKNOWN_ACTION")))
|
|
74
116
|
}
|
|
@@ -745,6 +787,166 @@ final class CraftHealth {
|
|
|
745
787
|
healthStore.execute(query)
|
|
746
788
|
}
|
|
747
789
|
|
|
790
|
+
// MARK: Live workout sessions
|
|
791
|
+
|
|
792
|
+
/// Starts an OS-owned workout session. HealthKit's live builder was
|
|
793
|
+
/// introduced for iOS 26; older systems receive a clear unsupported
|
|
794
|
+
/// result instead of silently recording a post-hoc workout.
|
|
795
|
+
func startWorkout(body: [String: Any], completion rawCompletion: @escaping Completion) {
|
|
796
|
+
let completion = completeOnMain(rawCompletion)
|
|
797
|
+
guard let type = body["type"] as? String, !type.isEmpty else {
|
|
798
|
+
completion(.failure(CraftHealthError(message: "startWorkout needs a workout type", code: "INVALID_ARGUMENT")))
|
|
799
|
+
return
|
|
800
|
+
}
|
|
801
|
+
guard healthStore != nil else {
|
|
802
|
+
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
guard liveWorkoutID == nil else {
|
|
806
|
+
completion(.failure(CraftHealthError(message: "A live workout is already running", code: "WORKOUT_IN_PROGRESS")))
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
guard #available(iOS 26.0, *) else {
|
|
810
|
+
completion(.failure(CraftHealthError(message: "Live workouts require iOS 26 or later", code: "UNSUPPORTED")))
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
startWorkout26(type: type, location: body["location"] as? String, completion: completion)
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
@available(iOS 26.0, *)
|
|
817
|
+
private func startWorkout26(type: String, location: String?, completion: @escaping Completion) {
|
|
818
|
+
guard let healthStore else {
|
|
819
|
+
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
guard let activity = Self.workoutActivity(type) else {
|
|
823
|
+
completion(.failure(CraftHealthError(message: "Unsupported workout type", code: "INVALID_ARGUMENT")))
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
let configuration = HKWorkoutConfiguration()
|
|
827
|
+
configuration.activityType = activity
|
|
828
|
+
switch location {
|
|
829
|
+
case "indoor": configuration.locationType = .indoor
|
|
830
|
+
case "outdoor": configuration.locationType = .outdoor
|
|
831
|
+
case nil, "unknown": configuration.locationType = .unknown
|
|
832
|
+
default:
|
|
833
|
+
completion(.failure(CraftHealthError(message: "location must be indoor, outdoor, or unknown", code: "INVALID_ARGUMENT")))
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
do {
|
|
837
|
+
let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
|
|
838
|
+
let builder = session.associatedWorkoutBuilder()
|
|
839
|
+
let delegate = CraftHealthWorkoutDelegate(id: { [weak self] in self?.liveWorkoutID ?? "" }) { [weak self] detail in self?.sendWorkout(detail) }
|
|
840
|
+
session.delegate = delegate
|
|
841
|
+
builder.shouldCollectWorkoutEvents = true
|
|
842
|
+
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
|
|
843
|
+
liveWorkoutSession = session
|
|
844
|
+
liveWorkoutBuilder = builder
|
|
845
|
+
liveWorkoutDelegate = delegate
|
|
846
|
+
liveWorkoutID = UUID().uuidString.lowercased()
|
|
847
|
+
liveWorkoutType = type
|
|
848
|
+
liveWorkoutStart = Date()
|
|
849
|
+
let id = liveWorkoutID!
|
|
850
|
+
let start = liveWorkoutStart!
|
|
851
|
+
session.startActivity(with: start)
|
|
852
|
+
builder.beginCollection(withStart: start) { [weak self] success, error in
|
|
853
|
+
DispatchQueue.main.async {
|
|
854
|
+
guard let self else { return }
|
|
855
|
+
guard success else {
|
|
856
|
+
self.clearLiveWorkout()
|
|
857
|
+
completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Live workout could not start")))
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
completion(.success(["id": id, "type": type, "startDate": start.timeIntervalSince1970 * 1000]))
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
} catch {
|
|
864
|
+
clearLiveWorkout()
|
|
865
|
+
completion(.failure(CraftHealthError(message: error.localizedDescription, code: "INVALID_ARGUMENT")))
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
func pauseWorkout(completion rawCompletion: @escaping Completion) {
|
|
870
|
+
let completion = completeOnMain(rawCompletion)
|
|
871
|
+
guard #available(iOS 26.0, *), let session = liveWorkoutSession as? HKWorkoutSession else {
|
|
872
|
+
completion(.failure(CraftHealthError(message: "No live workout is running", code: "NO_WORKOUT")))
|
|
873
|
+
return
|
|
874
|
+
}
|
|
875
|
+
session.pause()
|
|
876
|
+
completion(.success(true))
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
func resumeWorkout(completion rawCompletion: @escaping Completion) {
|
|
880
|
+
let completion = completeOnMain(rawCompletion)
|
|
881
|
+
guard #available(iOS 26.0, *), let session = liveWorkoutSession as? HKWorkoutSession else {
|
|
882
|
+
completion(.failure(CraftHealthError(message: "No live workout is running", code: "NO_WORKOUT")))
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
session.resume()
|
|
886
|
+
completion(.success(true))
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
func endWorkout(completion rawCompletion: @escaping Completion) {
|
|
890
|
+
let completion = completeOnMain(rawCompletion)
|
|
891
|
+
guard #available(iOS 26.0, *), let session = liveWorkoutSession as? HKWorkoutSession, let builder = liveWorkoutBuilder as? HKLiveWorkoutBuilder, let id = liveWorkoutID else {
|
|
892
|
+
completion(.failure(CraftHealthError(message: "No live workout is running", code: "NO_WORKOUT")))
|
|
893
|
+
return
|
|
894
|
+
}
|
|
895
|
+
let end = Date()
|
|
896
|
+
session.end()
|
|
897
|
+
builder.endCollection(withEnd: end) { [weak self] success, error in
|
|
898
|
+
guard let self else { return }
|
|
899
|
+
guard success else {
|
|
900
|
+
DispatchQueue.main.async { completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Live workout could not end"))) }
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
builder.finishWorkout { [weak self] workout, finishError in
|
|
904
|
+
DispatchQueue.main.async {
|
|
905
|
+
guard let self else { return }
|
|
906
|
+
defer { self.clearLiveWorkout() }
|
|
907
|
+
guard let workout else {
|
|
908
|
+
completion(.failure(CraftHealthError(message: finishError?.localizedDescription ?? "Live workout could not be saved")))
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
completion(.success([
|
|
912
|
+
"id": id,
|
|
913
|
+
"workoutId": workout.uuid.uuidString,
|
|
914
|
+
"endDate": end.timeIntervalSince1970 * 1000,
|
|
915
|
+
"durationSeconds": workout.duration,
|
|
916
|
+
]))
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
private func clearLiveWorkout() {
|
|
923
|
+
liveWorkoutSession = nil
|
|
924
|
+
liveWorkoutBuilder = nil
|
|
925
|
+
liveWorkoutDelegate = nil
|
|
926
|
+
liveWorkoutID = nil
|
|
927
|
+
liveWorkoutType = nil
|
|
928
|
+
liveWorkoutStart = nil
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private static func workoutActivity(_ type: String) -> HKWorkoutActivityType? {
|
|
932
|
+
switch type {
|
|
933
|
+
case "running": return .running
|
|
934
|
+
case "walking": return .walking
|
|
935
|
+
case "hiking": return .hiking
|
|
936
|
+
case "cycling": return .cycling
|
|
937
|
+
case "swimming": return .swimming
|
|
938
|
+
case "rowing": return .rowing
|
|
939
|
+
case "elliptical": return .elliptical
|
|
940
|
+
case "strength": return .traditionalStrengthTraining
|
|
941
|
+
case "hiit": return .highIntensityIntervalTraining
|
|
942
|
+
default: return nil
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
private func sendWorkout(_ detail: [String: Any]) {
|
|
947
|
+
if workoutSink?(detail) != true { CraftWebServices.forward("craftHealthWorkout", data: detail) }
|
|
948
|
+
}
|
|
949
|
+
|
|
748
950
|
// MARK: Heart rate as it arrives
|
|
749
951
|
|
|
750
952
|
/// The web view's coordinator, which sends `craftHealthHeartRate` to the
|
|
@@ -752,6 +954,21 @@ final class CraftHealth {
|
|
|
752
954
|
/// view) and the event goes to the native screens alone.
|
|
753
955
|
var pageSink: (([String: Any]) -> Bool)?
|
|
754
956
|
|
|
957
|
+
/// Live workout lifecycle events use a separate page event so existing
|
|
958
|
+
/// heart-rate listeners remain unchanged. When no page owns the event,
|
|
959
|
+
/// CraftWebServices forwards it to native screens.
|
|
960
|
+
var workoutSink: (([String: Any]) -> Bool)?
|
|
961
|
+
|
|
962
|
+
// Stored as object references because the generated app still supports
|
|
963
|
+
// iOS 16.2; concrete HealthKit types are only touched behind the iOS 26
|
|
964
|
+
// availability checks below.
|
|
965
|
+
private var liveWorkoutSession: AnyObject?
|
|
966
|
+
private var liveWorkoutBuilder: AnyObject?
|
|
967
|
+
private var liveWorkoutDelegate: AnyObject?
|
|
968
|
+
private var liveWorkoutID: String?
|
|
969
|
+
private var liveWorkoutType: String?
|
|
970
|
+
private var liveWorkoutStart: Date?
|
|
971
|
+
|
|
755
972
|
/// The one running stream: an anchored query that hears every heart-rate
|
|
756
973
|
/// sample HealthKit receives after it starts.
|
|
757
974
|
private var heartRateQuery: HKAnchoredObjectQuery?
|
|
@@ -235,7 +235,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
235
235
|
}
|
|
236
236
|
if var archived = try CraftRecordingArchive.read(self.directory, "heart-rate", id) {
|
|
237
237
|
if method == "readRecording" {
|
|
238
|
-
let since = (options["since"]
|
|
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
|
}
|
|
@@ -264,8 +264,8 @@ protocol CraftHybridScreenEvents: AnyObject {
|
|
|
264
264
|
func nativeScreenDidRender(_ screen: CraftNativeScreenController)
|
|
265
265
|
/// The screen could not draw its first frame: no bundle, or its script threw.
|
|
266
266
|
func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String)
|
|
267
|
-
/// `craft.navigation.open(path)`.
|
|
268
|
-
func nativeScreen(_ screen: CraftNativeScreenController, open path: String)
|
|
267
|
+
/// `craft.navigation.open(path, props)`.
|
|
268
|
+
func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any])
|
|
269
269
|
/// `craft.navigation.replace(path)`: finish the current flow atomically.
|
|
270
270
|
func nativeScreen(_ screen: CraftNativeScreenController, replace path: String)
|
|
271
271
|
}
|
|
@@ -446,11 +446,11 @@ final class CraftHybrid: NSObject {
|
|
|
446
446
|
/// A path or a web address opened by the app script or a single-stack
|
|
447
447
|
/// screen: the shell opens it (holding it until the shell exists); an app
|
|
448
448
|
/// with no shell opens a web address in the in-app browser.
|
|
449
|
-
func open(_ path: String) {
|
|
449
|
+
func open(_ path: String, props: [String: Any] = [:]) {
|
|
450
450
|
if let controller {
|
|
451
|
-
controller.open(path)
|
|
451
|
+
controller.open(path, props: props)
|
|
452
452
|
} else if isEnabled {
|
|
453
|
-
untilShell.append { $0.open(path) }
|
|
453
|
+
untilShell.append { $0.open(path, props: props) }
|
|
454
454
|
} else {
|
|
455
455
|
openOutside(path)
|
|
456
456
|
}
|
|
@@ -976,8 +976,8 @@ extension CraftHybrid: CraftHybridScreenEvents {
|
|
|
976
976
|
DispatchQueue.main.async { self.startAppScript() }
|
|
977
977
|
}
|
|
978
978
|
|
|
979
|
-
func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
|
|
980
|
-
open(path)
|
|
979
|
+
func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any]) {
|
|
980
|
+
open(path, props: props)
|
|
981
981
|
}
|
|
982
982
|
|
|
983
983
|
func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
|
|
@@ -1585,11 +1585,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1585
1585
|
}
|
|
1586
1586
|
|
|
1587
1587
|
/// The entry for a path: its native screen, or a web entry.
|
|
1588
|
-
private func entry(for path: String, isTabRoot: Bool, depth: Int?,
|
|
1588
|
+
private func entry(for path: String, isTabRoot: Bool, depth: Int?, props: [String: Any] = [:]) -> UIViewController {
|
|
1589
1589
|
if hybrid.isEnabled, let match = hybrid.routes.match(path) {
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params)
|
|
1590
|
+
let params = self.params(for: path, match)
|
|
1591
|
+
let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params, routeProps: props)
|
|
1593
1592
|
screen.hybridEvents = self
|
|
1594
1593
|
nativePaths[ObjectIdentifier(screen)] = match.path
|
|
1595
1594
|
return screen
|
|
@@ -1734,7 +1733,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1734
1733
|
/// an app with no web view, a path no screen draws opens in the in-app
|
|
1735
1734
|
/// browser on the app's site. A web address on the app's site is its
|
|
1736
1735
|
/// path; one anywhere else opens in the in-app browser.
|
|
1737
|
-
func open(_ raw: String) {
|
|
1736
|
+
func open(_ raw: String, props: [String: Any] = [:]) {
|
|
1738
1737
|
if let url = URL(string: raw), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
|
|
1739
1738
|
guard CraftHybridRoutes.isSite(url, site: hybrid.siteURL) else {
|
|
1740
1739
|
hybrid.openOutside(raw)
|
|
@@ -1749,7 +1748,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1749
1748
|
return
|
|
1750
1749
|
}
|
|
1751
1750
|
if hybrid.routes.match(raw) != nil {
|
|
1752
|
-
showNative(path: raw, replace: false)
|
|
1751
|
+
showNative(path: raw, replace: false, props: props)
|
|
1753
1752
|
return
|
|
1754
1753
|
}
|
|
1755
1754
|
if hybrid.hasWeb {
|
|
@@ -1769,7 +1768,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1769
1768
|
|
|
1770
1769
|
/// The native screen for `raw`: the one already in the stack (popping to
|
|
1771
1770
|
/// it), a tab's root, or a new one pushed.
|
|
1772
|
-
func showNative(path raw: String, replace: Bool, fromLink: Bool = false) {
|
|
1771
|
+
func showNative(path raw: String, replace: Bool, fromLink: Bool = false, props: [String: Any] = [:]) {
|
|
1773
1772
|
guard let match = hybrid.routes.match(raw) else { return }
|
|
1774
1773
|
interruptTabSwipe(restoringSelection: true)
|
|
1775
1774
|
let tabs = hybrid.tabIds
|
|
@@ -1785,7 +1784,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1785
1784
|
nav.popToRootViewController(animated: !fromLink)
|
|
1786
1785
|
}
|
|
1787
1786
|
// `/m/store?coach=5`: the tab's own screen, with the query.
|
|
1788
|
-
(stacks[tab]?.viewControllers.first as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
|
|
1787
|
+
(stacks[tab]?.viewControllers.first as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match), props: props)
|
|
1789
1788
|
return
|
|
1790
1789
|
}
|
|
1791
1790
|
if fromLink, let owner = CraftHybridRoutes.tab(owning: match.path, among: tabs), owner != currentTab {
|
|
@@ -1796,10 +1795,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1796
1795
|
if existing !== nav.topViewController { nav.popToViewController(existing, animated: true) }
|
|
1797
1796
|
// Opened again, perhaps with another query (`?person=8`): the
|
|
1798
1797
|
// screen hears the params it was opened with this time.
|
|
1799
|
-
(existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
|
|
1798
|
+
(existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match), props: props)
|
|
1800
1799
|
return
|
|
1801
1800
|
}
|
|
1802
|
-
let screen = entry(for: raw, isTabRoot: false, depth: nil)
|
|
1801
|
+
let screen = entry(for: raw, isTabRoot: false, depth: nil, props: props)
|
|
1803
1802
|
let show = { [weak nav] in
|
|
1804
1803
|
guard let nav else { return }
|
|
1805
1804
|
if replace, !nav.viewControllers.isEmpty {
|
|
@@ -2175,8 +2174,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2175
2174
|
}
|
|
2176
2175
|
}
|
|
2177
2176
|
|
|
2178
|
-
func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
|
|
2179
|
-
open(path)
|
|
2177
|
+
func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any]) {
|
|
2178
|
+
open(path, props: props)
|
|
2180
2179
|
}
|
|
2181
2180
|
|
|
2182
2181
|
func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
|
|
@@ -70,7 +70,7 @@ enum CraftLiveActivities {
|
|
|
70
70
|
|
|
71
71
|
private static func finite(_ body: [String: Any], _ key: String, _ fallback: Double? = nil) -> Double? {
|
|
72
72
|
guard body.keys.contains(key) else { return fallback }
|
|
73
|
-
guard let number = body[key]
|
|
73
|
+
guard let number = craftNativeNumber(body[key]), number.doubleValue.isFinite else { return nil }
|
|
74
74
|
return max(0, number.doubleValue)
|
|
75
75
|
}
|
|
76
76
|
|