@craft-native/ios 0.0.237 → 0.0.238
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 +9 -1
- package/dist/testing.d.ts +2 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +376 -88
- package/templates/CraftHeartRate.swift +141 -44
- package/templates/CraftNativeActions.swift +46 -48
- package/templates/CraftNativeScreen.swift +11 -4
- package/templates/CraftPersistentStorage.swift +99 -0
- package/templates/CraftRecordingArchive.swift +85 -0
- package/templates/testing/DriverTests.swift.template +8 -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 {
|
|
@@ -1935,6 +1998,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1935
1998
|
private var isLocationRecordingPaused = false
|
|
1936
1999
|
private var locationRecordingId: String?
|
|
1937
2000
|
private var locationRecordingStartedAt: TimeInterval?
|
|
2001
|
+
private let recordingDirectory: URL?
|
|
2002
|
+
private var locationRecordingRestoreError: Error?
|
|
1938
2003
|
|
|
1939
2004
|
// Network monitoring
|
|
1940
2005
|
private var networkMonitor: NWPathMonitor?
|
|
@@ -2000,7 +2065,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2000
2065
|
// SQLite database
|
|
2001
2066
|
private var db: OpaquePointer?
|
|
2002
2067
|
|
|
2003
|
-
init(config: CraftConfig) {
|
|
2068
|
+
init(config: CraftConfig, recordingDirectory: URL? = nil) {
|
|
2069
|
+
self.recordingDirectory = recordingDirectory
|
|
2004
2070
|
self.config = config
|
|
2005
2071
|
super.init()
|
|
2006
2072
|
if config.enableSpeechRecognition {
|
|
@@ -2015,7 +2081,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2015
2081
|
// relaunch restore — see `CraftZigRuntime.adoptLocationRecording`.
|
|
2016
2082
|
// Running both would leave two managers appending to one track.
|
|
2017
2083
|
if !CraftZigRuntime.adoptLocationRecording() {
|
|
2018
|
-
restoreLocationRecordingState()
|
|
2084
|
+
do { try restoreLocationRecordingState() }
|
|
2085
|
+
catch { locationRecordingRestoreError = error }
|
|
2019
2086
|
}
|
|
2020
2087
|
}
|
|
2021
2088
|
if config.enableContacts {
|
|
@@ -2165,6 +2232,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2165
2232
|
/// `craft_ios_deliver_result` instead of evaluating JavaScript here —
|
|
2166
2233
|
/// one reply path, owned by whichever side received the page's message.
|
|
2167
2234
|
func dispatch(action: String, body: [String: Any], callbackId: String?) {
|
|
2235
|
+
if ["startLocationRecording", "pauseLocationRecording", "resumeLocationRecording", "stopLocationRecording", "getLocationRecordingState", "readLocationRecording", "acknowledgeLocationRecording"].contains(action),
|
|
2236
|
+
locationRecordingRestoreError != nil {
|
|
2237
|
+
rejectCallback(callbackId, error: "Unable to restore recording data", code: "RECORDING_STORAGE_ERROR")
|
|
2238
|
+
return
|
|
2239
|
+
}
|
|
2240
|
+
if ["getLocationRecordingState", "readLocationRecording", "stopLocationRecording"].contains(action),
|
|
2241
|
+
archivedLocationAction(action, body: body, callbackId: callbackId) { return }
|
|
2168
2242
|
if let answer = CraftNativeActions.perform(action: action, body: body, config: config) {
|
|
2169
2243
|
switch answer {
|
|
2170
2244
|
case .success(let value): resolveCallback(callbackId, result: value)
|
|
@@ -2361,20 +2435,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2361
2435
|
resolveCallback(callbackId, result: true)
|
|
2362
2436
|
case "startLocationRecording":
|
|
2363
2437
|
if config.enableGeolocation {
|
|
2364
|
-
startLocationRecording(callbackId: callbackId)
|
|
2438
|
+
startLocationRecording(body: body, callbackId: callbackId)
|
|
2365
2439
|
} else {
|
|
2366
2440
|
rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
|
|
2367
2441
|
}
|
|
2368
2442
|
case "pauseLocationRecording":
|
|
2369
|
-
pauseLocationRecording(callbackId: callbackId)
|
|
2443
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { pauseLocationRecording(callbackId: callbackId) }
|
|
2370
2444
|
case "resumeLocationRecording":
|
|
2371
|
-
resumeLocationRecording(callbackId: callbackId)
|
|
2445
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { resumeLocationRecording(callbackId: callbackId) }
|
|
2372
2446
|
case "stopLocationRecording":
|
|
2373
|
-
stopLocationRecording(callbackId: callbackId)
|
|
2447
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { stopLocationRecording(callbackId: callbackId) }
|
|
2374
2448
|
case "getLocationRecordingState":
|
|
2375
2449
|
getLocationRecordingState(callbackId: callbackId)
|
|
2376
2450
|
case "readLocationRecording":
|
|
2377
|
-
readLocationRecording(callbackId: callbackId)
|
|
2451
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { readLocationRecording(callbackId: callbackId) }
|
|
2452
|
+
case "acknowledgeLocationRecording":
|
|
2453
|
+
_ = archivedLocationAction(action, body: body, callbackId: callbackId)
|
|
2378
2454
|
// App Badge
|
|
2379
2455
|
case "setBadge":
|
|
2380
2456
|
if let count = body["count"] as? Int {
|
|
@@ -3682,6 +3758,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3682
3758
|
pushNotifications: \(config.enablePushNotifications),
|
|
3683
3759
|
secureStorage: \(config.enableSecureStorage),
|
|
3684
3760
|
geolocation: \(config.enableGeolocation),
|
|
3761
|
+
crypto: true,
|
|
3762
|
+
locationRecordingOwnership: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingOwnership),
|
|
3763
|
+
locationRecordingIntentIdentity: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingIntentIdentity),
|
|
3685
3764
|
backgroundLocation: \(config.enableBackgroundLocation),
|
|
3686
3765
|
healthKit: \(config.enableHealthKit),
|
|
3687
3766
|
liveActivities: \(config.enableLiveActivities),
|
|
@@ -3698,6 +3777,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3698
3777
|
brightness: true,
|
|
3699
3778
|
music: \(config.enableAppleMusic),
|
|
3700
3779
|
heartRate: \(config.enableHeartRate),
|
|
3780
|
+
heartRateRecordingOwnership: \(config.enableHeartRate),
|
|
3781
|
+
heartRateRecordingIntentIdentity: \(config.enableHeartRate),
|
|
3701
3782
|
scheduledWorkouts: \(config.enableScheduledWorkouts),
|
|
3702
3783
|
speech: true,
|
|
3703
3784
|
crashReporter: \(CraftCrashReporter.shared.enabled),
|
|
@@ -5282,6 +5363,20 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5282
5363
|
browserOpen: true
|
|
5283
5364
|
};
|
|
5284
5365
|
craft._invoke = function(action, payload) {
|
|
5366
|
+
var recordingOptions = action === 'heartRate' && payload && ['startRecording', 'pauseRecording', 'resumeRecording', 'stopRecording', 'readRecording', 'getRecordingState', 'acknowledgeRecording'].indexOf(payload.method) !== -1
|
|
5367
|
+
? payload.options : ['startLocationRecording', 'pauseLocationRecording', 'resumeLocationRecording', 'stopLocationRecording', 'readLocationRecording', 'getLocationRecordingState', 'acknowledgeLocationRecording'].indexOf(action) !== -1 ? payload : null;
|
|
5368
|
+
if (recordingOptions && Object.prototype.hasOwnProperty.call(recordingOptions, 'recordingId') &&
|
|
5369
|
+
(typeof recordingOptions.recordingId !== 'string' || !recordingOptions.recordingId.trim())) {
|
|
5370
|
+
var invalidStart = new Error('recordingId must be a nonempty recording ID');
|
|
5371
|
+
invalidStart.code = 'INVALID_ARGUMENT';
|
|
5372
|
+
return Promise.reject(invalidStart);
|
|
5373
|
+
}
|
|
5374
|
+
if (recordingOptions && Object.prototype.hasOwnProperty.call(recordingOptions, 'expectedId') &&
|
|
5375
|
+
(typeof recordingOptions.expectedId !== 'string' || !recordingOptions.expectedId.trim())) {
|
|
5376
|
+
var invalid = new Error('expectedId must be a nonempty recording ID');
|
|
5377
|
+
invalid.code = 'INVALID_ARGUMENT';
|
|
5378
|
+
return Promise.reject(invalid);
|
|
5379
|
+
}
|
|
5285
5380
|
var self = craft;
|
|
5286
5381
|
var id = 'cb_' + (++self._callbackId);
|
|
5287
5382
|
var message = Object.assign({}, payload || {}, {action: action, callbackId: id});
|
|
@@ -5419,6 +5514,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5419
5514
|
legacyLocationWatchCallbacks.length = 0;
|
|
5420
5515
|
return stopLocationWatchIfUnused();
|
|
5421
5516
|
};
|
|
5517
|
+
craft.crypto = { randomUUID: function() { return craft._invoke('cryptoRandomUUID'); } };
|
|
5422
5518
|
craft.location = {
|
|
5423
5519
|
getCurrentPosition: function(options) { return legacyGeolocation.getCurrentPosition(options || {}); },
|
|
5424
5520
|
watchPosition: function(callback) {
|
|
@@ -5437,12 +5533,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5437
5533
|
if (!locationWatchCallbacks.delete(id)) return;
|
|
5438
5534
|
void stopLocationWatchIfUnused().catch(function() {});
|
|
5439
5535
|
},
|
|
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
|
-
|
|
5536
|
+
startRecording: function(options) { return craft._invoke('startLocationRecording', options || {}); },
|
|
5537
|
+
pauseRecording: function(options) { return craft._invoke('pauseLocationRecording', options || {}); },
|
|
5538
|
+
resumeRecording: function(options) { return craft._invoke('resumeLocationRecording', options || {}); },
|
|
5539
|
+
stopRecording: function(options) { return craft._invoke('stopLocationRecording', options || {}); },
|
|
5540
|
+
getRecordingState: function(options) { return craft._invoke('getLocationRecordingState', options || {}); },
|
|
5541
|
+
acknowledgeRecording: function(options) { return craft._invoke('acknowledgeLocationRecording', options || {}); },
|
|
5542
|
+
readRecording: function(options) { return craft._invoke('readLocationRecording', options || {}); }
|
|
5446
5543
|
};
|
|
5447
5544
|
craft.health = {
|
|
5448
5545
|
requestAuthorization: function(types, options) {
|
|
@@ -5530,12 +5627,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5530
5627
|
connect: function(id) { return heartRate('connect', {id: typeof id === 'string' ? id : id && id.id}); },
|
|
5531
5628
|
reconnect: function() { return heartRate('reconnect'); },
|
|
5532
5629
|
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
|
-
|
|
5630
|
+
startRecording: function(options) { return heartRate('startRecording', options || {}); },
|
|
5631
|
+
pauseRecording: function(options) { return heartRate('pauseRecording', options || {}); },
|
|
5632
|
+
resumeRecording: function(options) { return heartRate('resumeRecording', options || {}); },
|
|
5633
|
+
stopRecording: function(options) { return heartRate('stopRecording', options || {}); },
|
|
5634
|
+
getRecordingState: function(options) { return heartRate('getRecordingState', options || {}); },
|
|
5635
|
+
acknowledgeRecording: function(options) { return heartRate('acknowledgeRecording', options || {}); },
|
|
5636
|
+
readRecording: function(options) { return heartRate('readRecording', options || {}); }
|
|
5539
5637
|
};
|
|
5540
5638
|
})();
|
|
5541
5639
|
// Structured workouts on the paired Apple Watch (WorkoutKit,
|
|
@@ -6360,12 +6458,12 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6360
6458
|
}
|
|
6361
6459
|
|
|
6362
6460
|
private var locationRecordingStateURL: URL {
|
|
6363
|
-
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
6461
|
+
(recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0])
|
|
6364
6462
|
.appendingPathComponent("craft-location-recording-state.json")
|
|
6365
6463
|
}
|
|
6366
6464
|
|
|
6367
6465
|
private var locationRecordingTrackURL: URL {
|
|
6368
|
-
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
6466
|
+
(recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0])
|
|
6369
6467
|
.appendingPathComponent("craft-location-recording.jsonl")
|
|
6370
6468
|
}
|
|
6371
6469
|
|
|
@@ -6382,54 +6480,142 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6382
6480
|
locationManager?.showsBackgroundLocationIndicator = config.enableBackgroundLocation
|
|
6383
6481
|
}
|
|
6384
6482
|
|
|
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
|
-
}
|
|
6483
|
+
private func persistLocationRecordingState() throws {
|
|
6484
|
+
let state: [String: Any] = ["active": isRecordingLocation, "paused": isLocationRecordingPaused, "id": locationRecordingId ?? NSNull(), "startedAt": locationRecordingStartedAt ?? NSNull()]
|
|
6485
|
+
try CraftRecordingArchive.writeAtomically(JSONSerialization.data(withJSONObject: state), locationRecordingStateURL)
|
|
6401
6486
|
}
|
|
6402
6487
|
|
|
6403
|
-
private func restoreLocationRecordingState() {
|
|
6404
|
-
guard
|
|
6405
|
-
|
|
6406
|
-
|
|
6407
|
-
|
|
6488
|
+
private func restoreLocationRecordingState() throws {
|
|
6489
|
+
guard FileManager.default.fileExists(atPath: locationRecordingStateURL.path) else {
|
|
6490
|
+
if FileManager.default.fileExists(atPath: locationRecordingTrackURL.path), !(try Data(contentsOf: locationRecordingTrackURL)).isEmpty {
|
|
6491
|
+
throw CraftNativeActionError(code: "RECORDING_STORAGE_ERROR", message: "Recording metadata is missing")
|
|
6492
|
+
}
|
|
6493
|
+
return
|
|
6494
|
+
}
|
|
6495
|
+
let data = try Data(contentsOf: locationRecordingStateURL)
|
|
6496
|
+
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
6497
|
+
let active = state["active"] as? Bool else {
|
|
6498
|
+
throw CraftNativeActionError(code: "RECORDING_STORAGE_ERROR", message: "Invalid recording metadata")
|
|
6499
|
+
}
|
|
6500
|
+
isRecordingLocation = active
|
|
6408
6501
|
isLocationRecordingPaused = state["paused"] as? Bool ?? false
|
|
6409
6502
|
locationRecordingId = state["id"] as? String
|
|
6410
6503
|
locationRecordingStartedAt = state["startedAt"] as? TimeInterval
|
|
6411
|
-
if
|
|
6504
|
+
if let id = locationRecordingId, try CraftRecordingArchive.isAcknowledged(recordingBaseDirectory, "location", id) {
|
|
6505
|
+
try CraftRecordingArchive.acknowledge(recordingBaseDirectory, "location", id)
|
|
6506
|
+
if FileManager.default.fileExists(atPath: locationRecordingTrackURL.path) { try FileManager.default.removeItem(at: locationRecordingTrackURL) }
|
|
6507
|
+
locationRecordingId = nil
|
|
6508
|
+
locationRecordingStartedAt = nil
|
|
6509
|
+
isRecordingLocation = false
|
|
6510
|
+
isLocationRecordingPaused = false
|
|
6511
|
+
try persistLocationRecordingState()
|
|
6512
|
+
}
|
|
6513
|
+
if isRecordingLocation && !isLocationRecordingPaused {
|
|
6412
6514
|
locationManager?.delegate = self
|
|
6413
6515
|
configureBackgroundLocationIfNeeded()
|
|
6414
6516
|
locationManager?.startUpdatingLocation()
|
|
6415
6517
|
}
|
|
6416
6518
|
}
|
|
6417
6519
|
|
|
6418
|
-
|
|
6520
|
+
/// Same serial main-queue dispatch as the operation it protects.
|
|
6521
|
+
private func requireLocationRecordingOwner(_ body: [String: Any], callbackId: String?) -> Bool {
|
|
6522
|
+
guard body.keys.contains("expectedId") else { return true }
|
|
6523
|
+
guard let expected = body["expectedId"] as? String,
|
|
6524
|
+
!expected.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
6525
|
+
rejectCallback(callbackId, error: "expectedId must be a nonempty recording ID", code: "INVALID_ARGUMENT")
|
|
6526
|
+
return false
|
|
6527
|
+
}
|
|
6528
|
+
guard locationRecordingId == expected else {
|
|
6529
|
+
rejectCallback(callbackId, error: "The active location recording has changed", code: "RECORDING_CONFLICT")
|
|
6530
|
+
return false
|
|
6531
|
+
}
|
|
6532
|
+
return true
|
|
6533
|
+
}
|
|
6534
|
+
|
|
6535
|
+
private var recordingBaseDirectory: URL {
|
|
6536
|
+
recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
6537
|
+
}
|
|
6538
|
+
|
|
6539
|
+
private func archivedLocationAction(_ action: String, body: [String: Any], callbackId: String?) -> Bool {
|
|
6540
|
+
guard body.keys.contains("expectedId") else {
|
|
6541
|
+
if action == "acknowledgeLocationRecording" {
|
|
6542
|
+
rejectCallback(callbackId, error: "Acknowledgement needs expectedId", code: "INVALID_ARGUMENT")
|
|
6543
|
+
return true
|
|
6544
|
+
}
|
|
6545
|
+
return false
|
|
6546
|
+
}
|
|
6547
|
+
guard let id = body["expectedId"] as? String, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
6548
|
+
rejectCallback(callbackId, error: "expectedId must be a nonempty recording ID", code: "INVALID_ARGUMENT")
|
|
6549
|
+
return true
|
|
6550
|
+
}
|
|
6551
|
+
do {
|
|
6552
|
+
if action == "acknowledgeLocationRecording" {
|
|
6553
|
+
if locationRecordingId == id && isRecordingLocation {
|
|
6554
|
+
rejectCallback(callbackId, error: "The recording is still active", code: "RECORDING_ALREADY_ACTIVE")
|
|
6555
|
+
return true
|
|
6556
|
+
}
|
|
6557
|
+
var known = try locationRecordingId == id || CraftRecordingArchive.isAcknowledged(recordingBaseDirectory, "location", id)
|
|
6558
|
+
if !known { known = try CraftRecordingArchive.read(recordingBaseDirectory, "location", id) != nil }
|
|
6559
|
+
guard known else {
|
|
6560
|
+
rejectCallback(callbackId, error: "Unknown recording ID", code: "RECORDING_CONFLICT")
|
|
6561
|
+
return true
|
|
6562
|
+
}
|
|
6563
|
+
try CraftRecordingArchive.acknowledge(recordingBaseDirectory, "location", id)
|
|
6564
|
+
if locationRecordingId == id {
|
|
6565
|
+
if FileManager.default.fileExists(atPath: locationRecordingTrackURL.path) { try FileManager.default.removeItem(at: locationRecordingTrackURL) }
|
|
6566
|
+
locationRecordingId = nil
|
|
6567
|
+
locationRecordingStartedAt = nil
|
|
6568
|
+
try persistLocationRecordingState()
|
|
6569
|
+
}
|
|
6570
|
+
resolveCallback(callbackId, result: true)
|
|
6571
|
+
return true
|
|
6572
|
+
}
|
|
6573
|
+
if var saved = try CraftRecordingArchive.read(recordingBaseDirectory, "location", id) {
|
|
6574
|
+
if action == "readLocationRecording" { resolveCallback(callbackId, result: saved["locations"] ?? []) }
|
|
6575
|
+
else {
|
|
6576
|
+
if action == "getLocationRecordingState" { saved.removeValue(forKey: "locations") }
|
|
6577
|
+
resolveCallback(callbackId, result: saved)
|
|
6578
|
+
}
|
|
6579
|
+
return true
|
|
6580
|
+
}
|
|
6581
|
+
return !requireLocationRecordingOwner(body, callbackId: callbackId)
|
|
6582
|
+
} catch let error as CraftNativeActionError {
|
|
6583
|
+
rejectCallback(callbackId, error: error.message, code: error.code)
|
|
6584
|
+
} catch { rejectCallback(callbackId, error: "Unable to persist the recording", code: "RECORDING_STORAGE_ERROR") }
|
|
6585
|
+
return true
|
|
6586
|
+
}
|
|
6587
|
+
|
|
6588
|
+
private func startLocationRecording(body: [String: Any], callbackId: String?) {
|
|
6589
|
+
guard !isRecordingLocation else {
|
|
6590
|
+
rejectCallback(callbackId, error: "A location recording is already active", code: "RECORDING_ALREADY_ACTIVE")
|
|
6591
|
+
return
|
|
6592
|
+
}
|
|
6419
6593
|
let directory = locationRecordingTrackURL.deletingLastPathComponent()
|
|
6594
|
+
let id: String
|
|
6420
6595
|
do {
|
|
6596
|
+
id = try CraftRecordingArchive.startId(body, directory: recordingBaseDirectory, kind: "location", currentId: locationRecordingId)
|
|
6597
|
+
try CraftRecordingArchive.save(recordingBaseDirectory, "location", locationRecordingSummary(), samples: try loadRecordedLocationsStrict())
|
|
6421
6598
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
6422
6599
|
try Data().write(to: locationRecordingTrackURL, options: .atomic)
|
|
6423
6600
|
protectLocationFile(locationRecordingTrackURL)
|
|
6601
|
+
} catch let error as CraftNativeActionError {
|
|
6602
|
+
rejectCallback(callbackId, error: error.message, code: error.code)
|
|
6603
|
+
return
|
|
6424
6604
|
} catch {
|
|
6425
6605
|
rejectCallback(callbackId, error: "Unable to create the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
6426
6606
|
return
|
|
6427
6607
|
}
|
|
6428
|
-
|
|
6608
|
+
let previous = (locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused)
|
|
6609
|
+
locationRecordingId = id
|
|
6429
6610
|
locationRecordingStartedAt = Date().timeIntervalSince1970 * 1000
|
|
6430
6611
|
isRecordingLocation = true
|
|
6431
6612
|
isLocationRecordingPaused = false
|
|
6432
|
-
persistLocationRecordingState()
|
|
6613
|
+
do { try persistLocationRecordingState() }
|
|
6614
|
+
catch {
|
|
6615
|
+
(locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused) = previous
|
|
6616
|
+
rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
6617
|
+
return
|
|
6618
|
+
}
|
|
6433
6619
|
locationManager?.delegate = self
|
|
6434
6620
|
requestLocationAuthorization()
|
|
6435
6621
|
configureBackgroundLocationIfNeeded()
|
|
@@ -6442,8 +6628,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6442
6628
|
rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
|
|
6443
6629
|
return
|
|
6444
6630
|
}
|
|
6631
|
+
let previous = isLocationRecordingPaused
|
|
6445
6632
|
isLocationRecordingPaused = true
|
|
6446
|
-
persistLocationRecordingState()
|
|
6633
|
+
do { try persistLocationRecordingState() }
|
|
6634
|
+
catch {
|
|
6635
|
+
isLocationRecordingPaused = previous
|
|
6636
|
+
rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
6637
|
+
return
|
|
6638
|
+
}
|
|
6447
6639
|
if !isWatchingLocation { locationManager?.stopUpdatingLocation() }
|
|
6448
6640
|
resolveCallback(callbackId, result: locationRecordingSummary())
|
|
6449
6641
|
}
|
|
@@ -6453,8 +6645,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6453
6645
|
rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
|
|
6454
6646
|
return
|
|
6455
6647
|
}
|
|
6648
|
+
let previous = isLocationRecordingPaused
|
|
6456
6649
|
isLocationRecordingPaused = false
|
|
6457
|
-
persistLocationRecordingState()
|
|
6650
|
+
do { try persistLocationRecordingState() }
|
|
6651
|
+
catch {
|
|
6652
|
+
isLocationRecordingPaused = previous
|
|
6653
|
+
rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
6654
|
+
return
|
|
6655
|
+
}
|
|
6458
6656
|
// Set here too: a recording restored at launch while paused never
|
|
6459
6657
|
// got a delegate, so resuming it started updates nobody received
|
|
6460
6658
|
// and the route stopped at the pause.
|
|
@@ -6466,17 +6664,21 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6466
6664
|
}
|
|
6467
6665
|
|
|
6468
6666
|
private func stopLocationRecording(callbackId: String?) {
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6667
|
+
do {
|
|
6668
|
+
let locations = try loadRecordedLocationsStrict()
|
|
6669
|
+
let previous = (isRecordingLocation, isLocationRecordingPaused)
|
|
6670
|
+
isRecordingLocation = false
|
|
6671
|
+
isLocationRecordingPaused = false
|
|
6672
|
+
do { try persistLocationRecordingState() }
|
|
6673
|
+
catch { (isRecordingLocation, isLocationRecordingPaused) = previous; throw error }
|
|
6674
|
+
if !isWatchingLocation {
|
|
6675
|
+
locationManager?.stopUpdatingLocation()
|
|
6676
|
+
locationManager?.allowsBackgroundLocationUpdates = false
|
|
6677
|
+
}
|
|
6678
|
+
var summary = locationRecordingSummary()
|
|
6679
|
+
summary["locations"] = locations
|
|
6680
|
+
resolveCallback(callbackId, result: summary)
|
|
6681
|
+
} catch { rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR") }
|
|
6480
6682
|
}
|
|
6481
6683
|
|
|
6482
6684
|
private func getLocationRecordingState(callbackId: String?) {
|
|
@@ -6486,7 +6688,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6486
6688
|
}
|
|
6487
6689
|
|
|
6488
6690
|
private func readLocationRecording(callbackId: String?) {
|
|
6489
|
-
resolveCallback(callbackId, result:
|
|
6691
|
+
do { resolveCallback(callbackId, result: try loadRecordedLocationsStrict()) }
|
|
6692
|
+
catch { rejectCallback(callbackId, error: "Unable to read the location recording", code: "RECORDING_STORAGE_ERROR") }
|
|
6490
6693
|
}
|
|
6491
6694
|
|
|
6492
6695
|
private func locationRecordingSummary() -> [String: Any] {
|
|
@@ -6521,8 +6724,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6521
6724
|
try? protectedURL.setResourceValues(values)
|
|
6522
6725
|
}
|
|
6523
6726
|
|
|
6524
|
-
private func loadRecordedLocations() -> [[String: Any]] {
|
|
6525
|
-
|
|
6727
|
+
private func loadRecordedLocations() -> [[String: Any]] { (try? loadRecordedLocationsStrict()) ?? [] }
|
|
6728
|
+
|
|
6729
|
+
private func loadRecordedLocationsStrict() throws -> [[String: Any]] {
|
|
6730
|
+
if locationRecordingId == nil { return [] }
|
|
6731
|
+
let text = try String(contentsOf: locationRecordingTrackURL, encoding: .utf8)
|
|
6526
6732
|
return text.split(separator: "\n").compactMap { line in
|
|
6527
6733
|
guard let data = line.data(using: .utf8) else { return nil }
|
|
6528
6734
|
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
@@ -6531,13 +6737,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6531
6737
|
|
|
6532
6738
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
6533
6739
|
guard let location = locations.last else { return }
|
|
6534
|
-
|
|
6740
|
+
var data = locationData(location)
|
|
6535
6741
|
|
|
6536
6742
|
if let callbackId = singleLocationCallbackId {
|
|
6537
6743
|
finishSingleLocationRequest()
|
|
6538
6744
|
resolveCallback(callbackId, result: data)
|
|
6539
6745
|
}
|
|
6540
6746
|
|
|
6747
|
+
if isRecordingLocation, let id = locationRecordingId { data["recordingId"] = id }
|
|
6541
6748
|
appendRecordedLocation(data)
|
|
6542
6749
|
if isWatchingLocation || isRecordingLocation {
|
|
6543
6750
|
sendToWeb("craftLocationUpdate", data: data)
|
|
@@ -8832,6 +9039,60 @@ final class CraftZigRuntime: NSObject {
|
|
|
8832
9039
|
UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64
|
|
8833
9040
|
) -> Bool
|
|
8834
9041
|
private typealias AdoptLocationRecordingFn = @convention(c) () -> Bool
|
|
9042
|
+
private typealias RecordingOwnershipVersionFn = @convention(c) () -> Int32
|
|
9043
|
+
private typealias RecordingReplyFn = @convention(c) (UnsafeMutableRawPointer?, Bool, UnsafePointer<CChar>, UInt) -> Void
|
|
9044
|
+
private typealias PerformRecordingFn = @convention(c) (UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, UnsafeMutableRawPointer?, RecordingReplyFn) -> Bool
|
|
9045
|
+
private final class RecordingReply {
|
|
9046
|
+
var answered = false
|
|
9047
|
+
var result: Result<Any, CraftNativeActionError>?
|
|
9048
|
+
|
|
9049
|
+
}
|
|
9050
|
+
private static let performRecordingFn: PerformRecordingFn? = {
|
|
9051
|
+
guard let sym = dlsym(image, "craft_ios_perform_recording_action") else { return nil }
|
|
9052
|
+
return unsafeBitCast(sym, to: PerformRecordingFn.self)
|
|
9053
|
+
}()
|
|
9054
|
+
|
|
9055
|
+
/// Synchronous native recorder requests use the exact page owner and reply
|
|
9056
|
+
/// envelope, without loading a page or creating a second location manager.
|
|
9057
|
+
static func performRecording(action: String, body: [String: Any], completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> Bool {
|
|
9058
|
+
guard supportsRecordingOwnership, let performRecordingFn else {
|
|
9059
|
+
if !isLinked { return false }
|
|
9060
|
+
completion(.failure(CraftNativeActionError(code: "RECORDING_OWNERSHIP_UNSUPPORTED", message: "The linked runtime lacks the shared recorder API")))
|
|
9061
|
+
return true
|
|
9062
|
+
}
|
|
9063
|
+
guard let payload = try? JSONSerialization.data(withJSONObject: body),
|
|
9064
|
+
let json = String(data: payload, encoding: .utf8) else {
|
|
9065
|
+
completion(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "Recording options must be JSON")))
|
|
9066
|
+
return true
|
|
9067
|
+
}
|
|
9068
|
+
let reply = RecordingReply()
|
|
9069
|
+
let served = withExtendedLifetime(reply) {
|
|
9070
|
+
action.withCString { a in json.withCString { p in
|
|
9071
|
+
performRecordingFn(a, UInt(strlen(a)), p, UInt(strlen(p)), Unmanaged.passUnretained(reply).toOpaque()) { context, failed, bytes, count in
|
|
9072
|
+
guard let context else { return }
|
|
9073
|
+
let reply = Unmanaged<RecordingReply>.fromOpaque(context).takeUnretainedValue()
|
|
9074
|
+
guard !reply.answered else { return }
|
|
9075
|
+
reply.answered = true
|
|
9076
|
+
let data = Data(bytes: bytes, count: Int(count))
|
|
9077
|
+
guard let result = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) else {
|
|
9078
|
+
reply.result = .failure(CraftNativeActionError(code: "INVALID_RESPONSE", message: "Invalid recorder response"))
|
|
9079
|
+
return
|
|
9080
|
+
}
|
|
9081
|
+
if failed {
|
|
9082
|
+
let error = result as? [String: Any] ?? [:]
|
|
9083
|
+
reply.result = .failure(CraftNativeActionError(code: error["code"] as? String ?? "CRAFT_ERROR", message: error["message"] as? String ?? "Recording failed"))
|
|
9084
|
+
} else { reply.result = .success(result) }
|
|
9085
|
+
}
|
|
9086
|
+
} }
|
|
9087
|
+
}
|
|
9088
|
+
// The C scope has ended before application code can reenter a bridge.
|
|
9089
|
+
if served, let result = reply.result { completion(result) }
|
|
9090
|
+
else {
|
|
9091
|
+
completion(.failure(CraftNativeActionError(code: "RECORDING_OWNERSHIP_UNSUPPORTED", message: "The linked recorder did not serve its synchronous request")))
|
|
9092
|
+
}
|
|
9093
|
+
return true
|
|
9094
|
+
}
|
|
9095
|
+
|
|
8835
9096
|
|
|
8836
9097
|
private static let image: UnsafeMutableRawPointer? = dlopen(nil, RTLD_NOW)
|
|
8837
9098
|
|
|
@@ -8859,6 +9120,19 @@ final class CraftZigRuntime: NSObject {
|
|
|
8859
9120
|
/// because "is Zig linked" is the first question to ask when an action
|
|
8860
9121
|
/// behaves like the Swift one after a migration was supposed to move it.
|
|
8861
9122
|
@objc static var isLinked: Bool { handleActionFn != nil }
|
|
9123
|
+
static var supportsRecordingOwnership: Bool {
|
|
9124
|
+
if !isLinked { return true }
|
|
9125
|
+
guard performRecordingFn != nil,
|
|
9126
|
+
let symbol = dlsym(image, "craft_ios_recording_ownership_version") else { return false }
|
|
9127
|
+
return unsafeBitCast(symbol, to: RecordingOwnershipVersionFn.self)() >= 1
|
|
9128
|
+
}
|
|
9129
|
+
|
|
9130
|
+
static var supportsRecordingIntentIdentity: Bool {
|
|
9131
|
+
if !isLinked { return true }
|
|
9132
|
+
guard performRecordingFn != nil,
|
|
9133
|
+
let symbol = dlsym(image, "craft_ios_recording_ownership_version") else { return false }
|
|
9134
|
+
return unsafeBitCast(symbol, to: RecordingOwnershipVersionFn.self)() >= 2
|
|
9135
|
+
}
|
|
8862
9136
|
|
|
8863
9137
|
/// Give Zig the webview its replies are evaluated against.
|
|
8864
9138
|
///
|
|
@@ -8979,6 +9253,9 @@ enum CraftWebServices {
|
|
|
8979
9253
|
completion(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "\(action) needs the app's web view, which this app does not have")))
|
|
8980
9254
|
return
|
|
8981
9255
|
}
|
|
9256
|
+
if action.hasSuffix("LocationRecording") || action == "getLocationRecordingState" {
|
|
9257
|
+
if CraftZigRuntime.performRecording(action: action, body: body, completion: completion) { return }
|
|
9258
|
+
}
|
|
8982
9259
|
let id = "native:\(UUID().uuidString)"
|
|
8983
9260
|
waiting[id] = completion
|
|
8984
9261
|
coordinator.dispatch(action: action, body: body, callbackId: id)
|
|
@@ -9021,6 +9298,17 @@ enum CraftWebServices {
|
|
|
9021
9298
|
/// how this codebase accumulated five envelopes.
|
|
9022
9299
|
@objc(CraftSwiftShim)
|
|
9023
9300
|
final class CraftSwiftShim: NSObject {
|
|
9301
|
+
/// Called on main by Zig after serializing its native event once. The
|
|
9302
|
+
/// existing page delivery remains in Zig; these are the native listeners.
|
|
9303
|
+
@objc(forwardNativeEvent:payload:)
|
|
9304
|
+
static func forwardNativeEvent(_ event: String, payload: String) {
|
|
9305
|
+
guard CraftWebServices.forwardedEvents.contains(event),
|
|
9306
|
+
let bytes = payload.data(using: .utf8),
|
|
9307
|
+
let detail = try? JSONSerialization.jsonObject(with: bytes) as? [String: Any] else { return }
|
|
9308
|
+
CraftWebServices.forward(event, data: detail)
|
|
9309
|
+
CraftEventManager.shared.sendToAppScript(event, data: detail)
|
|
9310
|
+
}
|
|
9311
|
+
|
|
9024
9312
|
/// The coordinator serving hand-offs. Set by `makeCoordinator`, or in an
|
|
9025
9313
|
/// app with no web view by `CraftHybrid.configure`, which holds it.
|
|
9026
9314
|
static weak var coordinator: CraftWebView.Coordinator?
|