@craft-native/ios 0.0.237 → 0.0.241

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