craft-native 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/dist/android/src/index.d.ts +2 -0
- package/dist/android/src/index.js +22 -18
- package/dist/android/templates/CraftNative.kt.template +17 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +115 -40
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +92 -5
- package/dist/android/templates/CraftNativeMapLibre.kt.template +90 -3
- package/dist/android/templates/CraftNativeMutation.kt.template +32 -0
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +7 -2
- package/dist/android/templates/CraftNativeServices.kt.template +25 -0
- package/dist/android/templates/CraftNativeShell.kt.template +41 -9
- package/dist/android/templates/MainActivityNative.kt.template +369 -69
- package/dist/android/templates/build.gradle.kts.project.template +1 -1
- package/dist/api/mobile.d.ts +37 -1
- package/dist/browser.js +9 -9
- package/dist/cli.js +90 -673
- package/dist/components/index.d.ts +50 -9
- package/dist/index.cjs +394 -438
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +446 -493
- package/dist/ios/src/index.d.ts +2 -0
- package/dist/ios/src/index.js +54 -53
- package/dist/ios/templates/CraftApp.swift +44 -3
- package/dist/ios/templates/CraftHealth.swift +217 -0
- package/dist/ios/templates/CraftHeartRate.swift +6 -3
- package/dist/ios/templates/CraftHybrid.swift +30 -22
- package/dist/ios/templates/CraftLiveActivities.swift +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +87 -8
- package/dist/ios/templates/CraftNativeFiles.swift +15 -3
- package/dist/ios/templates/CraftNativeFlatList.swift +18 -1
- package/dist/ios/templates/CraftNativeMutation.swift +36 -0
- package/dist/ios/templates/CraftNativeScreen.swift +826 -69
- package/dist/ios/templates/CraftNativeServices.swift +4 -2
- package/dist/ios/templates/CraftNativeUI.swift +10 -2
- package/dist/ios/templates/testing/DriverTests.swift.template +1 -0
- package/dist/ios/templates/testing/HostApp.swift +9 -2
- package/dist/macos/templates/CraftNativeMutation.swift +36 -0
- package/dist/macos/templates/native_layout.zig +226 -2
- package/dist/mobile-testing.js +2 -2
- package/dist/mobile.js +101 -63
- package/package.json +1 -1
package/dist/ios/src/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/ios/src/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
|
};
|
|
@@ -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
|
}
|
|
@@ -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) {
|
|
@@ -1249,8 +1249,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1249
1249
|
// create a second web root here: the shared WebView cannot draw
|
|
1250
1250
|
// both pages during the same gesture.
|
|
1251
1251
|
guard hybrid.routes.match(id) != nil else { return false }
|
|
1252
|
-
target = CraftFramePacing.shared.measure("tab swipe mount \(id)") { makeStack(tab: id, rootPath: id) }
|
|
1253
|
-
(target.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
|
|
1252
|
+
target = CraftFramePacing.shared.measure("tab swipe mount \(id)") { makeStack(tab: id, rootPath: id, hidden: true) }
|
|
1254
1253
|
}
|
|
1255
1254
|
guard current.view !== target.view,
|
|
1256
1255
|
!(current.topViewController is CraftHybridWebSlot && target.topViewController is CraftHybridWebSlot),
|
|
@@ -1546,6 +1545,12 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1546
1545
|
|
|
1547
1546
|
private var visibleStack: UINavigationController? { stacks[currentTab] }
|
|
1548
1547
|
|
|
1548
|
+
/// A swipe preview can be visible without owning navigation or focus.
|
|
1549
|
+
/// UIKit appearance callbacks must follow the committed native stack.
|
|
1550
|
+
func isFocusedScreen(_ screen: CraftNativeScreenController) -> Bool {
|
|
1551
|
+
visibleStack?.topViewController === screen
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1549
1554
|
var webIsVisible: Bool {
|
|
1550
1555
|
guard let webHost, let slot = visibleStack?.topViewController as? CraftHybridWebSlot else { return false }
|
|
1551
1556
|
return webHost.view.superview === slot.view
|
|
@@ -1557,7 +1562,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1557
1562
|
/// the view hierarchy, so its screen never takes the shell's chrome (the
|
|
1558
1563
|
/// bar, the tab accessory) from the one on screen.
|
|
1559
1564
|
private func makeStack(tab: String, rootPath: String, hidden: Bool = false) -> UINavigationController {
|
|
1560
|
-
let
|
|
1565
|
+
let root = entry(for: rootPath, isTabRoot: true, depth: nil)
|
|
1566
|
+
// Constructing the navigation controller can load the root's view and
|
|
1567
|
+
// mount its script immediately. Set its initial focus before that.
|
|
1568
|
+
if hidden { (root as? CraftNativeScreenController)?.screenVisibilityChanged(false) }
|
|
1569
|
+
let nav = UINavigationController(rootViewController: root)
|
|
1561
1570
|
nav.delegate = self
|
|
1562
1571
|
nav.view.backgroundColor = config.resolvedBackgroundColor
|
|
1563
1572
|
nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
|
|
@@ -1585,11 +1594,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1585
1594
|
}
|
|
1586
1595
|
|
|
1587
1596
|
/// The entry for a path: its native screen, or a web entry.
|
|
1588
|
-
private func entry(for path: String, isTabRoot: Bool, depth: Int?,
|
|
1597
|
+
private func entry(for path: String, isTabRoot: Bool, depth: Int?, props: [String: Any] = [:]) -> UIViewController {
|
|
1589
1598
|
if hybrid.isEnabled, let match = hybrid.routes.match(path) {
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params)
|
|
1599
|
+
let params = self.params(for: path, match)
|
|
1600
|
+
let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params, routeProps: props)
|
|
1593
1601
|
screen.hybridEvents = self
|
|
1594
1602
|
nativePaths[ObjectIdentifier(screen)] = match.path
|
|
1595
1603
|
return screen
|
|
@@ -1734,7 +1742,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1734
1742
|
/// an app with no web view, a path no screen draws opens in the in-app
|
|
1735
1743
|
/// browser on the app's site. A web address on the app's site is its
|
|
1736
1744
|
/// path; one anywhere else opens in the in-app browser.
|
|
1737
|
-
func open(_ raw: String) {
|
|
1745
|
+
func open(_ raw: String, props: [String: Any] = [:]) {
|
|
1738
1746
|
if let url = URL(string: raw), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
|
|
1739
1747
|
guard CraftHybridRoutes.isSite(url, site: hybrid.siteURL) else {
|
|
1740
1748
|
hybrid.openOutside(raw)
|
|
@@ -1749,7 +1757,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1749
1757
|
return
|
|
1750
1758
|
}
|
|
1751
1759
|
if hybrid.routes.match(raw) != nil {
|
|
1752
|
-
showNative(path: raw, replace: false)
|
|
1760
|
+
showNative(path: raw, replace: false, props: props)
|
|
1753
1761
|
return
|
|
1754
1762
|
}
|
|
1755
1763
|
if hybrid.hasWeb {
|
|
@@ -1769,7 +1777,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1769
1777
|
|
|
1770
1778
|
/// The native screen for `raw`: the one already in the stack (popping to
|
|
1771
1779
|
/// it), a tab's root, or a new one pushed.
|
|
1772
|
-
func showNative(path raw: String, replace: Bool, fromLink: Bool = false) {
|
|
1780
|
+
func showNative(path raw: String, replace: Bool, fromLink: Bool = false, props: [String: Any] = [:]) {
|
|
1773
1781
|
guard let match = hybrid.routes.match(raw) else { return }
|
|
1774
1782
|
interruptTabSwipe(restoringSelection: true)
|
|
1775
1783
|
let tabs = hybrid.tabIds
|
|
@@ -1785,7 +1793,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1785
1793
|
nav.popToRootViewController(animated: !fromLink)
|
|
1786
1794
|
}
|
|
1787
1795
|
// `/m/store?coach=5`: the tab's own screen, with the query.
|
|
1788
|
-
(stacks[tab]?.viewControllers.first as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
|
|
1796
|
+
(stacks[tab]?.viewControllers.first as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match), props: props)
|
|
1789
1797
|
return
|
|
1790
1798
|
}
|
|
1791
1799
|
if fromLink, let owner = CraftHybridRoutes.tab(owning: match.path, among: tabs), owner != currentTab {
|
|
@@ -1796,10 +1804,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1796
1804
|
if existing !== nav.topViewController { nav.popToViewController(existing, animated: true) }
|
|
1797
1805
|
// Opened again, perhaps with another query (`?person=8`): the
|
|
1798
1806
|
// screen hears the params it was opened with this time.
|
|
1799
|
-
(existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
|
|
1807
|
+
(existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match), props: props)
|
|
1800
1808
|
return
|
|
1801
1809
|
}
|
|
1802
|
-
let screen = entry(for: raw, isTabRoot: false, depth: nil)
|
|
1810
|
+
let screen = entry(for: raw, isTabRoot: false, depth: nil, props: props)
|
|
1803
1811
|
let show = { [weak nav] in
|
|
1804
1812
|
guard let nav else { return }
|
|
1805
1813
|
if replace, !nav.viewControllers.isEmpty {
|
|
@@ -2175,8 +2183,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2175
2183
|
}
|
|
2176
2184
|
}
|
|
2177
2185
|
|
|
2178
|
-
func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
|
|
2179
|
-
open(path)
|
|
2186
|
+
func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any]) {
|
|
2187
|
+
open(path, props: props)
|
|
2180
2188
|
}
|
|
2181
2189
|
|
|
2182
2190
|
func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
|