@craft-native/ios 0.0.237 → 0.0.241
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +17 -1
- package/dist/testing.d.ts +16 -2
- package/package.json +1 -1
- package/templates/CraftApp.swift +418 -93
- package/templates/CraftHeartRate.swift +141 -44
- package/templates/CraftNativeActions.swift +46 -48
- package/templates/CraftNativeScreen.swift +82 -17
- package/templates/CraftPersistentStorage.swift +99 -0
- package/templates/CraftRecordingArchive.swift +85 -0
- package/templates/testing/DriverTests.swift.template +45 -1
package/templates/CraftApp.swift
CHANGED
|
@@ -421,6 +421,12 @@ class CraftEventManager {
|
|
|
421
421
|
}
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
+
/// Zig already delivers to the page; native-only delivery must not repeat it.
|
|
425
|
+
func sendToAppScript(_ event: String, data: [String: Any]) {
|
|
426
|
+
guard listener == .appScript else { return }
|
|
427
|
+
sendToWeb(event, data: data)
|
|
428
|
+
}
|
|
429
|
+
|
|
424
430
|
/// An event no one will hear: logged, and background work it holds is
|
|
425
431
|
/// answered at once.
|
|
426
432
|
private func drop(_ event: String, data: [String: Any]) {
|
|
@@ -613,6 +619,12 @@ struct CraftApp: App {
|
|
|
613
619
|
// images and all, and a tab bar or a splash that waited for it would be late
|
|
614
620
|
// on every launch.
|
|
615
621
|
|
|
622
|
+
/// Finger tracking changes only the indicator. Publishing it on CraftChrome
|
|
623
|
+
/// invalidated the glass surface, every tab and the splash for every pan event.
|
|
624
|
+
final class CraftTabSwipeProgress: ObservableObject {
|
|
625
|
+
@Published var position: CGFloat?
|
|
626
|
+
}
|
|
627
|
+
|
|
616
628
|
final class CraftChrome: ObservableObject {
|
|
617
629
|
static let shared = CraftChrome()
|
|
618
630
|
|
|
@@ -625,6 +637,21 @@ final class CraftChrome: ObservableObject {
|
|
|
625
637
|
var titleOverride: String? = nil
|
|
626
638
|
var iconTint: String? = nil
|
|
627
639
|
var iconPulse = false
|
|
640
|
+
var selectedSymbol: String? = nil
|
|
641
|
+
var selectedIconTint: String? = nil
|
|
642
|
+
var selectedIconPulse: Bool? = nil
|
|
643
|
+
|
|
644
|
+
static func normalizedSelectedSymbol(_ value: String?) -> String? {
|
|
645
|
+
guard let name = value?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { return nil }
|
|
646
|
+
return name
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
func iconAppearance(selected: Bool, motionAllowed: Bool) -> (symbol: String, tint: String?, pulse: Bool) {
|
|
650
|
+
let name = selected ? selectedSymbol ?? symbol : symbol
|
|
651
|
+
let tint = selected ? selectedIconTint ?? iconTint : iconTint
|
|
652
|
+
let pulse = selected ? selectedIconPulse ?? iconPulse : iconPulse
|
|
653
|
+
return (name, tint, pulse && motionAllowed)
|
|
654
|
+
}
|
|
628
655
|
var displayTitle: String { titleOverride ?? title }
|
|
629
656
|
var accessibilityLabel: String? = nil
|
|
630
657
|
var image: String? = nil
|
|
@@ -633,7 +660,11 @@ final class CraftChrome: ObservableObject {
|
|
|
633
660
|
@Published var tabs: [Tab] = []
|
|
634
661
|
@Published var tabAccessory: Tab?
|
|
635
662
|
@Published var selected: String?
|
|
636
|
-
|
|
663
|
+
let tabSwipeProgress = CraftTabSwipeProgress()
|
|
664
|
+
private(set) var tabSwipePosition: CGFloat? {
|
|
665
|
+
get { tabSwipeProgress.position }
|
|
666
|
+
set { tabSwipeProgress.position = newValue }
|
|
667
|
+
}
|
|
637
668
|
@Published var tabBarVisible = false
|
|
638
669
|
@Published var splashVisible = true
|
|
639
670
|
@Published var tint: UIColor?
|
|
@@ -675,7 +706,7 @@ final class CraftChrome: ObservableObject {
|
|
|
675
706
|
splashVisible = splash
|
|
676
707
|
tabSwipeEnabled = true
|
|
677
708
|
guard !configured.isEmpty else { return }
|
|
678
|
-
tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil) }
|
|
709
|
+
tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil, selectedSymbol: Tab.normalizedSelectedSymbol($0.selectedSymbol), selectedIconTint: $0.selectedIconTint, selectedIconPulse: $0.selectedIconPulse) }
|
|
679
710
|
self.selected = selected ?? configured.first?.id
|
|
680
711
|
tabBarVisible = true
|
|
681
712
|
}
|
|
@@ -688,27 +719,42 @@ final class CraftChrome: ObservableObject {
|
|
|
688
719
|
/// Change one tab's caption or icon without replacing stacks or selection.
|
|
689
720
|
func updateTab(_ item: [String: Any]) {
|
|
690
721
|
guard let id = item["id"] as? String, let index = tabs.firstIndex(where: { $0.id == id }) else { return }
|
|
722
|
+
// One live reading is one change. Mutating a member of a Published
|
|
723
|
+
// array sends objectWillChange for every field, including equal ones,
|
|
724
|
+
// rebuilding the glass bar repeatedly while its content scrolls.
|
|
725
|
+
var next = tabs[index]
|
|
691
726
|
if item.keys.contains("iconText") {
|
|
692
|
-
|
|
727
|
+
next.iconText = (item["iconText"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
|
|
693
728
|
}
|
|
694
729
|
if item.keys.contains("title") {
|
|
695
|
-
|
|
730
|
+
next.titleOverride = (item["title"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(40)) }
|
|
696
731
|
}
|
|
697
732
|
if item.keys.contains("iconTint") {
|
|
698
|
-
|
|
733
|
+
next.iconTint = (item["iconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 }
|
|
699
734
|
}
|
|
700
735
|
if item.keys.contains("iconPulse") {
|
|
701
|
-
|
|
736
|
+
next.iconPulse = item["iconPulse"] as? Bool ?? false
|
|
737
|
+
}
|
|
738
|
+
if item.keys.contains("selectedSymbol") {
|
|
739
|
+
next.selectedSymbol = Tab.normalizedSelectedSymbol(item["selectedSymbol"] as? String)
|
|
740
|
+
}
|
|
741
|
+
if item.keys.contains("selectedIconTint") {
|
|
742
|
+
next.selectedIconTint = (item["selectedIconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 }
|
|
743
|
+
}
|
|
744
|
+
if item.keys.contains("selectedIconPulse") {
|
|
745
|
+
next.selectedIconPulse = item["selectedIconPulse"] as? Bool
|
|
702
746
|
}
|
|
703
747
|
if item.keys.contains("accessibilityLabel") {
|
|
704
|
-
|
|
748
|
+
next.accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
|
|
705
749
|
}
|
|
706
750
|
if item.keys.contains("image") {
|
|
707
|
-
|
|
751
|
+
next.image = (item["image"] as? String).flatMap { URL(string: $0)?.scheme == "https" ? $0 : nil }
|
|
708
752
|
}
|
|
709
753
|
if item.keys.contains("badge") {
|
|
710
|
-
|
|
754
|
+
next.badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
|
|
711
755
|
}
|
|
756
|
+
guard next != tabs[index] else { return }
|
|
757
|
+
tabs[index] = next
|
|
712
758
|
}
|
|
713
759
|
/// A destination beside the tab pill, outside the tab stacks and swipe order.
|
|
714
760
|
func updateTabAccessory(_ item: [String: Any]?) {
|
|
@@ -752,7 +798,10 @@ final class CraftChrome: ObservableObject {
|
|
|
752
798
|
tabs = (body["tabs"] as? [[String: Any]] ?? []).compactMap { item in
|
|
753
799
|
guard let id = item["id"] as? String, let title = item["title"] as? String else { return nil }
|
|
754
800
|
let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
755
|
-
return Tab(id: id, title: title, symbol: item["symbol"] as? String ?? "circle", badge: badge
|
|
801
|
+
return Tab(id: id, title: title, symbol: item["symbol"] as? String ?? "circle", badge: badge,
|
|
802
|
+
selectedSymbol: Tab.normalizedSelectedSymbol(item["selectedSymbol"] as? String),
|
|
803
|
+
selectedIconTint: (item["selectedIconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 },
|
|
804
|
+
selectedIconPulse: item["selectedIconPulse"] as? Bool)
|
|
756
805
|
}
|
|
757
806
|
if let selected = body["selected"] as? String {
|
|
758
807
|
self.selected = selected
|
|
@@ -1108,18 +1157,7 @@ struct CraftTabBarView: View {
|
|
|
1108
1157
|
}
|
|
1109
1158
|
}
|
|
1110
1159
|
.background {
|
|
1111
|
-
|
|
1112
|
-
if let position = chrome.tabSwipePosition
|
|
1113
|
-
?? chrome.tabs.firstIndex(where: { $0.id == chrome.selected }).map({ CGFloat($0) }) {
|
|
1114
|
-
let width = geometry.size.width / CGFloat(max(1, chrome.tabs.count))
|
|
1115
|
-
Capsule()
|
|
1116
|
-
.fill(Color.primary.opacity(0.09))
|
|
1117
|
-
.frame(width: width, height: geometry.size.height)
|
|
1118
|
-
.offset(x: position * width)
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
.allowsHitTesting(false)
|
|
1122
|
-
.accessibilityHidden(true)
|
|
1160
|
+
CraftTabSelectionIndicator(selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }), count: chrome.tabs.count)
|
|
1123
1161
|
}
|
|
1124
1162
|
}
|
|
1125
1163
|
|
|
@@ -1129,13 +1167,15 @@ struct CraftTabBarView: View {
|
|
|
1129
1167
|
|
|
1130
1168
|
/// Live styling belongs to the icon, leaving selection and the caption clear.
|
|
1131
1169
|
@ViewBuilder private func tabSymbol(_ tab: CraftChrome.Tab, selected: Bool) -> some View {
|
|
1132
|
-
let
|
|
1133
|
-
let
|
|
1170
|
+
let appearance = tab.iconAppearance(selected: selected, motionAllowed: !reduceMotion && scenePhase == .active && chrome.tabBarVisible)
|
|
1171
|
+
let color = appearance.tint.flatMap { UIColor(hex: $0) }.map { Color($0) } ?? (selected ? selectedColor : Color.primary)
|
|
1172
|
+
let name = selected && tab.selectedSymbol != nil ? appearance.symbol : symbol(appearance.symbol, selected: selected)
|
|
1173
|
+
let icon = Image(systemName: name)
|
|
1134
1174
|
.font(.system(size: iconSize, weight: selected ? .semibold : .regular))
|
|
1135
1175
|
.foregroundStyle(color)
|
|
1136
1176
|
.frame(height: iconSize + 6)
|
|
1137
1177
|
if #available(iOS 17.0, *) {
|
|
1138
|
-
icon.symbolEffect(.pulse, options: .repeating, isActive:
|
|
1178
|
+
icon.symbolEffect(.pulse, options: .repeating, isActive: appearance.pulse)
|
|
1139
1179
|
} else {
|
|
1140
1180
|
icon
|
|
1141
1181
|
}
|
|
@@ -1148,6 +1188,26 @@ struct CraftTabBarView: View {
|
|
|
1148
1188
|
}
|
|
1149
1189
|
}
|
|
1150
1190
|
|
|
1191
|
+
private struct CraftTabSelectionIndicator: View {
|
|
1192
|
+
@ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
|
|
1193
|
+
let selected: Int?
|
|
1194
|
+
let count: Int
|
|
1195
|
+
|
|
1196
|
+
var body: some View {
|
|
1197
|
+
GeometryReader { geometry in
|
|
1198
|
+
if let position = progress.position ?? selected.map({ CGFloat($0) }) {
|
|
1199
|
+
let width = geometry.size.width / CGFloat(max(1, count))
|
|
1200
|
+
Capsule()
|
|
1201
|
+
.fill(Color.primary.opacity(0.09))
|
|
1202
|
+
.frame(width: width, height: geometry.size.height)
|
|
1203
|
+
.offset(x: position * width)
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
.allowsHitTesting(false)
|
|
1207
|
+
.accessibilityHidden(true)
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1151
1211
|
/// The launch screen, held until the page is ready: the same colour and the
|
|
1152
1212
|
/// same logo at the same place, so the hand-over from the system's launch
|
|
1153
1213
|
/// screen cannot be seen.
|
|
@@ -1517,6 +1577,9 @@ struct CraftConfig: Codable {
|
|
|
1517
1577
|
var id: String
|
|
1518
1578
|
var title: String
|
|
1519
1579
|
var symbol: String? = nil
|
|
1580
|
+
var selectedSymbol: String? = nil
|
|
1581
|
+
var selectedIconTint: String? = nil
|
|
1582
|
+
var selectedIconPulse: Bool? = nil
|
|
1520
1583
|
}
|
|
1521
1584
|
|
|
1522
1585
|
struct BackgroundRefresh: Codable {
|
|
@@ -1928,6 +1991,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1928
1991
|
private var locationManager: CLLocationManager?
|
|
1929
1992
|
private var singleLocationCallbackId: String?
|
|
1930
1993
|
private var singleLocationTimeoutWorkItem: DispatchWorkItem?
|
|
1994
|
+
private var singleLocationTimeoutMilliseconds: Double = 30_000
|
|
1995
|
+
private var singleLocationAcquisitionStarted = false
|
|
1996
|
+
private var singleLocationAuthorizationRequested = false
|
|
1931
1997
|
private var locationPermissionCallbackId: String?
|
|
1932
1998
|
private var locationPermissionRequiresAlways = false
|
|
1933
1999
|
private var isWatchingLocation = false
|
|
@@ -1935,6 +2001,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1935
2001
|
private var isLocationRecordingPaused = false
|
|
1936
2002
|
private var locationRecordingId: String?
|
|
1937
2003
|
private var locationRecordingStartedAt: TimeInterval?
|
|
2004
|
+
private let recordingDirectory: URL?
|
|
2005
|
+
private var locationRecordingRestoreError: Error?
|
|
1938
2006
|
|
|
1939
2007
|
// Network monitoring
|
|
1940
2008
|
private var networkMonitor: NWPathMonitor?
|
|
@@ -2000,14 +2068,15 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2000
2068
|
// SQLite database
|
|
2001
2069
|
private var db: OpaquePointer?
|
|
2002
2070
|
|
|
2003
|
-
init(config: CraftConfig) {
|
|
2071
|
+
init(config: CraftConfig, locationManager suppliedLocationManager: CLLocationManager? = nil, recordingDirectory: URL? = nil) {
|
|
2072
|
+
self.recordingDirectory = recordingDirectory
|
|
2004
2073
|
self.config = config
|
|
2005
2074
|
super.init()
|
|
2006
2075
|
if config.enableSpeechRecognition {
|
|
2007
2076
|
speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
|
|
2008
2077
|
}
|
|
2009
2078
|
if config.enableGeolocation {
|
|
2010
|
-
locationManager = CLLocationManager()
|
|
2079
|
+
locationManager = suppliedLocationManager ?? CLLocationManager()
|
|
2011
2080
|
locationManager?.desiredAccuracy = kCLLocationAccuracyBest
|
|
2012
2081
|
locationManager?.activityType = .fitness
|
|
2013
2082
|
locationManager?.pausesLocationUpdatesAutomatically = false
|
|
@@ -2015,7 +2084,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2015
2084
|
// relaunch restore — see `CraftZigRuntime.adoptLocationRecording`.
|
|
2016
2085
|
// Running both would leave two managers appending to one track.
|
|
2017
2086
|
if !CraftZigRuntime.adoptLocationRecording() {
|
|
2018
|
-
restoreLocationRecordingState()
|
|
2087
|
+
do { try restoreLocationRecordingState() }
|
|
2088
|
+
catch { locationRecordingRestoreError = error }
|
|
2019
2089
|
}
|
|
2020
2090
|
}
|
|
2021
2091
|
if config.enableContacts {
|
|
@@ -2165,6 +2235,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2165
2235
|
/// `craft_ios_deliver_result` instead of evaluating JavaScript here —
|
|
2166
2236
|
/// one reply path, owned by whichever side received the page's message.
|
|
2167
2237
|
func dispatch(action: String, body: [String: Any], callbackId: String?) {
|
|
2238
|
+
if ["startLocationRecording", "pauseLocationRecording", "resumeLocationRecording", "stopLocationRecording", "getLocationRecordingState", "readLocationRecording", "acknowledgeLocationRecording"].contains(action),
|
|
2239
|
+
locationRecordingRestoreError != nil {
|
|
2240
|
+
rejectCallback(callbackId, error: "Unable to restore recording data", code: "RECORDING_STORAGE_ERROR")
|
|
2241
|
+
return
|
|
2242
|
+
}
|
|
2243
|
+
if ["getLocationRecordingState", "readLocationRecording", "stopLocationRecording"].contains(action),
|
|
2244
|
+
archivedLocationAction(action, body: body, callbackId: callbackId) { return }
|
|
2168
2245
|
if let answer = CraftNativeActions.perform(action: action, body: body, config: config) {
|
|
2169
2246
|
switch answer {
|
|
2170
2247
|
case .success(let value): resolveCallback(callbackId, result: value)
|
|
@@ -2361,20 +2438,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2361
2438
|
resolveCallback(callbackId, result: true)
|
|
2362
2439
|
case "startLocationRecording":
|
|
2363
2440
|
if config.enableGeolocation {
|
|
2364
|
-
startLocationRecording(callbackId: callbackId)
|
|
2441
|
+
startLocationRecording(body: body, callbackId: callbackId)
|
|
2365
2442
|
} else {
|
|
2366
2443
|
rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
|
|
2367
2444
|
}
|
|
2368
2445
|
case "pauseLocationRecording":
|
|
2369
|
-
pauseLocationRecording(callbackId: callbackId)
|
|
2446
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { pauseLocationRecording(callbackId: callbackId) }
|
|
2370
2447
|
case "resumeLocationRecording":
|
|
2371
|
-
resumeLocationRecording(callbackId: callbackId)
|
|
2448
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { resumeLocationRecording(callbackId: callbackId) }
|
|
2372
2449
|
case "stopLocationRecording":
|
|
2373
|
-
stopLocationRecording(callbackId: callbackId)
|
|
2450
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { stopLocationRecording(callbackId: callbackId) }
|
|
2374
2451
|
case "getLocationRecordingState":
|
|
2375
2452
|
getLocationRecordingState(callbackId: callbackId)
|
|
2376
2453
|
case "readLocationRecording":
|
|
2377
|
-
readLocationRecording(callbackId: callbackId)
|
|
2454
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { readLocationRecording(callbackId: callbackId) }
|
|
2455
|
+
case "acknowledgeLocationRecording":
|
|
2456
|
+
_ = archivedLocationAction(action, body: body, callbackId: callbackId)
|
|
2378
2457
|
// App Badge
|
|
2379
2458
|
case "setBadge":
|
|
2380
2459
|
if let count = body["count"] as? Int {
|
|
@@ -3682,6 +3761,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3682
3761
|
pushNotifications: \(config.enablePushNotifications),
|
|
3683
3762
|
secureStorage: \(config.enableSecureStorage),
|
|
3684
3763
|
geolocation: \(config.enableGeolocation),
|
|
3764
|
+
crypto: true,
|
|
3765
|
+
locationRecordingOwnership: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingOwnership),
|
|
3766
|
+
locationRecordingIntentIdentity: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingIntentIdentity),
|
|
3685
3767
|
backgroundLocation: \(config.enableBackgroundLocation),
|
|
3686
3768
|
healthKit: \(config.enableHealthKit),
|
|
3687
3769
|
liveActivities: \(config.enableLiveActivities),
|
|
@@ -3698,6 +3780,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3698
3780
|
brightness: true,
|
|
3699
3781
|
music: \(config.enableAppleMusic),
|
|
3700
3782
|
heartRate: \(config.enableHeartRate),
|
|
3783
|
+
heartRateRecordingOwnership: \(config.enableHeartRate),
|
|
3784
|
+
heartRateRecordingIntentIdentity: \(config.enableHeartRate),
|
|
3701
3785
|
scheduledWorkouts: \(config.enableScheduledWorkouts),
|
|
3702
3786
|
speech: true,
|
|
3703
3787
|
crashReporter: \(CraftCrashReporter.shared.enabled),
|
|
@@ -5282,6 +5366,20 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5282
5366
|
browserOpen: true
|
|
5283
5367
|
};
|
|
5284
5368
|
craft._invoke = function(action, payload) {
|
|
5369
|
+
var recordingOptions = action === 'heartRate' && payload && ['startRecording', 'pauseRecording', 'resumeRecording', 'stopRecording', 'readRecording', 'getRecordingState', 'acknowledgeRecording'].indexOf(payload.method) !== -1
|
|
5370
|
+
? payload.options : ['startLocationRecording', 'pauseLocationRecording', 'resumeLocationRecording', 'stopLocationRecording', 'readLocationRecording', 'getLocationRecordingState', 'acknowledgeLocationRecording'].indexOf(action) !== -1 ? payload : null;
|
|
5371
|
+
if (recordingOptions && Object.prototype.hasOwnProperty.call(recordingOptions, 'recordingId') &&
|
|
5372
|
+
(typeof recordingOptions.recordingId !== 'string' || !recordingOptions.recordingId.trim())) {
|
|
5373
|
+
var invalidStart = new Error('recordingId must be a nonempty recording ID');
|
|
5374
|
+
invalidStart.code = 'INVALID_ARGUMENT';
|
|
5375
|
+
return Promise.reject(invalidStart);
|
|
5376
|
+
}
|
|
5377
|
+
if (recordingOptions && Object.prototype.hasOwnProperty.call(recordingOptions, 'expectedId') &&
|
|
5378
|
+
(typeof recordingOptions.expectedId !== 'string' || !recordingOptions.expectedId.trim())) {
|
|
5379
|
+
var invalid = new Error('expectedId must be a nonempty recording ID');
|
|
5380
|
+
invalid.code = 'INVALID_ARGUMENT';
|
|
5381
|
+
return Promise.reject(invalid);
|
|
5382
|
+
}
|
|
5285
5383
|
var self = craft;
|
|
5286
5384
|
var id = 'cb_' + (++self._callbackId);
|
|
5287
5385
|
var message = Object.assign({}, payload || {}, {action: action, callbackId: id});
|
|
@@ -5419,6 +5517,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5419
5517
|
legacyLocationWatchCallbacks.length = 0;
|
|
5420
5518
|
return stopLocationWatchIfUnused();
|
|
5421
5519
|
};
|
|
5520
|
+
craft.crypto = { randomUUID: function() { return craft._invoke('cryptoRandomUUID'); } };
|
|
5422
5521
|
craft.location = {
|
|
5423
5522
|
getCurrentPosition: function(options) { return legacyGeolocation.getCurrentPosition(options || {}); },
|
|
5424
5523
|
watchPosition: function(callback) {
|
|
@@ -5437,12 +5536,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5437
5536
|
if (!locationWatchCallbacks.delete(id)) return;
|
|
5438
5537
|
void stopLocationWatchIfUnused().catch(function() {});
|
|
5439
5538
|
},
|
|
5440
|
-
startRecording: function() { return craft._invoke('startLocationRecording'); },
|
|
5441
|
-
pauseRecording: function() { return craft._invoke('pauseLocationRecording'); },
|
|
5442
|
-
resumeRecording: function() { return craft._invoke('resumeLocationRecording'); },
|
|
5443
|
-
stopRecording: function() { return craft._invoke('stopLocationRecording'); },
|
|
5444
|
-
getRecordingState: function() { return craft._invoke('getLocationRecordingState'); },
|
|
5445
|
-
|
|
5539
|
+
startRecording: function(options) { return craft._invoke('startLocationRecording', options || {}); },
|
|
5540
|
+
pauseRecording: function(options) { return craft._invoke('pauseLocationRecording', options || {}); },
|
|
5541
|
+
resumeRecording: function(options) { return craft._invoke('resumeLocationRecording', options || {}); },
|
|
5542
|
+
stopRecording: function(options) { return craft._invoke('stopLocationRecording', options || {}); },
|
|
5543
|
+
getRecordingState: function(options) { return craft._invoke('getLocationRecordingState', options || {}); },
|
|
5544
|
+
acknowledgeRecording: function(options) { return craft._invoke('acknowledgeLocationRecording', options || {}); },
|
|
5545
|
+
readRecording: function(options) { return craft._invoke('readLocationRecording', options || {}); }
|
|
5446
5546
|
};
|
|
5447
5547
|
craft.health = {
|
|
5448
5548
|
requestAuthorization: function(types, options) {
|
|
@@ -5530,12 +5630,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5530
5630
|
connect: function(id) { return heartRate('connect', {id: typeof id === 'string' ? id : id && id.id}); },
|
|
5531
5631
|
reconnect: function() { return heartRate('reconnect'); },
|
|
5532
5632
|
disconnect: function(options) { return heartRate('disconnect', {forget: !!(options && options.forget)}); },
|
|
5533
|
-
startRecording: function() { return heartRate('startRecording'); },
|
|
5534
|
-
pauseRecording: function() { return heartRate('pauseRecording'); },
|
|
5535
|
-
resumeRecording: function() { return heartRate('resumeRecording'); },
|
|
5536
|
-
stopRecording: function() { return heartRate('stopRecording'); },
|
|
5537
|
-
getRecordingState: function() { return heartRate('getRecordingState'); },
|
|
5538
|
-
|
|
5633
|
+
startRecording: function(options) { return heartRate('startRecording', options || {}); },
|
|
5634
|
+
pauseRecording: function(options) { return heartRate('pauseRecording', options || {}); },
|
|
5635
|
+
resumeRecording: function(options) { return heartRate('resumeRecording', options || {}); },
|
|
5636
|
+
stopRecording: function(options) { return heartRate('stopRecording', options || {}); },
|
|
5637
|
+
getRecordingState: function(options) { return heartRate('getRecordingState', options || {}); },
|
|
5638
|
+
acknowledgeRecording: function(options) { return heartRate('acknowledgeRecording', options || {}); },
|
|
5639
|
+
readRecording: function(options) { return heartRate('readRecording', options || {}); }
|
|
5539
5640
|
};
|
|
5540
5641
|
})();
|
|
5541
5642
|
// Structured workouts on the paired Apple Watch (WorkoutKit,
|
|
@@ -6302,7 +6403,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6302
6403
|
}
|
|
6303
6404
|
singleLocationCallbackId = callbackId
|
|
6304
6405
|
let maximumAge = max(0, (body["maximumAge"] as? NSNumber)?.doubleValue ?? 0)
|
|
6305
|
-
if maximumAge > 0,
|
|
6406
|
+
if (manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse), maximumAge > 0,
|
|
6306
6407
|
let cachedLocation = manager.location,
|
|
6307
6408
|
max(0, Date().timeIntervalSince(cachedLocation.timestamp) * 1000) <= maximumAge {
|
|
6308
6409
|
finishSingleLocationRequest()
|
|
@@ -6310,7 +6411,35 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6310
6411
|
return
|
|
6311
6412
|
}
|
|
6312
6413
|
|
|
6313
|
-
|
|
6414
|
+
singleLocationTimeoutMilliseconds = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000)
|
|
6415
|
+
continueSingleLocationRequest(manager)
|
|
6416
|
+
}
|
|
6417
|
+
|
|
6418
|
+
private func continueSingleLocationRequest(_ manager: CLLocationManager) {
|
|
6419
|
+
guard let callbackId = singleLocationCallbackId else { return }
|
|
6420
|
+
switch manager.authorizationStatus {
|
|
6421
|
+
case .notDetermined:
|
|
6422
|
+
if !singleLocationAuthorizationRequested {
|
|
6423
|
+
singleLocationAuthorizationRequested = true
|
|
6424
|
+
manager.requestWhenInUseAuthorization()
|
|
6425
|
+
}
|
|
6426
|
+
return
|
|
6427
|
+
case .denied, .restricted:
|
|
6428
|
+
finishSingleLocationRequest()
|
|
6429
|
+
rejectCallback(callbackId, error: "Location permission was denied.", code: "PERMISSION_DENIED")
|
|
6430
|
+
return
|
|
6431
|
+
case .authorizedAlways, .authorizedWhenInUse:
|
|
6432
|
+
break
|
|
6433
|
+
@unknown default:
|
|
6434
|
+
finishSingleLocationRequest()
|
|
6435
|
+
rejectCallback(callbackId, error: "Location authorization is unavailable.", code: "PERMISSION_DENIED")
|
|
6436
|
+
return
|
|
6437
|
+
}
|
|
6438
|
+
guard !singleLocationAcquisitionStarted else { return }
|
|
6439
|
+
singleLocationAcquisitionStarted = true
|
|
6440
|
+
// The acquisition timeout starts after the person decides. A
|
|
6441
|
+
// permission sheet is not a failed or timed-out GPS fix.
|
|
6442
|
+
let timeoutMs = singleLocationTimeoutMilliseconds
|
|
6314
6443
|
let timeoutWorkItem = DispatchWorkItem { [weak self] in
|
|
6315
6444
|
guard let self, self.singleLocationCallbackId == callbackId else { return }
|
|
6316
6445
|
self.finishSingleLocationRequest()
|
|
@@ -6318,7 +6447,6 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6318
6447
|
}
|
|
6319
6448
|
singleLocationTimeoutWorkItem = timeoutWorkItem
|
|
6320
6449
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(min(timeoutMs + 100, Double(Int.max)))), execute: timeoutWorkItem)
|
|
6321
|
-
requestLocationAuthorization()
|
|
6322
6450
|
manager.requestLocation()
|
|
6323
6451
|
}
|
|
6324
6452
|
|
|
@@ -6326,6 +6454,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6326
6454
|
singleLocationTimeoutWorkItem?.cancel()
|
|
6327
6455
|
singleLocationTimeoutWorkItem = nil
|
|
6328
6456
|
singleLocationCallbackId = nil
|
|
6457
|
+
singleLocationAcquisitionStarted = false
|
|
6458
|
+
singleLocationAuthorizationRequested = false
|
|
6329
6459
|
locationManager?.desiredAccuracy = kCLLocationAccuracyBest
|
|
6330
6460
|
}
|
|
6331
6461
|
|
|
@@ -6360,12 +6490,12 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6360
6490
|
}
|
|
6361
6491
|
|
|
6362
6492
|
private var locationRecordingStateURL: URL {
|
|
6363
|
-
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
6493
|
+
(recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0])
|
|
6364
6494
|
.appendingPathComponent("craft-location-recording-state.json")
|
|
6365
6495
|
}
|
|
6366
6496
|
|
|
6367
6497
|
private var locationRecordingTrackURL: URL {
|
|
6368
|
-
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
6498
|
+
(recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0])
|
|
6369
6499
|
.appendingPathComponent("craft-location-recording.jsonl")
|
|
6370
6500
|
}
|
|
6371
6501
|
|
|
@@ -6382,54 +6512,142 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6382
6512
|
locationManager?.showsBackgroundLocationIndicator = config.enableBackgroundLocation
|
|
6383
6513
|
}
|
|
6384
6514
|
|
|
6385
|
-
private func persistLocationRecordingState() {
|
|
6386
|
-
let state: [String: Any] = [
|
|
6387
|
-
|
|
6388
|
-
"paused": isLocationRecordingPaused,
|
|
6389
|
-
"id": locationRecordingId ?? NSNull(),
|
|
6390
|
-
"startedAt": locationRecordingStartedAt ?? NSNull(),
|
|
6391
|
-
]
|
|
6392
|
-
do {
|
|
6393
|
-
let directory = locationRecordingStateURL.deletingLastPathComponent()
|
|
6394
|
-
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
6395
|
-
let data = try JSONSerialization.data(withJSONObject: state)
|
|
6396
|
-
try data.write(to: locationRecordingStateURL, options: .atomic)
|
|
6397
|
-
protectLocationFile(locationRecordingStateURL)
|
|
6398
|
-
} catch {
|
|
6399
|
-
print("Unable to persist location recording state: \(error)")
|
|
6400
|
-
}
|
|
6515
|
+
private func persistLocationRecordingState() throws {
|
|
6516
|
+
let state: [String: Any] = ["active": isRecordingLocation, "paused": isLocationRecordingPaused, "id": locationRecordingId ?? NSNull(), "startedAt": locationRecordingStartedAt ?? NSNull()]
|
|
6517
|
+
try CraftRecordingArchive.writeAtomically(JSONSerialization.data(withJSONObject: state), locationRecordingStateURL)
|
|
6401
6518
|
}
|
|
6402
6519
|
|
|
6403
|
-
private func restoreLocationRecordingState() {
|
|
6404
|
-
guard
|
|
6405
|
-
|
|
6406
|
-
|
|
6407
|
-
|
|
6520
|
+
private func restoreLocationRecordingState() throws {
|
|
6521
|
+
guard FileManager.default.fileExists(atPath: locationRecordingStateURL.path) else {
|
|
6522
|
+
if FileManager.default.fileExists(atPath: locationRecordingTrackURL.path), !(try Data(contentsOf: locationRecordingTrackURL)).isEmpty {
|
|
6523
|
+
throw CraftNativeActionError(code: "RECORDING_STORAGE_ERROR", message: "Recording metadata is missing")
|
|
6524
|
+
}
|
|
6525
|
+
return
|
|
6526
|
+
}
|
|
6527
|
+
let data = try Data(contentsOf: locationRecordingStateURL)
|
|
6528
|
+
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
6529
|
+
let active = state["active"] as? Bool else {
|
|
6530
|
+
throw CraftNativeActionError(code: "RECORDING_STORAGE_ERROR", message: "Invalid recording metadata")
|
|
6531
|
+
}
|
|
6532
|
+
isRecordingLocation = active
|
|
6408
6533
|
isLocationRecordingPaused = state["paused"] as? Bool ?? false
|
|
6409
6534
|
locationRecordingId = state["id"] as? String
|
|
6410
6535
|
locationRecordingStartedAt = state["startedAt"] as? TimeInterval
|
|
6411
|
-
if
|
|
6536
|
+
if let id = locationRecordingId, try CraftRecordingArchive.isAcknowledged(recordingBaseDirectory, "location", id) {
|
|
6537
|
+
try CraftRecordingArchive.acknowledge(recordingBaseDirectory, "location", id)
|
|
6538
|
+
if FileManager.default.fileExists(atPath: locationRecordingTrackURL.path) { try FileManager.default.removeItem(at: locationRecordingTrackURL) }
|
|
6539
|
+
locationRecordingId = nil
|
|
6540
|
+
locationRecordingStartedAt = nil
|
|
6541
|
+
isRecordingLocation = false
|
|
6542
|
+
isLocationRecordingPaused = false
|
|
6543
|
+
try persistLocationRecordingState()
|
|
6544
|
+
}
|
|
6545
|
+
if isRecordingLocation && !isLocationRecordingPaused {
|
|
6412
6546
|
locationManager?.delegate = self
|
|
6413
6547
|
configureBackgroundLocationIfNeeded()
|
|
6414
6548
|
locationManager?.startUpdatingLocation()
|
|
6415
6549
|
}
|
|
6416
6550
|
}
|
|
6417
6551
|
|
|
6418
|
-
|
|
6552
|
+
/// Same serial main-queue dispatch as the operation it protects.
|
|
6553
|
+
private func requireLocationRecordingOwner(_ body: [String: Any], callbackId: String?) -> Bool {
|
|
6554
|
+
guard body.keys.contains("expectedId") else { return true }
|
|
6555
|
+
guard let expected = body["expectedId"] as? String,
|
|
6556
|
+
!expected.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
6557
|
+
rejectCallback(callbackId, error: "expectedId must be a nonempty recording ID", code: "INVALID_ARGUMENT")
|
|
6558
|
+
return false
|
|
6559
|
+
}
|
|
6560
|
+
guard locationRecordingId == expected else {
|
|
6561
|
+
rejectCallback(callbackId, error: "The active location recording has changed", code: "RECORDING_CONFLICT")
|
|
6562
|
+
return false
|
|
6563
|
+
}
|
|
6564
|
+
return true
|
|
6565
|
+
}
|
|
6566
|
+
|
|
6567
|
+
private var recordingBaseDirectory: URL {
|
|
6568
|
+
recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
6569
|
+
}
|
|
6570
|
+
|
|
6571
|
+
private func archivedLocationAction(_ action: String, body: [String: Any], callbackId: String?) -> Bool {
|
|
6572
|
+
guard body.keys.contains("expectedId") else {
|
|
6573
|
+
if action == "acknowledgeLocationRecording" {
|
|
6574
|
+
rejectCallback(callbackId, error: "Acknowledgement needs expectedId", code: "INVALID_ARGUMENT")
|
|
6575
|
+
return true
|
|
6576
|
+
}
|
|
6577
|
+
return false
|
|
6578
|
+
}
|
|
6579
|
+
guard let id = body["expectedId"] as? String, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
6580
|
+
rejectCallback(callbackId, error: "expectedId must be a nonempty recording ID", code: "INVALID_ARGUMENT")
|
|
6581
|
+
return true
|
|
6582
|
+
}
|
|
6583
|
+
do {
|
|
6584
|
+
if action == "acknowledgeLocationRecording" {
|
|
6585
|
+
if locationRecordingId == id && isRecordingLocation {
|
|
6586
|
+
rejectCallback(callbackId, error: "The recording is still active", code: "RECORDING_ALREADY_ACTIVE")
|
|
6587
|
+
return true
|
|
6588
|
+
}
|
|
6589
|
+
var known = try locationRecordingId == id || CraftRecordingArchive.isAcknowledged(recordingBaseDirectory, "location", id)
|
|
6590
|
+
if !known { known = try CraftRecordingArchive.read(recordingBaseDirectory, "location", id) != nil }
|
|
6591
|
+
guard known else {
|
|
6592
|
+
rejectCallback(callbackId, error: "Unknown recording ID", code: "RECORDING_CONFLICT")
|
|
6593
|
+
return true
|
|
6594
|
+
}
|
|
6595
|
+
try CraftRecordingArchive.acknowledge(recordingBaseDirectory, "location", id)
|
|
6596
|
+
if locationRecordingId == id {
|
|
6597
|
+
if FileManager.default.fileExists(atPath: locationRecordingTrackURL.path) { try FileManager.default.removeItem(at: locationRecordingTrackURL) }
|
|
6598
|
+
locationRecordingId = nil
|
|
6599
|
+
locationRecordingStartedAt = nil
|
|
6600
|
+
try persistLocationRecordingState()
|
|
6601
|
+
}
|
|
6602
|
+
resolveCallback(callbackId, result: true)
|
|
6603
|
+
return true
|
|
6604
|
+
}
|
|
6605
|
+
if var saved = try CraftRecordingArchive.read(recordingBaseDirectory, "location", id) {
|
|
6606
|
+
if action == "readLocationRecording" { resolveCallback(callbackId, result: saved["locations"] ?? []) }
|
|
6607
|
+
else {
|
|
6608
|
+
if action == "getLocationRecordingState" { saved.removeValue(forKey: "locations") }
|
|
6609
|
+
resolveCallback(callbackId, result: saved)
|
|
6610
|
+
}
|
|
6611
|
+
return true
|
|
6612
|
+
}
|
|
6613
|
+
return !requireLocationRecordingOwner(body, callbackId: callbackId)
|
|
6614
|
+
} catch let error as CraftNativeActionError {
|
|
6615
|
+
rejectCallback(callbackId, error: error.message, code: error.code)
|
|
6616
|
+
} catch { rejectCallback(callbackId, error: "Unable to persist the recording", code: "RECORDING_STORAGE_ERROR") }
|
|
6617
|
+
return true
|
|
6618
|
+
}
|
|
6619
|
+
|
|
6620
|
+
private func startLocationRecording(body: [String: Any], callbackId: String?) {
|
|
6621
|
+
guard !isRecordingLocation else {
|
|
6622
|
+
rejectCallback(callbackId, error: "A location recording is already active", code: "RECORDING_ALREADY_ACTIVE")
|
|
6623
|
+
return
|
|
6624
|
+
}
|
|
6419
6625
|
let directory = locationRecordingTrackURL.deletingLastPathComponent()
|
|
6626
|
+
let id: String
|
|
6420
6627
|
do {
|
|
6628
|
+
id = try CraftRecordingArchive.startId(body, directory: recordingBaseDirectory, kind: "location", currentId: locationRecordingId)
|
|
6629
|
+
try CraftRecordingArchive.save(recordingBaseDirectory, "location", locationRecordingSummary(), samples: try loadRecordedLocationsStrict())
|
|
6421
6630
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
6422
6631
|
try Data().write(to: locationRecordingTrackURL, options: .atomic)
|
|
6423
6632
|
protectLocationFile(locationRecordingTrackURL)
|
|
6633
|
+
} catch let error as CraftNativeActionError {
|
|
6634
|
+
rejectCallback(callbackId, error: error.message, code: error.code)
|
|
6635
|
+
return
|
|
6424
6636
|
} catch {
|
|
6425
6637
|
rejectCallback(callbackId, error: "Unable to create the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
6426
6638
|
return
|
|
6427
6639
|
}
|
|
6428
|
-
|
|
6640
|
+
let previous = (locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused)
|
|
6641
|
+
locationRecordingId = id
|
|
6429
6642
|
locationRecordingStartedAt = Date().timeIntervalSince1970 * 1000
|
|
6430
6643
|
isRecordingLocation = true
|
|
6431
6644
|
isLocationRecordingPaused = false
|
|
6432
|
-
persistLocationRecordingState()
|
|
6645
|
+
do { try persistLocationRecordingState() }
|
|
6646
|
+
catch {
|
|
6647
|
+
(locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused) = previous
|
|
6648
|
+
rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
6649
|
+
return
|
|
6650
|
+
}
|
|
6433
6651
|
locationManager?.delegate = self
|
|
6434
6652
|
requestLocationAuthorization()
|
|
6435
6653
|
configureBackgroundLocationIfNeeded()
|
|
@@ -6442,8 +6660,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6442
6660
|
rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
|
|
6443
6661
|
return
|
|
6444
6662
|
}
|
|
6663
|
+
let previous = isLocationRecordingPaused
|
|
6445
6664
|
isLocationRecordingPaused = true
|
|
6446
|
-
persistLocationRecordingState()
|
|
6665
|
+
do { try persistLocationRecordingState() }
|
|
6666
|
+
catch {
|
|
6667
|
+
isLocationRecordingPaused = previous
|
|
6668
|
+
rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
6669
|
+
return
|
|
6670
|
+
}
|
|
6447
6671
|
if !isWatchingLocation { locationManager?.stopUpdatingLocation() }
|
|
6448
6672
|
resolveCallback(callbackId, result: locationRecordingSummary())
|
|
6449
6673
|
}
|
|
@@ -6453,8 +6677,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6453
6677
|
rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
|
|
6454
6678
|
return
|
|
6455
6679
|
}
|
|
6680
|
+
let previous = isLocationRecordingPaused
|
|
6456
6681
|
isLocationRecordingPaused = false
|
|
6457
|
-
persistLocationRecordingState()
|
|
6682
|
+
do { try persistLocationRecordingState() }
|
|
6683
|
+
catch {
|
|
6684
|
+
isLocationRecordingPaused = previous
|
|
6685
|
+
rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
6686
|
+
return
|
|
6687
|
+
}
|
|
6458
6688
|
// Set here too: a recording restored at launch while paused never
|
|
6459
6689
|
// got a delegate, so resuming it started updates nobody received
|
|
6460
6690
|
// and the route stopped at the pause.
|
|
@@ -6466,17 +6696,21 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6466
6696
|
}
|
|
6467
6697
|
|
|
6468
6698
|
private func stopLocationRecording(callbackId: String?) {
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6699
|
+
do {
|
|
6700
|
+
let locations = try loadRecordedLocationsStrict()
|
|
6701
|
+
let previous = (isRecordingLocation, isLocationRecordingPaused)
|
|
6702
|
+
isRecordingLocation = false
|
|
6703
|
+
isLocationRecordingPaused = false
|
|
6704
|
+
do { try persistLocationRecordingState() }
|
|
6705
|
+
catch { (isRecordingLocation, isLocationRecordingPaused) = previous; throw error }
|
|
6706
|
+
if !isWatchingLocation {
|
|
6707
|
+
locationManager?.stopUpdatingLocation()
|
|
6708
|
+
locationManager?.allowsBackgroundLocationUpdates = false
|
|
6709
|
+
}
|
|
6710
|
+
var summary = locationRecordingSummary()
|
|
6711
|
+
summary["locations"] = locations
|
|
6712
|
+
resolveCallback(callbackId, result: summary)
|
|
6713
|
+
} catch { rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR") }
|
|
6480
6714
|
}
|
|
6481
6715
|
|
|
6482
6716
|
private func getLocationRecordingState(callbackId: String?) {
|
|
@@ -6486,7 +6720,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6486
6720
|
}
|
|
6487
6721
|
|
|
6488
6722
|
private func readLocationRecording(callbackId: String?) {
|
|
6489
|
-
resolveCallback(callbackId, result:
|
|
6723
|
+
do { resolveCallback(callbackId, result: try loadRecordedLocationsStrict()) }
|
|
6724
|
+
catch { rejectCallback(callbackId, error: "Unable to read the location recording", code: "RECORDING_STORAGE_ERROR") }
|
|
6490
6725
|
}
|
|
6491
6726
|
|
|
6492
6727
|
private func locationRecordingSummary() -> [String: Any] {
|
|
@@ -6521,8 +6756,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6521
6756
|
try? protectedURL.setResourceValues(values)
|
|
6522
6757
|
}
|
|
6523
6758
|
|
|
6524
|
-
private func loadRecordedLocations() -> [[String: Any]] {
|
|
6525
|
-
|
|
6759
|
+
private func loadRecordedLocations() -> [[String: Any]] { (try? loadRecordedLocationsStrict()) ?? [] }
|
|
6760
|
+
|
|
6761
|
+
private func loadRecordedLocationsStrict() throws -> [[String: Any]] {
|
|
6762
|
+
if locationRecordingId == nil { return [] }
|
|
6763
|
+
let text = try String(contentsOf: locationRecordingTrackURL, encoding: .utf8)
|
|
6526
6764
|
return text.split(separator: "\n").compactMap { line in
|
|
6527
6765
|
guard let data = line.data(using: .utf8) else { return nil }
|
|
6528
6766
|
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
@@ -6531,13 +6769,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6531
6769
|
|
|
6532
6770
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
6533
6771
|
guard let location = locations.last else { return }
|
|
6534
|
-
|
|
6772
|
+
var data = locationData(location)
|
|
6535
6773
|
|
|
6536
6774
|
if let callbackId = singleLocationCallbackId {
|
|
6537
6775
|
finishSingleLocationRequest()
|
|
6538
6776
|
resolveCallback(callbackId, result: data)
|
|
6539
6777
|
}
|
|
6540
6778
|
|
|
6779
|
+
if isRecordingLocation, let id = locationRecordingId { data["recordingId"] = id }
|
|
6541
6780
|
appendRecordedLocation(data)
|
|
6542
6781
|
if isWatchingLocation || isRecordingLocation {
|
|
6543
6782
|
sendToWeb("craftLocationUpdate", data: data)
|
|
@@ -6546,6 +6785,10 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6546
6785
|
|
|
6547
6786
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
6548
6787
|
let nativeError = error as NSError
|
|
6788
|
+
if nativeError.domain == kCLErrorDomain, nativeError.code == CLError.Code.denied.rawValue,
|
|
6789
|
+
manager.authorizationStatus == .notDetermined, singleLocationCallbackId != nil, !singleLocationAcquisitionStarted {
|
|
6790
|
+
return
|
|
6791
|
+
}
|
|
6549
6792
|
// kCLErrorLocationUnknown is Core Location saying it has no fix
|
|
6550
6793
|
// *yet*, not that it failed. requestLocation gives up on it, so a
|
|
6551
6794
|
// one-shot asks again shortly instead of failing a caller whose
|
|
@@ -6554,7 +6797,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6554
6797
|
if nativeError.domain == kCLErrorDomain && nativeError.code == CLError.Code.locationUnknown.rawValue {
|
|
6555
6798
|
if let waiting = singleLocationCallbackId {
|
|
6556
6799
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in
|
|
6557
|
-
guard let self, self.singleLocationCallbackId == waiting else { return }
|
|
6800
|
+
guard let self, self.singleLocationCallbackId == waiting, self.singleLocationAcquisitionStarted else { return }
|
|
6558
6801
|
self.locationManager?.requestLocation()
|
|
6559
6802
|
}
|
|
6560
6803
|
}
|
|
@@ -6570,6 +6813,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6570
6813
|
}
|
|
6571
6814
|
|
|
6572
6815
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
6816
|
+
if singleLocationCallbackId != nil { continueSingleLocationRequest(manager) }
|
|
6573
6817
|
guard let callbackId = locationPermissionCallbackId else { return }
|
|
6574
6818
|
let status = manager.authorizationStatus
|
|
6575
6819
|
if status == .notDetermined { return }
|
|
@@ -8832,6 +9076,60 @@ final class CraftZigRuntime: NSObject {
|
|
|
8832
9076
|
UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64
|
|
8833
9077
|
) -> Bool
|
|
8834
9078
|
private typealias AdoptLocationRecordingFn = @convention(c) () -> Bool
|
|
9079
|
+
private typealias RecordingOwnershipVersionFn = @convention(c) () -> Int32
|
|
9080
|
+
private typealias RecordingReplyFn = @convention(c) (UnsafeMutableRawPointer?, Bool, UnsafePointer<CChar>, UInt) -> Void
|
|
9081
|
+
private typealias PerformRecordingFn = @convention(c) (UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, UnsafeMutableRawPointer?, RecordingReplyFn) -> Bool
|
|
9082
|
+
private final class RecordingReply {
|
|
9083
|
+
var answered = false
|
|
9084
|
+
var result: Result<Any, CraftNativeActionError>?
|
|
9085
|
+
|
|
9086
|
+
}
|
|
9087
|
+
private static let performRecordingFn: PerformRecordingFn? = {
|
|
9088
|
+
guard let sym = dlsym(image, "craft_ios_perform_recording_action") else { return nil }
|
|
9089
|
+
return unsafeBitCast(sym, to: PerformRecordingFn.self)
|
|
9090
|
+
}()
|
|
9091
|
+
|
|
9092
|
+
/// Synchronous native recorder requests use the exact page owner and reply
|
|
9093
|
+
/// envelope, without loading a page or creating a second location manager.
|
|
9094
|
+
static func performRecording(action: String, body: [String: Any], completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> Bool {
|
|
9095
|
+
guard supportsRecordingOwnership, let performRecordingFn else {
|
|
9096
|
+
if !isLinked { return false }
|
|
9097
|
+
completion(.failure(CraftNativeActionError(code: "RECORDING_OWNERSHIP_UNSUPPORTED", message: "The linked runtime lacks the shared recorder API")))
|
|
9098
|
+
return true
|
|
9099
|
+
}
|
|
9100
|
+
guard let payload = try? JSONSerialization.data(withJSONObject: body),
|
|
9101
|
+
let json = String(data: payload, encoding: .utf8) else {
|
|
9102
|
+
completion(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "Recording options must be JSON")))
|
|
9103
|
+
return true
|
|
9104
|
+
}
|
|
9105
|
+
let reply = RecordingReply()
|
|
9106
|
+
let served = withExtendedLifetime(reply) {
|
|
9107
|
+
action.withCString { a in json.withCString { p in
|
|
9108
|
+
performRecordingFn(a, UInt(strlen(a)), p, UInt(strlen(p)), Unmanaged.passUnretained(reply).toOpaque()) { context, failed, bytes, count in
|
|
9109
|
+
guard let context else { return }
|
|
9110
|
+
let reply = Unmanaged<RecordingReply>.fromOpaque(context).takeUnretainedValue()
|
|
9111
|
+
guard !reply.answered else { return }
|
|
9112
|
+
reply.answered = true
|
|
9113
|
+
let data = Data(bytes: bytes, count: Int(count))
|
|
9114
|
+
guard let result = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) else {
|
|
9115
|
+
reply.result = .failure(CraftNativeActionError(code: "INVALID_RESPONSE", message: "Invalid recorder response"))
|
|
9116
|
+
return
|
|
9117
|
+
}
|
|
9118
|
+
if failed {
|
|
9119
|
+
let error = result as? [String: Any] ?? [:]
|
|
9120
|
+
reply.result = .failure(CraftNativeActionError(code: error["code"] as? String ?? "CRAFT_ERROR", message: error["message"] as? String ?? "Recording failed"))
|
|
9121
|
+
} else { reply.result = .success(result) }
|
|
9122
|
+
}
|
|
9123
|
+
} }
|
|
9124
|
+
}
|
|
9125
|
+
// The C scope has ended before application code can reenter a bridge.
|
|
9126
|
+
if served, let result = reply.result { completion(result) }
|
|
9127
|
+
else {
|
|
9128
|
+
completion(.failure(CraftNativeActionError(code: "RECORDING_OWNERSHIP_UNSUPPORTED", message: "The linked recorder did not serve its synchronous request")))
|
|
9129
|
+
}
|
|
9130
|
+
return true
|
|
9131
|
+
}
|
|
9132
|
+
|
|
8835
9133
|
|
|
8836
9134
|
private static let image: UnsafeMutableRawPointer? = dlopen(nil, RTLD_NOW)
|
|
8837
9135
|
|
|
@@ -8859,6 +9157,19 @@ final class CraftZigRuntime: NSObject {
|
|
|
8859
9157
|
/// because "is Zig linked" is the first question to ask when an action
|
|
8860
9158
|
/// behaves like the Swift one after a migration was supposed to move it.
|
|
8861
9159
|
@objc static var isLinked: Bool { handleActionFn != nil }
|
|
9160
|
+
static var supportsRecordingOwnership: Bool {
|
|
9161
|
+
if !isLinked { return true }
|
|
9162
|
+
guard performRecordingFn != nil,
|
|
9163
|
+
let symbol = dlsym(image, "craft_ios_recording_ownership_version") else { return false }
|
|
9164
|
+
return unsafeBitCast(symbol, to: RecordingOwnershipVersionFn.self)() >= 1
|
|
9165
|
+
}
|
|
9166
|
+
|
|
9167
|
+
static var supportsRecordingIntentIdentity: Bool {
|
|
9168
|
+
if !isLinked { return true }
|
|
9169
|
+
guard performRecordingFn != nil,
|
|
9170
|
+
let symbol = dlsym(image, "craft_ios_recording_ownership_version") else { return false }
|
|
9171
|
+
return unsafeBitCast(symbol, to: RecordingOwnershipVersionFn.self)() >= 2
|
|
9172
|
+
}
|
|
8862
9173
|
|
|
8863
9174
|
/// Give Zig the webview its replies are evaluated against.
|
|
8864
9175
|
///
|
|
@@ -8979,6 +9290,9 @@ enum CraftWebServices {
|
|
|
8979
9290
|
completion(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "\(action) needs the app's web view, which this app does not have")))
|
|
8980
9291
|
return
|
|
8981
9292
|
}
|
|
9293
|
+
if action.hasSuffix("LocationRecording") || action == "getLocationRecordingState" {
|
|
9294
|
+
if CraftZigRuntime.performRecording(action: action, body: body, completion: completion) { return }
|
|
9295
|
+
}
|
|
8982
9296
|
let id = "native:\(UUID().uuidString)"
|
|
8983
9297
|
waiting[id] = completion
|
|
8984
9298
|
coordinator.dispatch(action: action, body: body, callbackId: id)
|
|
@@ -9021,6 +9335,17 @@ enum CraftWebServices {
|
|
|
9021
9335
|
/// how this codebase accumulated five envelopes.
|
|
9022
9336
|
@objc(CraftSwiftShim)
|
|
9023
9337
|
final class CraftSwiftShim: NSObject {
|
|
9338
|
+
/// Called on main by Zig after serializing its native event once. The
|
|
9339
|
+
/// existing page delivery remains in Zig; these are the native listeners.
|
|
9340
|
+
@objc(forwardNativeEvent:payload:)
|
|
9341
|
+
static func forwardNativeEvent(_ event: String, payload: String) {
|
|
9342
|
+
guard CraftWebServices.forwardedEvents.contains(event),
|
|
9343
|
+
let bytes = payload.data(using: .utf8),
|
|
9344
|
+
let detail = try? JSONSerialization.jsonObject(with: bytes) as? [String: Any] else { return }
|
|
9345
|
+
CraftWebServices.forward(event, data: detail)
|
|
9346
|
+
CraftEventManager.shared.sendToAppScript(event, data: detail)
|
|
9347
|
+
}
|
|
9348
|
+
|
|
9024
9349
|
/// The coordinator serving hand-offs. Set by `makeCoordinator`, or in an
|
|
9025
9350
|
/// app with no web view by `CraftHybrid.configure`, which holds it.
|
|
9026
9351
|
static weak var coordinator: CraftWebView.Coordinator?
|