@craft-native/ios 0.0.236 → 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/app-shortcuts.d.ts +11 -0
- package/dist/cli.js +58 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +65 -1
- package/dist/testing.d.ts +2 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +392 -88
- package/templates/CraftHeartRate.swift +141 -44
- package/templates/CraftNativeActions.swift +46 -48
- package/templates/CraftNativeScreen.swift +177 -10
- 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
|
@@ -205,6 +205,12 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
|
|
|
205
205
|
// push, a refresh, a route recording's location) draws nothing, and
|
|
206
206
|
// its events still need someone to hear them.
|
|
207
207
|
CraftHybrid.shared.configure(config)
|
|
208
|
+
if let shortcuts = config.appShortcuts {
|
|
209
|
+
application.shortcutItems = shortcuts.prefix(4).map { shortcut in
|
|
210
|
+
UIApplicationShortcutItem(type: shortcut.id, localizedTitle: shortcut.title, localizedSubtitle: shortcut.subtitle,
|
|
211
|
+
icon: UIApplicationShortcutIcon(systemImageName: shortcut.symbol), userInfo: ["url": shortcut.url as NSString])
|
|
212
|
+
}
|
|
213
|
+
}
|
|
208
214
|
if let shortcut = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
|
209
215
|
CraftEventManager.shared.handleShortcut(shortcut)
|
|
210
216
|
// Returning false tells UIKit the launch-time item was handled and
|
|
@@ -415,6 +421,12 @@ class CraftEventManager {
|
|
|
415
421
|
}
|
|
416
422
|
}
|
|
417
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
|
+
|
|
418
430
|
/// An event no one will hear: logged, and background work it holds is
|
|
419
431
|
/// answered at once.
|
|
420
432
|
private func drop(_ event: String, data: [String: Any]) {
|
|
@@ -607,6 +619,12 @@ struct CraftApp: App {
|
|
|
607
619
|
// images and all, and a tab bar or a splash that waited for it would be late
|
|
608
620
|
// on every launch.
|
|
609
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
|
+
|
|
610
628
|
final class CraftChrome: ObservableObject {
|
|
611
629
|
static let shared = CraftChrome()
|
|
612
630
|
|
|
@@ -619,6 +637,21 @@ final class CraftChrome: ObservableObject {
|
|
|
619
637
|
var titleOverride: String? = nil
|
|
620
638
|
var iconTint: String? = nil
|
|
621
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
|
+
}
|
|
622
655
|
var displayTitle: String { titleOverride ?? title }
|
|
623
656
|
var accessibilityLabel: String? = nil
|
|
624
657
|
var image: String? = nil
|
|
@@ -627,7 +660,11 @@ final class CraftChrome: ObservableObject {
|
|
|
627
660
|
@Published var tabs: [Tab] = []
|
|
628
661
|
@Published var tabAccessory: Tab?
|
|
629
662
|
@Published var selected: String?
|
|
630
|
-
|
|
663
|
+
let tabSwipeProgress = CraftTabSwipeProgress()
|
|
664
|
+
private(set) var tabSwipePosition: CGFloat? {
|
|
665
|
+
get { tabSwipeProgress.position }
|
|
666
|
+
set { tabSwipeProgress.position = newValue }
|
|
667
|
+
}
|
|
631
668
|
@Published var tabBarVisible = false
|
|
632
669
|
@Published var splashVisible = true
|
|
633
670
|
@Published var tint: UIColor?
|
|
@@ -669,7 +706,7 @@ final class CraftChrome: ObservableObject {
|
|
|
669
706
|
splashVisible = splash
|
|
670
707
|
tabSwipeEnabled = true
|
|
671
708
|
guard !configured.isEmpty else { return }
|
|
672
|
-
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) }
|
|
673
710
|
self.selected = selected ?? configured.first?.id
|
|
674
711
|
tabBarVisible = true
|
|
675
712
|
}
|
|
@@ -682,27 +719,42 @@ final class CraftChrome: ObservableObject {
|
|
|
682
719
|
/// Change one tab's caption or icon without replacing stacks or selection.
|
|
683
720
|
func updateTab(_ item: [String: Any]) {
|
|
684
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]
|
|
685
726
|
if item.keys.contains("iconText") {
|
|
686
|
-
|
|
727
|
+
next.iconText = (item["iconText"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
|
|
687
728
|
}
|
|
688
729
|
if item.keys.contains("title") {
|
|
689
|
-
|
|
730
|
+
next.titleOverride = (item["title"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(40)) }
|
|
690
731
|
}
|
|
691
732
|
if item.keys.contains("iconTint") {
|
|
692
|
-
|
|
733
|
+
next.iconTint = (item["iconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 }
|
|
693
734
|
}
|
|
694
735
|
if item.keys.contains("iconPulse") {
|
|
695
|
-
|
|
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
|
|
696
746
|
}
|
|
697
747
|
if item.keys.contains("accessibilityLabel") {
|
|
698
|
-
|
|
748
|
+
next.accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
|
|
699
749
|
}
|
|
700
750
|
if item.keys.contains("image") {
|
|
701
|
-
|
|
751
|
+
next.image = (item["image"] as? String).flatMap { URL(string: $0)?.scheme == "https" ? $0 : nil }
|
|
702
752
|
}
|
|
703
753
|
if item.keys.contains("badge") {
|
|
704
|
-
|
|
754
|
+
next.badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
|
|
705
755
|
}
|
|
756
|
+
guard next != tabs[index] else { return }
|
|
757
|
+
tabs[index] = next
|
|
706
758
|
}
|
|
707
759
|
/// A destination beside the tab pill, outside the tab stacks and swipe order.
|
|
708
760
|
func updateTabAccessory(_ item: [String: Any]?) {
|
|
@@ -746,7 +798,10 @@ final class CraftChrome: ObservableObject {
|
|
|
746
798
|
tabs = (body["tabs"] as? [[String: Any]] ?? []).compactMap { item in
|
|
747
799
|
guard let id = item["id"] as? String, let title = item["title"] as? String else { return nil }
|
|
748
800
|
let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
749
|
-
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)
|
|
750
805
|
}
|
|
751
806
|
if let selected = body["selected"] as? String {
|
|
752
807
|
self.selected = selected
|
|
@@ -1102,18 +1157,7 @@ struct CraftTabBarView: View {
|
|
|
1102
1157
|
}
|
|
1103
1158
|
}
|
|
1104
1159
|
.background {
|
|
1105
|
-
|
|
1106
|
-
if let position = chrome.tabSwipePosition
|
|
1107
|
-
?? chrome.tabs.firstIndex(where: { $0.id == chrome.selected }).map({ CGFloat($0) }) {
|
|
1108
|
-
let width = geometry.size.width / CGFloat(max(1, chrome.tabs.count))
|
|
1109
|
-
Capsule()
|
|
1110
|
-
.fill(Color.primary.opacity(0.09))
|
|
1111
|
-
.frame(width: width, height: geometry.size.height)
|
|
1112
|
-
.offset(x: position * width)
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
.allowsHitTesting(false)
|
|
1116
|
-
.accessibilityHidden(true)
|
|
1160
|
+
CraftTabSelectionIndicator(selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }), count: chrome.tabs.count)
|
|
1117
1161
|
}
|
|
1118
1162
|
}
|
|
1119
1163
|
|
|
@@ -1123,13 +1167,15 @@ struct CraftTabBarView: View {
|
|
|
1123
1167
|
|
|
1124
1168
|
/// Live styling belongs to the icon, leaving selection and the caption clear.
|
|
1125
1169
|
@ViewBuilder private func tabSymbol(_ tab: CraftChrome.Tab, selected: Bool) -> some View {
|
|
1126
|
-
let
|
|
1127
|
-
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)
|
|
1128
1174
|
.font(.system(size: iconSize, weight: selected ? .semibold : .regular))
|
|
1129
1175
|
.foregroundStyle(color)
|
|
1130
1176
|
.frame(height: iconSize + 6)
|
|
1131
1177
|
if #available(iOS 17.0, *) {
|
|
1132
|
-
icon.symbolEffect(.pulse, options: .repeating, isActive:
|
|
1178
|
+
icon.symbolEffect(.pulse, options: .repeating, isActive: appearance.pulse)
|
|
1133
1179
|
} else {
|
|
1134
1180
|
icon
|
|
1135
1181
|
}
|
|
@@ -1142,6 +1188,26 @@ struct CraftTabBarView: View {
|
|
|
1142
1188
|
}
|
|
1143
1189
|
}
|
|
1144
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
|
+
|
|
1145
1211
|
/// The launch screen, held until the page is ready: the same colour and the
|
|
1146
1212
|
/// same logo at the same place, so the hand-over from the system's launch
|
|
1147
1213
|
/// screen cannot be seen.
|
|
@@ -1399,7 +1465,17 @@ extension CraftConfig {
|
|
|
1399
1465
|
}
|
|
1400
1466
|
|
|
1401
1467
|
// MARK: - Configuration
|
|
1468
|
+
struct CraftConfiguredShortcut: Codable {
|
|
1469
|
+
var id: String
|
|
1470
|
+
var title: String
|
|
1471
|
+
var subtitle: String?
|
|
1472
|
+
var symbol: String
|
|
1473
|
+
var url: String
|
|
1474
|
+
var phrases: [String]
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1402
1477
|
struct CraftConfig: Codable {
|
|
1478
|
+
var appShortcuts: [CraftConfiguredShortcut]? = nil
|
|
1403
1479
|
var appName: String = "Craft App"
|
|
1404
1480
|
var bundleId: String = "com.craft.app"
|
|
1405
1481
|
/// Opt-in UIKit + JavaScriptCore screen; existing apps keep WKWebView.
|
|
@@ -1501,6 +1577,9 @@ struct CraftConfig: Codable {
|
|
|
1501
1577
|
var id: String
|
|
1502
1578
|
var title: String
|
|
1503
1579
|
var symbol: String? = nil
|
|
1580
|
+
var selectedSymbol: String? = nil
|
|
1581
|
+
var selectedIconTint: String? = nil
|
|
1582
|
+
var selectedIconPulse: Bool? = nil
|
|
1504
1583
|
}
|
|
1505
1584
|
|
|
1506
1585
|
struct BackgroundRefresh: Codable {
|
|
@@ -1919,6 +1998,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1919
1998
|
private var isLocationRecordingPaused = false
|
|
1920
1999
|
private var locationRecordingId: String?
|
|
1921
2000
|
private var locationRecordingStartedAt: TimeInterval?
|
|
2001
|
+
private let recordingDirectory: URL?
|
|
2002
|
+
private var locationRecordingRestoreError: Error?
|
|
1922
2003
|
|
|
1923
2004
|
// Network monitoring
|
|
1924
2005
|
private var networkMonitor: NWPathMonitor?
|
|
@@ -1984,7 +2065,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1984
2065
|
// SQLite database
|
|
1985
2066
|
private var db: OpaquePointer?
|
|
1986
2067
|
|
|
1987
|
-
init(config: CraftConfig) {
|
|
2068
|
+
init(config: CraftConfig, recordingDirectory: URL? = nil) {
|
|
2069
|
+
self.recordingDirectory = recordingDirectory
|
|
1988
2070
|
self.config = config
|
|
1989
2071
|
super.init()
|
|
1990
2072
|
if config.enableSpeechRecognition {
|
|
@@ -1999,7 +2081,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1999
2081
|
// relaunch restore — see `CraftZigRuntime.adoptLocationRecording`.
|
|
2000
2082
|
// Running both would leave two managers appending to one track.
|
|
2001
2083
|
if !CraftZigRuntime.adoptLocationRecording() {
|
|
2002
|
-
restoreLocationRecordingState()
|
|
2084
|
+
do { try restoreLocationRecordingState() }
|
|
2085
|
+
catch { locationRecordingRestoreError = error }
|
|
2003
2086
|
}
|
|
2004
2087
|
}
|
|
2005
2088
|
if config.enableContacts {
|
|
@@ -2149,6 +2232,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2149
2232
|
/// `craft_ios_deliver_result` instead of evaluating JavaScript here —
|
|
2150
2233
|
/// one reply path, owned by whichever side received the page's message.
|
|
2151
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 }
|
|
2152
2242
|
if let answer = CraftNativeActions.perform(action: action, body: body, config: config) {
|
|
2153
2243
|
switch answer {
|
|
2154
2244
|
case .success(let value): resolveCallback(callbackId, result: value)
|
|
@@ -2345,20 +2435,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2345
2435
|
resolveCallback(callbackId, result: true)
|
|
2346
2436
|
case "startLocationRecording":
|
|
2347
2437
|
if config.enableGeolocation {
|
|
2348
|
-
startLocationRecording(callbackId: callbackId)
|
|
2438
|
+
startLocationRecording(body: body, callbackId: callbackId)
|
|
2349
2439
|
} else {
|
|
2350
2440
|
rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
|
|
2351
2441
|
}
|
|
2352
2442
|
case "pauseLocationRecording":
|
|
2353
|
-
pauseLocationRecording(callbackId: callbackId)
|
|
2443
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { pauseLocationRecording(callbackId: callbackId) }
|
|
2354
2444
|
case "resumeLocationRecording":
|
|
2355
|
-
resumeLocationRecording(callbackId: callbackId)
|
|
2445
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { resumeLocationRecording(callbackId: callbackId) }
|
|
2356
2446
|
case "stopLocationRecording":
|
|
2357
|
-
stopLocationRecording(callbackId: callbackId)
|
|
2447
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { stopLocationRecording(callbackId: callbackId) }
|
|
2358
2448
|
case "getLocationRecordingState":
|
|
2359
2449
|
getLocationRecordingState(callbackId: callbackId)
|
|
2360
2450
|
case "readLocationRecording":
|
|
2361
|
-
readLocationRecording(callbackId: callbackId)
|
|
2451
|
+
if requireLocationRecordingOwner(body, callbackId: callbackId) { readLocationRecording(callbackId: callbackId) }
|
|
2452
|
+
case "acknowledgeLocationRecording":
|
|
2453
|
+
_ = archivedLocationAction(action, body: body, callbackId: callbackId)
|
|
2362
2454
|
// App Badge
|
|
2363
2455
|
case "setBadge":
|
|
2364
2456
|
if let count = body["count"] as? Int {
|
|
@@ -3666,6 +3758,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3666
3758
|
pushNotifications: \(config.enablePushNotifications),
|
|
3667
3759
|
secureStorage: \(config.enableSecureStorage),
|
|
3668
3760
|
geolocation: \(config.enableGeolocation),
|
|
3761
|
+
crypto: true,
|
|
3762
|
+
locationRecordingOwnership: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingOwnership),
|
|
3763
|
+
locationRecordingIntentIdentity: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingIntentIdentity),
|
|
3669
3764
|
backgroundLocation: \(config.enableBackgroundLocation),
|
|
3670
3765
|
healthKit: \(config.enableHealthKit),
|
|
3671
3766
|
liveActivities: \(config.enableLiveActivities),
|
|
@@ -3682,6 +3777,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3682
3777
|
brightness: true,
|
|
3683
3778
|
music: \(config.enableAppleMusic),
|
|
3684
3779
|
heartRate: \(config.enableHeartRate),
|
|
3780
|
+
heartRateRecordingOwnership: \(config.enableHeartRate),
|
|
3781
|
+
heartRateRecordingIntentIdentity: \(config.enableHeartRate),
|
|
3685
3782
|
scheduledWorkouts: \(config.enableScheduledWorkouts),
|
|
3686
3783
|
speech: true,
|
|
3687
3784
|
crashReporter: \(CraftCrashReporter.shared.enabled),
|
|
@@ -5266,6 +5363,20 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5266
5363
|
browserOpen: true
|
|
5267
5364
|
};
|
|
5268
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
|
+
}
|
|
5269
5380
|
var self = craft;
|
|
5270
5381
|
var id = 'cb_' + (++self._callbackId);
|
|
5271
5382
|
var message = Object.assign({}, payload || {}, {action: action, callbackId: id});
|
|
@@ -5403,6 +5514,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5403
5514
|
legacyLocationWatchCallbacks.length = 0;
|
|
5404
5515
|
return stopLocationWatchIfUnused();
|
|
5405
5516
|
};
|
|
5517
|
+
craft.crypto = { randomUUID: function() { return craft._invoke('cryptoRandomUUID'); } };
|
|
5406
5518
|
craft.location = {
|
|
5407
5519
|
getCurrentPosition: function(options) { return legacyGeolocation.getCurrentPosition(options || {}); },
|
|
5408
5520
|
watchPosition: function(callback) {
|
|
@@ -5421,12 +5533,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5421
5533
|
if (!locationWatchCallbacks.delete(id)) return;
|
|
5422
5534
|
void stopLocationWatchIfUnused().catch(function() {});
|
|
5423
5535
|
},
|
|
5424
|
-
startRecording: function() { return craft._invoke('startLocationRecording'); },
|
|
5425
|
-
pauseRecording: function() { return craft._invoke('pauseLocationRecording'); },
|
|
5426
|
-
resumeRecording: function() { return craft._invoke('resumeLocationRecording'); },
|
|
5427
|
-
stopRecording: function() { return craft._invoke('stopLocationRecording'); },
|
|
5428
|
-
getRecordingState: function() { return craft._invoke('getLocationRecordingState'); },
|
|
5429
|
-
|
|
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 || {}); }
|
|
5430
5543
|
};
|
|
5431
5544
|
craft.health = {
|
|
5432
5545
|
requestAuthorization: function(types, options) {
|
|
@@ -5514,12 +5627,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5514
5627
|
connect: function(id) { return heartRate('connect', {id: typeof id === 'string' ? id : id && id.id}); },
|
|
5515
5628
|
reconnect: function() { return heartRate('reconnect'); },
|
|
5516
5629
|
disconnect: function(options) { return heartRate('disconnect', {forget: !!(options && options.forget)}); },
|
|
5517
|
-
startRecording: function() { return heartRate('startRecording'); },
|
|
5518
|
-
pauseRecording: function() { return heartRate('pauseRecording'); },
|
|
5519
|
-
resumeRecording: function() { return heartRate('resumeRecording'); },
|
|
5520
|
-
stopRecording: function() { return heartRate('stopRecording'); },
|
|
5521
|
-
getRecordingState: function() { return heartRate('getRecordingState'); },
|
|
5522
|
-
|
|
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 || {}); }
|
|
5523
5637
|
};
|
|
5524
5638
|
})();
|
|
5525
5639
|
// Structured workouts on the paired Apple Watch (WorkoutKit,
|
|
@@ -6344,12 +6458,12 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6344
6458
|
}
|
|
6345
6459
|
|
|
6346
6460
|
private var locationRecordingStateURL: URL {
|
|
6347
|
-
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
6461
|
+
(recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0])
|
|
6348
6462
|
.appendingPathComponent("craft-location-recording-state.json")
|
|
6349
6463
|
}
|
|
6350
6464
|
|
|
6351
6465
|
private var locationRecordingTrackURL: URL {
|
|
6352
|
-
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
6466
|
+
(recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0])
|
|
6353
6467
|
.appendingPathComponent("craft-location-recording.jsonl")
|
|
6354
6468
|
}
|
|
6355
6469
|
|
|
@@ -6366,54 +6480,142 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6366
6480
|
locationManager?.showsBackgroundLocationIndicator = config.enableBackgroundLocation
|
|
6367
6481
|
}
|
|
6368
6482
|
|
|
6369
|
-
private func persistLocationRecordingState() {
|
|
6370
|
-
let state: [String: Any] = [
|
|
6371
|
-
|
|
6372
|
-
"paused": isLocationRecordingPaused,
|
|
6373
|
-
"id": locationRecordingId ?? NSNull(),
|
|
6374
|
-
"startedAt": locationRecordingStartedAt ?? NSNull(),
|
|
6375
|
-
]
|
|
6376
|
-
do {
|
|
6377
|
-
let directory = locationRecordingStateURL.deletingLastPathComponent()
|
|
6378
|
-
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
6379
|
-
let data = try JSONSerialization.data(withJSONObject: state)
|
|
6380
|
-
try data.write(to: locationRecordingStateURL, options: .atomic)
|
|
6381
|
-
protectLocationFile(locationRecordingStateURL)
|
|
6382
|
-
} catch {
|
|
6383
|
-
print("Unable to persist location recording state: \(error)")
|
|
6384
|
-
}
|
|
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)
|
|
6385
6486
|
}
|
|
6386
6487
|
|
|
6387
|
-
private func restoreLocationRecordingState() {
|
|
6388
|
-
guard
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
|
|
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
|
|
6392
6501
|
isLocationRecordingPaused = state["paused"] as? Bool ?? false
|
|
6393
6502
|
locationRecordingId = state["id"] as? String
|
|
6394
6503
|
locationRecordingStartedAt = state["startedAt"] as? TimeInterval
|
|
6395
|
-
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 {
|
|
6396
6514
|
locationManager?.delegate = self
|
|
6397
6515
|
configureBackgroundLocationIfNeeded()
|
|
6398
6516
|
locationManager?.startUpdatingLocation()
|
|
6399
6517
|
}
|
|
6400
6518
|
}
|
|
6401
6519
|
|
|
6402
|
-
|
|
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
|
+
}
|
|
6403
6593
|
let directory = locationRecordingTrackURL.deletingLastPathComponent()
|
|
6594
|
+
let id: String
|
|
6404
6595
|
do {
|
|
6596
|
+
id = try CraftRecordingArchive.startId(body, directory: recordingBaseDirectory, kind: "location", currentId: locationRecordingId)
|
|
6597
|
+
try CraftRecordingArchive.save(recordingBaseDirectory, "location", locationRecordingSummary(), samples: try loadRecordedLocationsStrict())
|
|
6405
6598
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
6406
6599
|
try Data().write(to: locationRecordingTrackURL, options: .atomic)
|
|
6407
6600
|
protectLocationFile(locationRecordingTrackURL)
|
|
6601
|
+
} catch let error as CraftNativeActionError {
|
|
6602
|
+
rejectCallback(callbackId, error: error.message, code: error.code)
|
|
6603
|
+
return
|
|
6408
6604
|
} catch {
|
|
6409
6605
|
rejectCallback(callbackId, error: "Unable to create the location recording", code: "RECORDING_STORAGE_ERROR")
|
|
6410
6606
|
return
|
|
6411
6607
|
}
|
|
6412
|
-
|
|
6608
|
+
let previous = (locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused)
|
|
6609
|
+
locationRecordingId = id
|
|
6413
6610
|
locationRecordingStartedAt = Date().timeIntervalSince1970 * 1000
|
|
6414
6611
|
isRecordingLocation = true
|
|
6415
6612
|
isLocationRecordingPaused = false
|
|
6416
|
-
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
|
+
}
|
|
6417
6619
|
locationManager?.delegate = self
|
|
6418
6620
|
requestLocationAuthorization()
|
|
6419
6621
|
configureBackgroundLocationIfNeeded()
|
|
@@ -6426,8 +6628,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6426
6628
|
rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
|
|
6427
6629
|
return
|
|
6428
6630
|
}
|
|
6631
|
+
let previous = isLocationRecordingPaused
|
|
6429
6632
|
isLocationRecordingPaused = true
|
|
6430
|
-
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
|
+
}
|
|
6431
6639
|
if !isWatchingLocation { locationManager?.stopUpdatingLocation() }
|
|
6432
6640
|
resolveCallback(callbackId, result: locationRecordingSummary())
|
|
6433
6641
|
}
|
|
@@ -6437,8 +6645,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6437
6645
|
rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
|
|
6438
6646
|
return
|
|
6439
6647
|
}
|
|
6648
|
+
let previous = isLocationRecordingPaused
|
|
6440
6649
|
isLocationRecordingPaused = false
|
|
6441
|
-
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
|
+
}
|
|
6442
6656
|
// Set here too: a recording restored at launch while paused never
|
|
6443
6657
|
// got a delegate, so resuming it started updates nobody received
|
|
6444
6658
|
// and the route stopped at the pause.
|
|
@@ -6450,17 +6664,21 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6450
6664
|
}
|
|
6451
6665
|
|
|
6452
6666
|
private func stopLocationRecording(callbackId: String?) {
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
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") }
|
|
6464
6682
|
}
|
|
6465
6683
|
|
|
6466
6684
|
private func getLocationRecordingState(callbackId: String?) {
|
|
@@ -6470,7 +6688,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6470
6688
|
}
|
|
6471
6689
|
|
|
6472
6690
|
private func readLocationRecording(callbackId: String?) {
|
|
6473
|
-
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") }
|
|
6474
6693
|
}
|
|
6475
6694
|
|
|
6476
6695
|
private func locationRecordingSummary() -> [String: Any] {
|
|
@@ -6505,8 +6724,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6505
6724
|
try? protectedURL.setResourceValues(values)
|
|
6506
6725
|
}
|
|
6507
6726
|
|
|
6508
|
-
private func loadRecordedLocations() -> [[String: Any]] {
|
|
6509
|
-
|
|
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)
|
|
6510
6732
|
return text.split(separator: "\n").compactMap { line in
|
|
6511
6733
|
guard let data = line.data(using: .utf8) else { return nil }
|
|
6512
6734
|
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
@@ -6515,13 +6737,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6515
6737
|
|
|
6516
6738
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
6517
6739
|
guard let location = locations.last else { return }
|
|
6518
|
-
|
|
6740
|
+
var data = locationData(location)
|
|
6519
6741
|
|
|
6520
6742
|
if let callbackId = singleLocationCallbackId {
|
|
6521
6743
|
finishSingleLocationRequest()
|
|
6522
6744
|
resolveCallback(callbackId, result: data)
|
|
6523
6745
|
}
|
|
6524
6746
|
|
|
6747
|
+
if isRecordingLocation, let id = locationRecordingId { data["recordingId"] = id }
|
|
6525
6748
|
appendRecordedLocation(data)
|
|
6526
6749
|
if isWatchingLocation || isRecordingLocation {
|
|
6527
6750
|
sendToWeb("craftLocationUpdate", data: data)
|
|
@@ -8816,6 +9039,60 @@ final class CraftZigRuntime: NSObject {
|
|
|
8816
9039
|
UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64
|
|
8817
9040
|
) -> Bool
|
|
8818
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
|
+
|
|
8819
9096
|
|
|
8820
9097
|
private static let image: UnsafeMutableRawPointer? = dlopen(nil, RTLD_NOW)
|
|
8821
9098
|
|
|
@@ -8843,6 +9120,19 @@ final class CraftZigRuntime: NSObject {
|
|
|
8843
9120
|
/// because "is Zig linked" is the first question to ask when an action
|
|
8844
9121
|
/// behaves like the Swift one after a migration was supposed to move it.
|
|
8845
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
|
+
}
|
|
8846
9136
|
|
|
8847
9137
|
/// Give Zig the webview its replies are evaluated against.
|
|
8848
9138
|
///
|
|
@@ -8963,6 +9253,9 @@ enum CraftWebServices {
|
|
|
8963
9253
|
completion(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "\(action) needs the app's web view, which this app does not have")))
|
|
8964
9254
|
return
|
|
8965
9255
|
}
|
|
9256
|
+
if action.hasSuffix("LocationRecording") || action == "getLocationRecordingState" {
|
|
9257
|
+
if CraftZigRuntime.performRecording(action: action, body: body, completion: completion) { return }
|
|
9258
|
+
}
|
|
8966
9259
|
let id = "native:\(UUID().uuidString)"
|
|
8967
9260
|
waiting[id] = completion
|
|
8968
9261
|
coordinator.dispatch(action: action, body: body, callbackId: id)
|
|
@@ -9005,6 +9298,17 @@ enum CraftWebServices {
|
|
|
9005
9298
|
/// how this codebase accumulated five envelopes.
|
|
9006
9299
|
@objc(CraftSwiftShim)
|
|
9007
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
|
+
|
|
9008
9312
|
/// The coordinator serving hand-offs. Set by `makeCoordinator`, or in an
|
|
9009
9313
|
/// app with no web view by `CraftHybrid.configure`, which holds it.
|
|
9010
9314
|
static weak var coordinator: CraftWebView.Coordinator?
|